diff --git a/crates/ide-core/src/event.rs b/crates/ide-core/src/event.rs index 5de98978..e2d45fec 100644 --- a/crates/ide-core/src/event.rs +++ b/crates/ide-core/src/event.rs @@ -82,6 +82,12 @@ pub enum IdeEvent { status: ServerStatus, message: Option, }, + /// A running server lacks a capability; the UI offers the managed tool as a replacement. + LanguageServerWarning { + name: String, + message: String, + install_tool: String, + }, /// A stopped language server left the project's list (disabled in settings, or a /// fallback that a preferred server took over from again). LanguageServerRemoved { diff --git a/crates/ide-core/src/workflow_settings.rs b/crates/ide-core/src/workflow_settings.rs index 64fb58fb..3b31a576 100644 --- a/crates/ide-core/src/workflow_settings.rs +++ b/crates/ide-core/src/workflow_settings.rs @@ -41,6 +41,8 @@ impl Default for VcsSettings { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] pub struct GitSettings { + /// Show blame after the current editor line. + pub inline_blame: bool, /// Path to the git executable; empty = auto-detect. pub executable: String, /// Default of Update Project: `merge` | `rebase`. @@ -71,6 +73,7 @@ pub struct GitSettings { impl Default for GitSettings { fn default() -> Self { Self { + inline_blame: false, executable: String::new(), update_method: "merge".into(), auto_update_on_push_rejected: false, diff --git a/crates/ide-host/src/formatter.rs b/crates/ide-host/src/formatter.rs index 7bc9af9d..054497b3 100644 --- a/crates/ide-host/src/formatter.rs +++ b/crates/ide-host/src/formatter.rs @@ -7,6 +7,16 @@ use ide_core::{DocId, IdeError, IdeEvent, IdeResult, Range, TextEdit}; use ide_language::FormatterConfig; use ide_lsp::types::FormatOptions; use ide_tools::{Tools, formatter}; +use std::path::Path; + +const OXFMT_CONFIGS: [&str; 4] = [".oxfmtrc.json", ".oxfmtrc.jsonc", "oxfmt.config.ts", "oxfmt.config.mts"]; + +/// oxfmt below an oxfmt configuration file, else Prettier. The Prettier +/// settings (languages, preference, on save) apply to both. +fn external_tool(file: &Path) -> &'static str { + let oxfmt = OXFMT_CONFIGS.iter().any(|name| formatter::find_up(file, Path::new(name)).is_some()); + if oxfmt { "oxfmt" } else { "prettier" } +} fn external_selected(config: Option<&FormatterConfig>, language: &str, settings: &PrettierSettings, lsp: bool) -> bool { config.is_some_and(|c| c.tool == "prettier") @@ -44,16 +54,19 @@ pub async fn format( return Err(IdeError::invalid("The file is read-only")); } let config = config.unwrap().clone(); - let custom = ide_sdk::apply::setting_path(&project.root, &prettier.path); + let tool = external_tool(&snap.path); + let name = formatter::display_name(tool); + let custom = ide_sdk::apply::setting_path(&project.root, &prettier.path).filter(|_| tool == "prettier"); let tools = Tools::new(&host.local_data_dir); - let whole_selection = range.is_some() && !config.range; + let whole_selection = range.is_some() && !formatter::supports_range(tool, &config); let edits: IdeResult> = tokio::task::spawn_blocking(move || { let text = ide_text::Document::new(&snap.text); let offsets = range.map(|r| (text.pos_to_offset(r.start), text.pos_to_offset(r.end))); - let mut command = tools.formatter_command(&config.tool, &snap.path, custom.as_deref())?; - command.current_dir(snap.path.parent().ok_or_else(|| IdeError::invalid("Prettier: file has no directory"))?); - command.args(formatter::arguments(&config, &snap.path, options.tab_size, options.insert_spaces, offsets)); - let formatted = formatter::run(command, &snap.text, &Default::default())?; + let mut command = tools.formatter_command(tool, &snap.path, custom.as_deref())?; + let dir = snap.path.parent().ok_or_else(|| IdeError::invalid(format!("{name}: file has no directory")))?; + command.current_dir(dir); + command.args(formatter::arguments(tool, &config, &snap.path, options.tab_size, options.insert_spaces, offsets)); + let formatted = formatter::run(tool, command, &snap.text, &Default::default())?; Ok(text .diff(&formatted) .changes @@ -69,13 +82,13 @@ pub async fn format( if edits.is_ok() && whole_selection { project.sink.emit(IdeEvent::Notify { level: NotifyLevel::Info, - title: "Prettier".into(), - body: Some( - "Prettier does not support selection formatting for this language; formatted the whole file".into(), - ), + title: name.into(), + body: Some(format!( + "{name} does not support selection formatting for this language; formatted the whole file" + )), }); } - edits.map_err(|e| e.with_tool("prettier")) + edits.map_err(|e| e.with_tool(tool)) } #[cfg(test)] @@ -104,4 +117,19 @@ mod tests { assert!(!external_selected(ts.formatter.as_ref(), "typescript", &settings, false)); assert!(!external_selected(None, "rust", &settings, false)); } + + #[test] + fn oxfmt_config_selects_oxfmt_over_prettier() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("web/src")).unwrap(); + let (web, api) = (root.join("web/src/app.ts"), root.join("api/main.ts")); + assert_eq!(external_tool(&web), "prettier"); + for config in OXFMT_CONFIGS { + std::fs::write(root.join("web").join(config), "{}").unwrap(); + assert_eq!(external_tool(&web), "oxfmt", "{config}"); + assert_eq!(external_tool(&api), "prettier", "{config}"); + std::fs::remove_file(root.join("web").join(config)).unwrap(); + } + } } diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs index 31a9eda2..58494359 100644 --- a/crates/ide-language/src/registry.rs +++ b/crates/ide-language/src/registry.rs @@ -8,6 +8,7 @@ use std::sync::Arc; /// Built-in definitions, embedded at compile time. Add new languages here. const BUILTIN: &[(&str, &str)] = &[ + ("asciidoc", include_str!("../../../languages/asciidoc.toml")), ("bat", include_str!("../../../languages/bat.toml")), ("c", include_str!("../../../languages/c.toml")), ("clojure", include_str!("../../../languages/clojure.toml")), @@ -15,6 +16,7 @@ const BUILTIN: &[(&str, &str)] = &[ ("cpp", include_str!("../../../languages/cpp.toml")), ("csharp", include_str!("../../../languages/csharp.toml")), ("css", include_str!("../../../languages/css.toml")), + ("d", include_str!("../../../languages/d.toml")), ("dart", include_str!("../../../languages/dart.toml")), ("diff", include_str!("../../../languages/diff.toml")), ("dockerfile", include_str!("../../../languages/dockerfile.toml")), @@ -373,6 +375,7 @@ mod tests { /// `name`s exported by `@codemirror/language-data` or built into `src/editor/core/cm/languages.ts` /// (the UI resolves `ui-mode` against these). const UI_MODES: &[&str] = &[ + "AsciiDoc", "Batch", "C", "C++", @@ -532,6 +535,12 @@ mod tests { let reg = reg(); assert_eq!(detect(®, "src/main.rs"), "rust"); assert_eq!(detect(®, "a/b.TS"), "typescript"); + for path in ["guide.adoc", "guide.asciidoc", "GUIDE.ADOC"] { + assert_eq!(detect(®, path), "asciidoc"); + } + let asciidoc = reg.get(&LanguageId::new("asciidoc")).unwrap(); + assert_eq!(asciidoc.ui_mode.as_deref(), Some("AsciiDoc")); + assert_eq!(asciidoc.line_comment.as_deref(), Some("//")); assert_eq!(reg.detect(Path::new("script"), Some("#!/usr/bin/env python3")).as_str(), "python"); assert_eq!(detect(®, "README"), "plaintext"); assert_eq!(detect(®, "unknown.zzz"), "plaintext"); @@ -567,7 +576,7 @@ mod tests { }; // After the TypeScript fallback (an available server before it would hide // it); before the HTML server (hover, rename and symbols take the first). - assert_eq!(names("typescript"), ["vtsls", "typescript-language-server", "angular-language-server"]); + assert_eq!(names("typescript"), ["vtsls", "typescript-language-server", "angular-language-server", "oxlint"]); assert_eq!(names("html"), ["angular-language-server", "vscode-html-language-server"]); for lang in ["typescript", "html"] { let cfg = reg.get(&LanguageId::new(lang)).unwrap(); @@ -577,6 +586,24 @@ mod tests { } } + #[test] + fn oxlint_serves_js_files_only_below_its_config() { + let reg = reg(); + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::create_dir_all(root.join("apps/web/src")).unwrap(); + std::fs::write(root.join("apps/web/.oxlintrc.json"), "{}").unwrap(); + for lang in ["javascript", "javascriptreact", "typescript", "typescriptreact", "vue", "svelte"] { + let cfg = reg.get(&LanguageId::new(lang)).unwrap(); + let oxlint = cfg.language_servers.iter().find(|s| s.name == "oxlint").unwrap(); + assert_eq!((oxlint.command.as_str(), oxlint.args.as_slice()), ("oxlint", ["--lsp".to_string()].as_slice())); + assert!(oxlint.require_root_marker && !oxlint.fallback, "{lang}"); + let served = |file: &str| crate::marked_root(Some(root), &root.join(file), &oxlint.root_markers); + assert_eq!(served("apps/web/src/main.ts"), Some(root.join("apps/web")), "{lang}"); + assert_eq!(served("apps/api/main.ts"), None, "{lang}"); + } + } + #[test] fn html_of_angular_projects_is_an_angular_template() { let reg = reg(); diff --git a/crates/ide-lsp/src/clang_tidy.rs b/crates/ide-lsp/src/clang_tidy.rs new file mode 100644 index 00000000..351ae473 --- /dev/null +++ b/crates/ide-lsp/src/clang_tidy.rs @@ -0,0 +1,126 @@ +//! Apple clangd accepts `.clang-tidy` projects but ships without clang-tidy checks. + +use crate::{LspManager, Server}; +use ide_core::IdeEvent; +use std::path::Path; + +pub(crate) const MESSAGE: &str = "Apple's clangd does not run clang-tidy, so checks from .clang-tidy are not shown. Install clangd (LLVM) to get them."; + +fn is_apple_clangd(path: &Path) -> bool { + if path == Path::new("/usr/bin/clangd") { + return true; + } + if !path.ends_with("usr/bin/clangd") { + return false; + } + path.starts_with("/Library/Developer/CommandLineTools") + || path.ancestors().any(|ancestor| { + ancestor.file_name().is_some_and(|name| name == "Toolchains") + && ancestor.parent().is_some_and(|developer| { + developer.ends_with("Contents/Developer") + && developer.parent().and_then(Path::parent).and_then(Path::file_name).is_some_and(|name| { + let name = name.to_string_lossy(); + name.starts_with("Xcode") && name.ends_with(".app") + }) + }) + }) +} + +fn has_config(server_root: &Path, project_root: &Path, file: &Path) -> bool { + server_root.join(".clang-tidy").is_file() + || file + .parent() + .into_iter() + .flat_map(Path::ancestors) + .take_while(|dir| dir.starts_with(project_root)) + .any(|dir| dir.join(".clang-tidy").is_file()) +} + +impl LspManager { + pub(crate) async fn check_clang_tidy(&self, server: &Server) { + if server.name != "clangd" + || !cfg!(target_os = "macos") + || self.shown_messages.lock().contains(&("clangd".into(), MESSAGE.into())) + { + return; + } + let binary = server.resolved_binary.read().clone(); + let Some(binary) = binary else { return }; + let files: Vec<_> = self + .docs + .lock() + .values() + .filter(|doc| { + matches!(doc.language.as_str(), "c" | "cpp" | "objective-c" | "objective-cpp") + && doc.servers.iter().any(|attached| attached.server.id == server.id) + }) + .map(|doc| doc.path.clone()) + .collect(); + let root = server.root.clone(); + let Some(me) = self.me.upgrade() else { return }; + let _ = tokio::task::spawn_blocking(move || { + if !is_apple_clangd(&binary) && !std::fs::canonicalize(&binary).is_ok_and(|path| is_apple_clangd(&path)) { + return; + } + if !me.shutting_down.load(std::sync::atomic::Ordering::Acquire) + && files.iter().any(|file| has_config(&root, &me.project_root_for(file), file)) + && me.first_message("clangd", MESSAGE) + { + me.sink.emit(IdeEvent::LanguageServerWarning { + name: "clangd".into(), + message: MESSAGE.into(), + install_tool: "clangd".into(), + }); + } + }) + .await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn apple_paths() { + for path in [ + "/usr/bin/clangd", + "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clangd", + "/Applications/Xcode-beta.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clangd", + "/Library/Developer/CommandLineTools/usr/bin/clangd", + ] { + assert!(is_apple_clangd(Path::new(path)), "{path}"); + } + for path in [ + "/Users/me/Library/Application Support/openidea/tools/clangd/bin/clangd", + "/opt/homebrew/opt/llvm/bin/clangd", + "/usr/local/bin/clangd", + "/usr/bin/clangd-custom", + "/Library/Developer/CommandLineTools-custom/usr/bin/clangd", + "/Applications/Other.app/Contents/Developer/Toolchains/LLVM.xctoolchain/usr/bin/clangd", + ] { + assert!(!is_apple_clangd(Path::new(path)), "{path}"); + } + } + + #[test] + fn config_in_server_root_or_file_ancestors_stays_inside_project() { + let dir = tempfile::tempdir().unwrap(); + let project = dir.path().join("project"); + let server = project.join("build"); + let source = project.join("src/nested"); + let file = source.join("main.cpp"); + std::fs::create_dir_all(&source).unwrap(); + std::fs::create_dir_all(&server).unwrap(); + std::fs::write(dir.path().join(".clang-tidy"), "").unwrap(); + assert!(!has_config(&server, &project, &file)); + for root in [&server, &source, &project.join("src"), &project] { + let config = root.join(".clang-tidy"); + std::fs::write(&config, "Checks: '-*,modernize-use-nullptr'").unwrap(); + assert!(has_config(&server, &project, &file), "{}", root.display()); + std::fs::remove_file(config).unwrap(); + } + std::fs::create_dir(source.join(".clang-tidy")).unwrap(); + assert!(!has_config(&server, &project, &file)); + } +} diff --git a/crates/ide-lsp/src/launch.rs b/crates/ide-lsp/src/launch.rs index 36ae556c..5c7d53c0 100644 --- a/crates/ide-lsp/src/launch.rs +++ b/crates/ide-lsp/src/launch.rs @@ -43,6 +43,18 @@ pub trait Launcher: Send + Sync + 'static { self.launch(config, root, handler, on_stderr) } + /// Starts a server and reports its executable when the launcher runs a local process. + fn launch_resolved( + &self, + config: &LanguageServerConfig, + root: &Path, + handler: Arc, + on_stderr: Box, + cancel: &CancellationToken, + ) -> Result<(Peer, Option), LaunchError> { + self.launch_cancellable(config, root, handler, on_stderr, cancel).map(|peer| (peer, None)) + } + /// Whether `config` could start without a `Missing` error. fn available(&self, _config: &LanguageServerConfig, _root: &Path) -> bool { true @@ -72,6 +84,17 @@ impl Launcher for ProcessLauncher { on_stderr: Box, cancel: &CancellationToken, ) -> Result { + self.launch_resolved(config, root, handler, on_stderr, cancel).map(|(peer, _)| peer) + } + + fn launch_resolved( + &self, + config: &LanguageServerConfig, + root: &Path, + handler: Arc, + on_stderr: Box, + cancel: &CancellationToken, + ) -> Result<(Peer, Option), LaunchError> { let search = server_search_path(config); let binary = ide_core::tool_path::find_command(&config.command, &search, root) .and_then(|b| resolve_rustup_proxy(&config.command, b)); @@ -87,10 +110,12 @@ impl Launcher for ProcessLauncher { handler, on_stderr, cancel, - ); + ) + .map(|peer| (peer, None)); } let binary = binary.ok_or(LaunchError::Missing)?; Peer::spawn(&binary.to_string_lossy(), &config.args, root, &env, handler, on_stderr) + .map(|peer| (peer, Some(binary.clone()))) .map_err(|e| LaunchError::Failed(format!("failed to start {}: {e}", binary.display()))) } diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 52145184..d8ead994 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -8,6 +8,7 @@ mod angular; mod caps; +mod clang_tidy; mod convert; mod external_project; mod file_ops; @@ -472,13 +473,16 @@ impl LspManager { if !me.execution_allowed.load(Ordering::Acquire) || cancel.is_cancelled() { return Err(LaunchError::Config("Project is in Restricted Mode".into())); } - me.launcher.launch_cancellable(&cfg, &root, handler, Box::new(move |line| tail.push_stderr(line)), &cancel) + me.launcher.launch_resolved(&cfg, &root, handler, Box::new(move |line| tail.push_stderr(line)), &cancel) }) .await .unwrap_or_else(|e| Err(LaunchError::Failed(e.to_string()))); let peer = match launched { - Ok(peer) => peer, + Ok((peer, binary)) => { + *server.resolved_binary.write() = binary; + peer + } Err(LaunchError::Missing) => { let cfg = server.effective_config(); let message = @@ -587,6 +591,7 @@ impl LspManager { } } } + self.check_clang_tidy(&server).await; server.refresh_busy_status(&self.sink); server.announce_ready(generation); self.spawn_supervisor(server, generation, peer); @@ -810,6 +815,12 @@ impl LspManager { } for s in &servers { self.ensure_started(s); + if s.running().is_some() + && let Some(me) = self.me.upgrade() + { + let server = s.clone(); + self.runtime.spawn(async move { me.check_clang_tidy(&server).await }); + } } } diff --git a/crates/ide-lsp/src/server.rs b/crates/ide-lsp/src/server.rs index 81c21b02..f15a893e 100644 --- a/crates/ide-lsp/src/server.rs +++ b/crates/ide-lsp/src/server.rs @@ -79,6 +79,7 @@ pub struct Server { pub config: LanguageServerConfig, /// `config` with the settings hook applied, as the current instance was launched. effective: Mutex>, + pub resolved_binary: RwLock>, languages: Mutex>, state: Mutex, /// Fired when the instance being started is abandoned (stop / restart while starting). @@ -114,6 +115,7 @@ impl Server { root, config, effective: Mutex::new(None), + resolved_binary: RwLock::new(None), languages: Mutex::new(vec![language]), state: Mutex::new(State { generation: 0, conn: Conn::Idle }), start_cancel: Mutex::new(CancellationToken::new()), diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 7bfb45cc..d2136467 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -21,6 +21,7 @@ use std::time::Duration; struct MockState { caps: Mutex, missing: std::sync::atomic::AtomicBool, + binary: Mutex>, launches: AtomicUsize, notifications: Mutex>, requests: Mutex>, @@ -148,6 +149,18 @@ impl MockLauncher { } impl Launcher for MockLauncher { + fn launch_resolved( + &self, + config: &LanguageServerConfig, + root: &Path, + handler: Arc, + on_stderr: Box, + cancel: &ide_core::CancellationToken, + ) -> Result<(Peer, Option), LaunchError> { + self.launch_cancellable(config, root, handler, on_stderr, cancel) + .map(|peer| (peer, self.state(&config.name).binary.lock().clone())) + } + fn launch( &self, config: &LanguageServerConfig, @@ -2836,3 +2849,68 @@ async fn formatting_provider_selection_tracks_dynamic_registration() { .unwrap(); assert!(!h.mgr.can_format(doc).await.unwrap()); } + +#[cfg(target_os = "macos")] +#[tokio::test] +async fn apple_clangd_warns_for_tidy_config_once_across_languages_and_restarts() { + for language in ["c", "cpp", "objective-c", "objective-cpp"] { + let h = harness_for(language, "src", &[("clangd", incremental_caps())], &[]); + *h.launcher.state("clangd").binary.lock() = Some(PathBuf::from("/usr/bin/clangd")); + std::fs::write(h.root.join(".clang-tidy"), "Checks: '-*,modernize-use-nullptr'").unwrap(); + h.open("main.src", "int *p = 0;"); + eventually(|| h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. }))).await; + let id = h.mgr.servers()[0].id; + h.mgr.restart(id).await.unwrap(); + h.wait_status("clangd", ServerStatus::Ready).await; + h.open("other.src", "int *p = 0;"); + h.mgr.shutdown_all().await; + let events = h.events.lock(); + let warnings: Vec<_> = events + .iter() + .filter_map(|e| match e { + IdeEvent::LanguageServerWarning { name, message, install_tool } => Some((name, message, install_tool)), + _ => None, + }) + .collect(); + assert_eq!(warnings.len(), 1, "{language}"); + assert_eq!(warnings[0].0, "clangd"); + assert!(warnings[0].1.contains(".clang-tidy")); + assert_eq!(warnings[0].2, "clangd"); + } +} + +#[cfg(target_os = "macos")] +#[tokio::test] +async fn clang_tidy_warning_requires_apple_binary_and_a_config() { + for (binary, config) in [ + ("/usr/bin/clangd", false), + ("/opt/homebrew/opt/llvm/bin/clangd", true), + ("/Users/me/Library/Application Support/openidea/tools/clangd/bin/clangd", true), + ] { + let h = harness_for("cpp", "cpp", &[("clangd", incremental_caps())], &[]); + *h.launcher.state("clangd").binary.lock() = Some(PathBuf::from(binary)); + if config { + std::fs::write(h.root.join(".clang-tidy"), "Checks: '-*,modernize-use-nullptr'").unwrap(); + } + h.open("main.cpp", "int *p = 0;"); + h.wait_status("clangd", ServerStatus::Ready).await; + h.mgr.shutdown_all().await; + assert!(!h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. })), "{binary}"); + } +} + +#[cfg(target_os = "macos")] +#[tokio::test] +async fn clang_tidy_config_for_a_later_opened_file_warns_on_a_running_server() { + let h = harness_for("cpp", "cpp", &[("clangd", incremental_caps())], &[]); + *h.launcher.state("clangd").binary.lock() = Some(PathBuf::from("/usr/bin/clangd")); + h.open("main.cpp", "int *p = 0;"); + h.wait_status("clangd", ServerStatus::Ready).await; + assert!(!h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. }))); + std::fs::create_dir(h.root.join("nested")).unwrap(); + std::fs::write(h.root.join("nested/.clang-tidy"), "Checks: '-*,modernize-use-nullptr'").unwrap(); + h.open("nested/other.cpp", "int *p = 0;"); + eventually(|| h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. }))).await; + assert_eq!(h.mgr.servers().len(), 1); + h.mgr.shutdown_all().await; +} diff --git a/crates/ide-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index 21b217dc..814ccf7d 100644 --- a/crates/ide-sdk/src/java.rs +++ b/crates/ide-sdk/src/java.rs @@ -313,21 +313,36 @@ fn text_key(text: &str) -> String { format!("{h:016x}") } +/// Adds `arg` as its own `--jvm-arg`, unless the user already set the property it +/// carries: a value of theirs wins, and a second call adds nothing. +fn set_jvm_property(args: &mut Vec, arg: &str) { + let key = arg.split('=').next().unwrap_or(arg); + let taken = args.iter().enumerate().any(|(i, a)| { + let value = + a.strip_prefix("--jvm-arg=").or_else(|| (i > 0 && args[i - 1] == "--jvm-arg").then_some(a.as_str())); + value.is_some_and(|a| a.split('=').next() == Some(key)) + }); + if !taken { + args.push(format!("--jvm-arg={arg}")); + } +} + fn add_jdtls_proxy_args(args: &mut Vec, proxy: &ide_core::proxy_settings::ProxySettings) { for arg in proxy.jvm_args() { - let key = arg.split('=').next().unwrap_or(arg.as_str()); - if !args.iter().enumerate().any(|(i, a)| { - let value = - a.strip_prefix("--jvm-arg=").or_else(|| (i > 0 && args[i - 1] == "--jvm-arg").then_some(a.as_str())); - value.is_some_and(|a| a.split('=').next() == Some(key)) - }) { - args.push(format!("--jvm-arg={arg}")); - } + set_jvm_property(args, &arg); } } const METADATA_PROPERTY: &str = "java.import.generatesMetadataFilesAtProjectRoot"; +/// JDK 25's `ZipFile` validates the ZIP64 extra field, and jars written by older tools +/// carry a size that check rejects: ECJ reports the dependency as "cannot be read or is +/// not a valid ZIP file", the project using it gets a build path error, and every project +/// depending on that one stops compiling — aspectjweaver 1.8.10 stalls a whole Maven +/// build that way, while the same jar loads fine on IDEA's JBR 21. This is how 25 is told +/// to behave the same. +const ZIP64_LENIENCY: &str = "-Djdk.util.zip.disableZip64ExtraFieldValidation=true"; + /// jdtls: its Eclipse workspace (`-data`) lives under the IDE's data /// directory, one per server root, and no Eclipse metadata is written into /// the project; it runs on a JDK `min_java`+ (via `JAVA_HOME`, which its @@ -384,6 +399,10 @@ pub fn configure_jdtls( // of the `HTTP_PROXY` / `HTTPS_PROXY` it inherits: pass the same proxy as system // properties. One the user set in their own JVM arguments wins. add_jdtls_proxy_args(&mut cfg.args, &ide_core::proxy_settings::proxy()); + // ECJ reads the project's jars with the server JVM's own zip reader, so a jar JDK 25 + // refuses to open takes the whole Maven build with it. IDEA never notices: its JBR 21 + // accepts the same file. + set_jvm_property(&mut cfg.args, ZIP64_LENIENCY); let managed = language_server_runtimes_dir(data_dir); let managed = dunce::canonicalize(&managed).unwrap_or(managed); let project_jdks: Vec<_> = project_jdk @@ -664,6 +683,11 @@ mod tests { assert_eq!(cfg.args[n], "-data"); assert!(cfg.args[n + 1].starts_with("/data/lsp/jdtls/"), "{}", cfg.args[n + 1]); assert_eq!(cfg.args[n + 2], "--jvm-arg=-Djava.import.generatesMetadataFilesAtProjectRoot=false"); + assert!( + cfg.args.iter().any(|a| a == "--jvm-arg=-Djdk.util.zip.disableZip64ExtraFieldValidation=true"), + "jars JDK 25 rejects must still load: {:?}", + cfg.args + ); let opts = serde_json::to_value(cfg.initialization_options.clone().unwrap()).unwrap(); let rt = &opts["settings"]["java"]["configuration"]["runtimes"]; assert_eq!(rt[0], json!({ "name": "JavaSE-1.8", "path": "/jdk8" })); @@ -751,6 +775,25 @@ mod tests { } } + #[test] + fn a_jar_jdk_25_rejects_loads_and_the_users_value_wins() { + // aspectjweaver 1.8.10 carries a ZIP64 extra field size 25 refuses to read; on the + // JVM that runs jdtls that is a build path error in every project using it. + let mut args = Vec::new(); + set_jvm_property(&mut args, ZIP64_LENIENCY); + assert_eq!(args, ["--jvm-arg=-Djdk.util.zip.disableZip64ExtraFieldValidation=true"]); + set_jvm_property(&mut args, ZIP64_LENIENCY); + assert_eq!(args.len(), 1, "added once"); + for user in [ + "--jvm-arg=-Djdk.util.zip.disableZip64ExtraFieldValidation=false".to_owned(), + "--jvm-arg=-Djdk.util.zip.disableZip64ExtraFieldValidation".to_owned(), + ] { + let mut own = vec![user.clone()]; + set_jvm_property(&mut own, ZIP64_LENIENCY); + assert_eq!(own, [user], "the user decides"); + } + } + #[test] fn jdtls_lombok_agent() { let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() }; diff --git a/crates/ide-tools/src/formatter.rs b/crates/ide-tools/src/formatter.rs index 9b599f0b..b6a7a692 100644 --- a/crates/ide-tools/src/formatter.rs +++ b/crates/ide-tools/src/formatter.rs @@ -13,20 +13,30 @@ pub fn find_up(file: &Path, relative: &Path) -> Option { file.parent()?.ancestors().map(|dir| dir.join(relative)).find(|p| p.is_file()) } -fn package_bin(package: &Path) -> IdeResult { +pub fn display_name(tool: &str) -> &str { + crate::registry::find(tool).map_or(tool, |t| t.display) +} + +fn package_bin(package: &Path, tool: &str) -> IdeResult { + let name = display_name(tool); let json: serde_json::Value = serde_json::from_slice(&std::fs::read(package.join("package.json"))?) - .map_err(|e| IdeError::invalid(format!("Prettier: {e}")))?; + .map_err(|e| IdeError::invalid(format!("{name}: {e}")))?; let bin = json["bin"] .as_str() - .or_else(|| json["bin"]["prettier"].as_str()) - .ok_or_else(|| IdeError::invalid("Prettier: package has no CLI entry point"))?; + .or_else(|| json["bin"][tool].as_str()) + .ok_or_else(|| IdeError::invalid(format!("{name}: package has no CLI entry point")))?; let path = package.join(bin); if !path.is_file() { - return Err(IdeError::unavailable(format!("Prettier: CLI does not exist: {}", path.display()))); + return Err(IdeError::unavailable(format!("{name}: CLI does not exist: {}", path.display()))); } Ok(path) } +/// Prettier formats a range for some languages (`config.range`); oxfmt formats whole files only. +pub fn supports_range(tool: &str, config: &FormatterConfig) -> bool { + tool == "prettier" && config.range +} + impl Tools { /// Explicit executable/script, then the nearest project package, then managed tools and PATH. pub fn formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult { @@ -34,22 +44,26 @@ impl Tools { } fn resolve_formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult { - if tool != "prettier" { + if crate::registry::find(tool).is_none_or(|t| t.kind != crate::ToolKind::Formatter) { return Err(IdeError::unavailable(format!("Unknown external formatter: {tool}"))); } + let package = Path::new("node_modules").join(tool); + let manifest = package.join("package.json"); let (path, node) = if let Some(custom) = custom.filter(|p| p.is_dir()) { - (package_bin(custom)?, true) + (package_bin(custom, tool)?, true) } else if let Some(custom) = custom { (custom.to_path_buf(), matches!(custom.extension().and_then(|s| s.to_str()), Some("js" | "cjs" | "mjs"))) - } else if let Some(manifest) = find_up(file, Path::new("node_modules/prettier/package.json")) { - (package_bin(manifest.parent().unwrap())?, true) - } else if self.root.join("node_modules/prettier/package.json").is_file() { - (package_bin(&self.root.join("node_modules/prettier"))?, true) + } else if let Some(manifest) = find_up(file, &manifest) { + (package_bin(manifest.parent().unwrap(), tool)?, true) + } else if self.root.join(&manifest).is_file() { + (package_bin(&self.root.join(&package), tool)?, true) } else { - (self.which("prettier").ok_or_else(|| IdeError::missing_tool("prettier"))?, false) + (self.which(tool).ok_or_else(|| IdeError::missing_tool(tool))?, false) }; if node { - let node = self.which("node").ok_or_else(|| IdeError::unavailable("Prettier: Node.js is required"))?; + let name = display_name(tool); + let node = + self.which("node").ok_or_else(|| IdeError::unavailable(format!("{name}: Node.js is required")))?; let mut cmd = self.command(node); cmd.arg(path); Ok(cmd) @@ -59,34 +73,40 @@ impl Tools { } } -/// Project configuration overrides these defaults; Prettier resolves config and plugins itself. +/// Both tools resolve their configuration and plugins themselves. Prettier's +/// project configuration overrides the editor indentation passed here; oxfmt has +/// no such flags and reads its configuration and `.editorconfig` only. pub fn arguments( + tool: &str, config: &FormatterConfig, file: &Path, tab_size: u32, insert_spaces: bool, range: Option<(u32, u32)>, ) -> Vec { - let parser = if config.parser == "markdown" && file.extension().is_some_and(|s| s == "mdx") { - "mdx" - } else { - &config.parser - }; - let mut args: Vec = [ - "--parser", - parser, - "--config-precedence", - "file-override", - "--tab-width", - &tab_size.max(1).to_string(), - "--use-tabs", - if insert_spaces { "false" } else { "true" }, - ] - .into_iter() - .map(Into::into) - .collect(); + let mut args: Vec = Vec::new(); + if tool == "prettier" { + let parser = if config.parser == "markdown" && file.extension().is_some_and(|s| s == "mdx") { + "mdx" + } else { + &config.parser + }; + args.extend( + [ + "--parser", + parser, + "--config-precedence", + "file-override", + "--tab-width", + &tab_size.max(1).to_string(), + "--use-tabs", + if insert_spaces { "false" } else { "true" }, + ] + .map(OsString::from), + ); + } args.extend(["--stdin-filepath".into(), file.as_os_str().to_owned()]); - if config.range + if supports_range(tool, config) && let Some((start, end)) = range { args.extend(["--range-start".into(), start.to_string().into(), "--range-end".into(), end.to_string().into()]); @@ -100,8 +120,8 @@ pub fn arguments( args } -pub fn run(mut command: Command, text: &str, cancel: &CancellationToken) -> IdeResult { - crate::process::run_input(&mut command, text, Duration::from_secs(15), cancel).map_err(|e| e.with_tool("prettier")) +pub fn run(tool: &str, mut command: Command, text: &str, cancel: &CancellationToken) -> IdeResult { + crate::process::run_input(&mut command, text, Duration::from_secs(15), cancel).map_err(|e| e.with_tool(tool)) } #[cfg(test)] @@ -118,7 +138,7 @@ mod tests { assert_eq!(missing.kind, ErrorKind::NotFound); assert_eq!(missing.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: true })); let custom = tools.formatter_command("prettier", &file, Some(&dir.path().join("absent-prettier"))).unwrap(); - let failure = run(custom, "text", &Default::default()).unwrap_err(); + let failure = run("prettier", custom, "text", &Default::default()).unwrap_err(); assert_eq!(failure.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: false })); } @@ -127,7 +147,7 @@ mod tests { fn stderr_is_preserved_verbatim_even_when_longer_than_four_thousand_characters() { let mut command = Command::new("sh"); command.args(["-c", "i=0; while [ $i -lt 1000 ]; do printf 'error\\n' >&2; i=$((i+1)); done; exit 1"]); - let failure = run(command, "", &Default::default()).unwrap_err(); + let failure = run("prettier", command, "", &Default::default()).unwrap_err(); assert_eq!(failure.message, "error\n".repeat(1000)); assert_eq!(failure.data, Some(ide_core::error::ErrorData::Tool { tool: "prettier".into(), missing: false })); } @@ -161,7 +181,8 @@ mod tests { ("vue", true), ] { let config = FormatterConfig { tool: "prettier".into(), parser: parser.into(), range: supports_range }; - let args = arguments(&config, Path::new("/project with spaces/file.md"), 4, false, Some((3, 9))); + let args = + arguments("prettier", &config, Path::new("/project with spaces/file.md"), 4, false, Some((3, 9))); let args: Vec<_> = args.iter().map(|a| a.to_string_lossy()).collect(); assert!(args.windows(2).any(|a| a == ["--config-precedence", "file-override"])); assert!(args.windows(2).any(|a| a == ["--stdin-filepath", "/project with spaces/file.md"])); @@ -171,4 +192,41 @@ mod tests { assert!(!args.contains(&"--write".into())); } } + + #[test] + fn oxfmt_runs_the_nearest_package_without_prettier_flags() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("node_modules/oxfmt/bin")).unwrap(); + std::fs::write(root.join("node_modules/oxfmt/package.json"), r#"{"bin":{"oxfmt":"bin/oxfmt"}}"#).unwrap(); + std::fs::write(root.join("node_modules/oxfmt/bin/oxfmt"), "").unwrap(); + std::fs::write(root.join(".prettierignore"), "dist").unwrap(); + let file = root.join("packages/web/src/app.ts"); + let path = std::env::var_os("PATH").unwrap_or_default(); + let tools = Tools::with_paths(root.join("tools"), path, crate::platform::Platform::current()); + + let command = tools.formatter_command("oxfmt", &file, None).unwrap(); + let program = Path::new(command.get_program()).file_stem().unwrap().to_string_lossy().into_owned(); + assert_eq!(program, "node"); + let script: Vec<_> = command.get_args().collect(); + // Compare components: `package_bin` joins the manifest's `bin` entry + // (`bin/oxfmt`) onto a platform path, so separators differ on Windows. + assert_eq!( + Path::new(&script[0]).components().collect::>(), + root.join("node_modules/oxfmt/bin/oxfmt").components().collect::>() + ); + + let config = FormatterConfig { tool: "prettier".into(), parser: "typescript".into(), range: true }; + let args = arguments("oxfmt", &config, &file, 4, false, Some((3, 9))); + let ignore = root.join(".prettierignore").into_os_string(); + assert_eq!( + args[..4], + ["--stdin-filepath".into(), file.clone().into_os_string(), "--ignore-path".into(), ignore] + ); + assert!(args[4..].chunks(2).all(|pair| pair[0] == "--ignore-path"), "{args:?}"); + assert!(!supports_range("oxfmt", &config)); + + let unknown = tools.formatter_command("rust-analyzer", &file, None).unwrap_err(); + assert!(unknown.message.contains("Unknown external formatter"), "{}", unknown.message); + } } diff --git a/crates/ide-tools/src/registry.rs b/crates/ide-tools/src/registry.rs index 6dfcd5df..661a7170 100644 --- a/crates/ide-tools/src/registry.rs +++ b/crates/ide-tools/src/registry.rs @@ -134,6 +134,28 @@ pub static TOOLS: &[ToolSpec] = &[ needs: NODE, homepage: "https://prettier.io", }, + ToolSpec { + name: "oxfmt", + display: "oxfmt", + kind: Formatter, + commands: &["oxfmt"], + method: Method::Npm { packages: &["oxfmt"] }, + fallback: None, + version: ver("oxfmt"), + needs: NODE, + homepage: "https://oxc.rs/docs/guide/usage/formatter", + }, + ToolSpec { + name: "oxlint", + display: "oxlint", + kind: LanguageServer, + commands: &["oxlint"], + method: Method::Npm { packages: &["oxlint"] }, + fallback: None, + version: ver("oxlint"), + needs: NODE, + homepage: "https://oxc.rs/docs/guide/usage/linter", + }, ToolSpec { name: "rust-analyzer", display: "rust-analyzer", @@ -965,12 +987,8 @@ mod tests { known.insert((d.adapter.clone(), d.command.clone())); } } - for l in &langs { - if let Some(f) = &l.formatter { - known.insert((f.tool.clone(), f.tool.clone())); - } - } - for t in TOOLS.iter().filter(|t| t.kind != Plugin) { + // The host selects the formatter of each file (Prettier or oxfmt). + for t in TOOLS.iter().filter(|t| !matches!(t.kind, Plugin | Formatter)) { assert!( known.contains(&(t.name.to_string(), t.commands[0].to_string())), "{} / {} is not referenced by languages/*.toml", diff --git a/crates/ide-tools/tests/prettier.rs b/crates/ide-tools/tests/prettier.rs index 3d622caa..c09f6d59 100644 --- a/crates/ide-tools/tests/prettier.rs +++ b/crates/ide-tools/tests/prettier.rs @@ -19,8 +19,8 @@ fn format( }; let mut cmd = tools.formatter_command("prettier", path, None)?; cmd.current_dir(path.parent().unwrap()); - cmd.args(formatter::arguments(&config, path, 4, true, range)); - formatter::run(cmd, text, &CancellationToken::new()) + cmd.args(formatter::arguments("prettier", &config, path, 4, true, range)); + formatter::run("prettier", cmd, text, &CancellationToken::new()) } #[test] diff --git a/crates/ide-vcs/src/write.rs b/crates/ide-vcs/src/write.rs index e31701be..4e4f9283 100644 --- a/crates/ide-vcs/src/write.rs +++ b/crates/ide-vcs/src/write.rs @@ -89,15 +89,28 @@ pub(crate) fn blame( return Ok(uncommitted_lines(line_count)); } // Restricted blame reads committed blobs only. Feeding working-tree bytes to - // Git's --contents path would run configured clean filters. - let out = if crate::execution_allowed() { - run( - r.root(), - &["--literal-pathspecs", "blame", "--porcelain", "--contents", "-", revision, "--", &rel], - Some(&contents), - )? + // Git's --contents path would run configured clean filters, so it blames the + // revision plain. Git learned to combine `--contents` with a revision in 2.41; + // older releases reject the pair (`fatal: cannot use --contents with final + // commit object name`) and take the same plain path — the edited lines are + // marked uncommitted below either way (issue #755). + let contents_args = ["--literal-pathspecs", "blame", "--porcelain", "--contents", "-", revision, "--", &rel]; + let plain_args: &[&str] = if crate::execution_allowed() { + &["--literal-pathspecs", "blame", "--porcelain", revision, "--", &rel] } else { - run(r.root(), &["--literal-pathspecs", "blame", "--no-textconv", "--porcelain", revision, "--", &rel], None)? + &["--literal-pathspecs", "blame", "--no-textconv", "--porcelain", revision, "--", &rel] + }; + let (out, contents_blame) = if crate::execution_allowed() { + let out = cli::run_raw(r.root(), &contents_args, Some(&contents))?; + if out.code == Some(0) { + (out, true) + } else if String::from_utf8_lossy(&out.stderr).contains("cannot use --contents") { + (run(r.root(), plain_args, None)?, false) + } else { + return Err(cli::failure(&contents_args, &out.stderr, &out.stdout, out.code)); + } + } else { + (run(r.root(), plain_args, None)?, false) }; let mut lines = crate::blame::parse_porcelain(&out.stdout_str()); for line in &mut lines { @@ -106,7 +119,7 @@ pub(crate) fn blame( line.summary = String::new(); } } - if !crate::execution_allowed() { + if !contents_blame { let base = r.file_at(path, Some(revision), encoding)?.unwrap_or_default(); for hunk in crate::diff::line_hunks(&base, &text).into_iter().rev() { let start = (hunk.old_start as usize).min(lines.len()); diff --git a/languages/asciidoc.toml b/languages/asciidoc.toml new file mode 100644 index 00000000..e798cbbd --- /dev/null +++ b/languages/asciidoc.toml @@ -0,0 +1,6 @@ +id = "asciidoc" +name = "AsciiDoc" +extensions = ["adoc", "asciidoc"] +line-comment = "//" +indent = { tab-size = 2, insert-spaces = true } +ui-mode = "AsciiDoc" diff --git a/languages/d.toml b/languages/d.toml new file mode 100644 index 00000000..2fcdc2cf --- /dev/null +++ b/languages/d.toml @@ -0,0 +1,13 @@ +id = "d" +name = "D" +extensions = ["d", "di"] +line-comment = "//" +block-comment = ["/*", "*/"] +indent = { tab-size = 4, insert-spaces = true } +ui-mode = "D" + +[[language-servers]] +name = "serve-d" +command = "serve-d" +root-markers = ["dub.json", "dub.sdl", "dub.selections.json"] +install-hint = "Install serve-d (https://github.com/symmetryinvestments/serve-d) and expose it as `serve-d`" diff --git a/languages/javascript.toml b/languages/javascript.toml index b5676537..5f19df86 100644 --- a/languages/javascript.toml +++ b/languages/javascript.toml @@ -27,6 +27,14 @@ args = ["--stdio"] root-markers = ["jsconfig.json", "tsconfig.json", "package.json"] install-hint = "npm i -g typescript@6 typescript-language-server" +[[language-servers]] +name = "oxlint" +command = "oxlint" +args = ["--lsp"] +root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"] +require-root-marker = true +install-hint = "npm i -D oxlint" + [debugger] adapter = "js-debug" command = "js-debug-adapter" diff --git a/languages/javascriptreact.toml b/languages/javascriptreact.toml index 3c5a1776..be5b5ab9 100644 --- a/languages/javascriptreact.toml +++ b/languages/javascriptreact.toml @@ -26,6 +26,14 @@ args = ["--stdio"] root-markers = ["jsconfig.json", "tsconfig.json", "package.json"] install-hint = "npm i -g typescript@6 typescript-language-server" +[[language-servers]] +name = "oxlint" +command = "oxlint" +args = ["--lsp"] +root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"] +require-root-marker = true +install-hint = "npm i -D oxlint" + [debugger] adapter = "js-debug" command = "js-debug-adapter" diff --git a/languages/svelte.toml b/languages/svelte.toml index 41095ee1..b441d410 100644 --- a/languages/svelte.toml +++ b/languages/svelte.toml @@ -13,3 +13,11 @@ command = "svelteserver" args = ["--stdio"] root-markers = ["svelte.config.js", "svelte.config.mjs", "svelte.config.ts", "package.json"] install-hint = "npm i -g svelte-language-server" + +[[language-servers]] +name = "oxlint" +command = "oxlint" +args = ["--lsp"] +root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"] +require-root-marker = true +install-hint = "npm i -D oxlint" diff --git a/languages/typescript.toml b/languages/typescript.toml index 41817c5c..0dedc17a 100644 --- a/languages/typescript.toml +++ b/languages/typescript.toml @@ -37,6 +37,14 @@ root-markers = ["angular.json", "node_modules/@angular/core"] require-root-marker = true install-hint = "npm i -g @angular/language-server" +[[language-servers]] +name = "oxlint" +command = "oxlint" +args = ["--lsp"] +root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"] +require-root-marker = true +install-hint = "npm i -D oxlint" + [debugger] adapter = "js-debug" command = "js-debug-adapter" diff --git a/languages/typescriptreact.toml b/languages/typescriptreact.toml index ad238c6c..d7ecf0b8 100644 --- a/languages/typescriptreact.toml +++ b/languages/typescriptreact.toml @@ -26,6 +26,14 @@ args = ["--stdio"] root-markers = ["tsconfig.json", "jsconfig.json", "package.json"] install-hint = "npm i -g typescript@6 typescript-language-server" +[[language-servers]] +name = "oxlint" +command = "oxlint" +args = ["--lsp"] +root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"] +require-root-marker = true +install-hint = "npm i -D oxlint" + [debugger] adapter = "js-debug" command = "js-debug-adapter" diff --git a/languages/vue.toml b/languages/vue.toml index e260bce7..247f0930 100644 --- a/languages/vue.toml +++ b/languages/vue.toml @@ -22,3 +22,11 @@ command = "vtsls" args = ["--stdio"] root-markers = ["tsconfig.json", "jsconfig.json", "package.json"] install-hint = "npm i -g @vtsls/language-server" + +[[language-servers]] +name = "oxlint" +command = "oxlint" +args = ["--lsp"] +root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"] +require-root-marker = true +install-hint = "npm i -D oxlint" diff --git a/src/bindings.ts b/src/bindings.ts index b3ea339c..7c4a2d95 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -3350,6 +3350,8 @@ export type GitOperation = { op: "interactiveRebase"; base: string | null; entries: RebaseEntry[] }; export type GitSettings = { + /** Show blame after the current editor line. */ + inlineBlame?: boolean, /** Path to the git executable; empty = auto-detect. */ executable?: string, /** Default of Update Project: `merge` | `rebase`. */ @@ -3619,6 +3621,8 @@ cancellable: boolean } | { type: "indexState"; indexing: boolean; files: number; symbols: number } | /** Git status / branch information should be refreshed. */ { type: "vcsChanged"; root: IpcPath } | { type: "languageServer"; server: ServerId; name: string; status: ServerStatus; message: string | null } | +/** A running server lacks a capability; the UI offers the managed tool as a replacement. */ +{ type: "languageServerWarning"; name: string; message: string; installTool: string } | /** * A stopped language server left the project's list (disabled in settings, or a * fallback that a preferred server took over from again). diff --git a/src/editor/api.ts b/src/editor/api.ts index 9f0251cb..9d1e81e0 100644 --- a/src/editor/api.ts +++ b/src/editor/api.ts @@ -32,6 +32,9 @@ import type { LanguageDescription } from "@codemirror/language"; import { create } from "zustand"; import { maybeEditorService } from "@/workbench/contributions"; +/** Documents above this size (UTF-16 units) skip per-line decorations. */ +export const HEAVY_LIMIT = 8_000_000; + export interface EditorDocContext { doc: number; path: string; diff --git a/src/editor/core/backend.ts b/src/editor/core/backend.ts index 8e4b020c..55dfc59a 100644 --- a/src/editor/core/backend.ts +++ b/src/editor/core/backend.ts @@ -119,6 +119,8 @@ const EXT_LANG: Record = { go: "go", json: "json", md: "markdown", + adoc: "asciidoc", + asciidoc: "asciidoc", css: "css", html: "html", yml: "yaml", @@ -218,6 +220,15 @@ const MEM_LANGUAGES: LanguageInfo[] = [ blockComment: null, indent: { tabSize: 2, insertSpaces: true }, }, + { + id: "asciidoc", + name: "AsciiDoc", + extensions: ["adoc", "asciidoc"], + uiMode: "AsciiDoc", + lineComment: "//", + blockComment: null, + indent: { tabSize: 2, insertSpaces: true }, + }, { id: "markdown", name: "Markdown", diff --git a/src/editor/core/cm/asciidoc.ts b/src/editor/core/cm/asciidoc.ts new file mode 100644 index 00000000..b24a5f3a --- /dev/null +++ b/src/editor/core/cm/asciidoc.ts @@ -0,0 +1,50 @@ +import { StreamLanguage, type StreamParser } from "@codemirror/language"; + +type State = { block: string | null }; + +const parser: StreamParser = { + startState: () => ({ block: null }), + copyState: ({ block }) => ({ block }), + token(stream, state) { + if (stream.sol()) { + const line = stream.string; + const delimiter = line.trim(); + if (state.block) { + if (delimiter === state.block) { + state.block = null; + stream.skipToEnd(); + return "meta"; + } + stream.skipToEnd(); + return "monospace"; + } + if (/^={1,6}\s+\S/.test(line)) { + stream.skipToEnd(); + return "heading"; + } + if (/^:[\w-]+:/.test(line)) { + stream.skipToEnd(); + return "attributeName"; + } + if (["----", "....", "____", "++++"].includes(delimiter)) { + state.block = delimiter; + stream.skipToEnd(); + return "meta"; + } + if (/^\s*\/\//.test(line)) { + stream.skipToEnd(); + return "comment"; + } + } + if (stream.eatSpace()) return null; + if (stream.match(/^https?:\/\/[^\s[\]<>]+/)) return "link"; + if (stream.match(/^\*[^*\n]+\*/)) return "strong"; + if (stream.match(/^_[^_\n]+_/)) return "emphasis"; + if (stream.match(/^\x60[^\x60\n]+\x60/)) return "monospace"; + stream.next(); + return null; + }, + languageData: { commentTokens: { line: "//" } }, +}; + +export const asciidocLanguage = StreamLanguage.define(parser); diff --git a/src/editor/core/cm/languages.test.ts b/src/editor/core/cm/languages.test.ts index 26101398..72d674ea 100644 --- a/src/editor/core/cm/languages.test.ts +++ b/src/editor/core/cm/languages.test.ts @@ -3,6 +3,54 @@ import { highlightTree, tagHighlighter, tags as t } from "@lezer/highlight"; import { findLanguageDescription, loadSupport } from "./languages"; describe("language modes", () => { + it("recognizes AsciiDoc files and loads their lightweight syntax mode", async () => { + for (const path of ["/docs/guide.adoc", "/docs/guide.asciidoc"]) { + const description = findLanguageDescription("AsciiDoc", path); + expect(description?.name).toBe("AsciiDoc"); + const support = await loadSupport(description!); + expect(support?.language).toBeTruthy(); + } + }); + + it("assigns highlight tags to AsciiDoc syntax", async () => { + const support = await loadSupport(findLanguageDescription("AsciiDoc", "/docs/guide.adoc")!); + const doc = [ + "= Guide", + ":toc: left", + "----", + "const example = true;", + "----", + "*bold* _italic_ `code` https://example.com", + "// Comment", + ].join("\n"); + const highlighter = tagHighlighter([ + { tag: t.heading, class: "heading" }, + { tag: t.attributeName, class: "attribute" }, + { tag: t.meta, class: "meta" }, + { tag: t.monospace, class: "monospace" }, + { tag: t.strong, class: "strong" }, + { tag: t.emphasis, class: "emphasis" }, + { tag: t.link, class: "link" }, + { tag: t.comment, class: "comment" }, + ]); + const highlighted: [string, string][] = []; + highlightTree(support!.language.parser.parse(doc), highlighter, (from, to, style) => { + highlighted.push([doc.slice(from, to), style]); + }); + expect(highlighted).toEqual([ + ["= Guide", "heading"], + [":toc: left", "attribute"], + ["----", "meta"], + ["const example = true;", "monospace"], + ["----", "meta"], + ["*bold*", "strong"], + ["_italic_", "emphasis"], + ["`code`", "monospace"], + ["https://example.com", "link"], + ["// Comment", "comment"], + ]); + }); + it("styles `?.` like `.` in JavaScript and TypeScript", async () => { const highlighter = tagHighlighter([{ tag: t.derefOperator, class: "deref" }]); for (const [mode, path] of [ diff --git a/src/editor/core/cm/languages.ts b/src/editor/core/cm/languages.ts index bcd1d9bd..9be9fbe3 100644 --- a/src/editor/core/cm/languages.ts +++ b/src/editor/core/cm/languages.ts @@ -68,6 +68,11 @@ const OWN_MODES = [ filename: /^(?:CMakeLists\.txt|.*\.cmake(?:\.in)?)$/, load: async () => new LanguageSupport((await import("./cmake")).cmakeLanguage), }), + LanguageDescription.of({ + name: "AsciiDoc", + extensions: ["adoc", "asciidoc"], + load: () => import("./asciidoc").then((m) => new LanguageSupport(m.asciidocLanguage)), + }), LanguageDescription.of({ name: "Shell", load: async () => { @@ -245,7 +250,7 @@ const DEDENT_STATEMENT = /^\s*(?:pass|break|continue|return)\b/; const KEYWORD_OPEN_LANGS = new Set(["elixir", "ruby", "lua"]); const KEYWORD_OPEN = /\b(?:do(?:\s*\|[^|]*\|)?|then|->)$|^\s*(?:def|defp|defmodule|class|module|function|local\s+function|if|unless|elsif|elseif|else|while|until|for|case|begin|cond|with|receive|try)\b/; -const NO_INDENT_LANGS = new Set(["plaintext", "markdown", "log", "diff", "ignore", "gitattributes"]); +const NO_INDENT_LANGS = new Set(["plaintext", "markdown", "asciidoc", "log", "diff", "ignore", "gitattributes"]); function opensBlock(code: string, language: string | undefined): boolean { if (!code) return false; diff --git a/src/editor/core/cm/setup.ts b/src/editor/core/cm/setup.ts index bcecc965..059b6c13 100644 --- a/src/editor/core/cm/setup.ts +++ b/src/editor/core/cm/setup.ts @@ -28,7 +28,7 @@ import type { EditorConfigProps } from "@/bindings"; import type { EditorSettings } from "@/platform/ipc"; import { editorPrefs, lineCommentStyleFor, type EditorExtras } from "@/platform/editorSettings"; import { BUNDLED_MONO_FAMILY, monoFontFamily } from "@/platform/fonts"; -import { editorContributions, type EditorDocContext } from "@/editor/api"; +import { editorContributions, HEAVY_LIMIT, type EditorDocContext } from "@/editor/api"; import { distractionFreeWidth, isDistractionFree } from "@/workbench/distractionFree"; import { openideaHighlightStyle } from "./highlight"; import { openideaTheme } from "./theme"; @@ -78,8 +78,7 @@ export function prefsOf(settings: EditorSettings | undefined): EditorExtras { return editorPrefs({ editor: settings }); } -/** Documents above this size (UTF-16 units) skip per-line decorations. */ -export const HEAVY_LIMIT = 8_000_000; +export { HEAVY_LIMIT } from "@/editor/api"; export interface EffectiveIndent { tabSize: number; diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index 90e12af6..723a27d5 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -171,6 +171,14 @@ function isTextTab(tab: Tab | null | undefined): tab is Tab & { input: { path: s type EditorKinds = ReturnType["editorKinds"]; +interface OpenRequest { + project: number; + admission: number; + sequence: number; + activation: number; + pane: string; +} + /** Tab title of an editor input: the file name, or what its editor kind calls it. */ export function tabTitle(input: EditorInput, kinds: EditorKinds): string { if (input.kind === "file") return basename(input.path ?? input.key); @@ -223,6 +231,10 @@ export class EditorController implements SlotHost { private closeCommitted = false; private projectGeneration = 0; private admissionGeneration = 0; + private openSequence = 0; + private selectionRequests = new Map(); + private activationGeneration = 0; + private admitting = false; private closeRequests = 0; private projectTransition: Promise = Promise.resolve(); @@ -246,6 +258,11 @@ export class EditorController implements SlotHost { useTabs.subscribe((s) => { const prev = prevTabs; prevTabs = s; + if ( + !this.admitting && + (s.activePane !== prev.activePane || s.panes[s.activePane]?.active !== prev.panes[prev.activePane]?.active) + ) + ++this.activationGeneration; this.stampActive(s); this.onTabsChanged(prev, s); }), @@ -347,6 +364,7 @@ export class EditorController implements SlotHost { this.preparedDocuments.clear(); for (const slot of this.slots.values()) slot.dispose(); this.slots.clear(); + this.selectionRequests.clear(); this.manager.stop(); useTabs.setState(initialTabsState(), true); void this.releaseDocuments(documents.values()).catch((e) => this.documentError(e)); @@ -361,6 +379,7 @@ export class EditorController implements SlotHost { private onTabsChanged(prev: TabsState, next: TabsState) { const live = new Set(); for (const p of Object.values(next.panes)) for (const t of p.tabs) live.add(t.id); + for (const id of this.selectionRequests.keys()) if (!live.has(id)) this.selectionRequests.delete(id); for (const [id, slot] of this.slots) { if (!live.has(id)) { this.slots.delete(id); @@ -529,7 +548,9 @@ export class EditorController implements SlotHost { if (active?.id === slot.tabId) this.setActiveSlot(slot); if (slot.wantsFocus) { slot.wantsFocus = false; - requestAnimationFrame(() => slot.focus()); + requestAnimationFrame(() => { + if (!slot.disposed && this.activeTabOf()?.id === slot.tabId) slot.focus(); + }); } } @@ -849,8 +870,15 @@ export class EditorController implements SlotHost { // --- opening --------------------------------------------------------------------------- - private openingGeneration() { - const generation = { project: this.projectGeneration, admission: this.admissionGeneration }; + private openingGeneration(o: OpenOptions): OpenRequest { + const s = useTabs.getState(); + const generation = { + project: this.projectGeneration, + admission: this.admissionGeneration, + sequence: ++this.openSequence, + activation: this.activationGeneration, + pane: o.pane && s.panes[o.pane] ? o.pane : s.activePane, + }; this.checkOpening(generation); return generation; } @@ -861,16 +889,28 @@ export class EditorController implements SlotHost { throw new Error("The project is closing"); } + private isCurrentOpening(generation: OpenRequest) { + return ( + generation.project === this.projectGeneration && + generation.admission === this.admissionGeneration && + generation.sequence === this.openSequence && + generation.activation === this.activationGeneration + ); + } + async openFile(path: string, o: OpenOptions = {}): Promise { - const generation = this.openingGeneration(); - if (isVirtualUri(path)) return this.openVirtual(path, o); + return this.openFileRequest(path, o, this.openingGeneration(o)); + } + + private async openFileRequest(path: string, o: OpenOptions, generation: OpenRequest): Promise { + if (isVirtualUri(path)) return this.openVirtualRequest(path, o, generation); if (isClassFile(path)) { await this.projectTransition; this.checkOpening(generation); try { const target = await call(commands.lspResolveClassFile(path)); this.checkOpening(generation); - return isVirtualUri(target) ? this.openVirtual(target, o) : this.openFile(target, o); + return this.openFileRequest(target, o, generation); } catch (e) { notifications().notify({ level: "warning", title: "Cannot open library class", body: String(e), group: "lsp" }); return; @@ -895,7 +935,10 @@ export class EditorController implements SlotHost { /** Opens the library class (or other server-provided document) behind `uri` read-only. */ async openVirtual(uri: string, o: OpenOptions = {}) { - const generation = this.openingGeneration(); + return this.openVirtualRequest(uri, o, this.openingGeneration(o)); + } + + private async openVirtualRequest(uri: string, o: OpenOptions, generation: OpenRequest) { await this.projectTransition; this.checkOpening(generation); let vd: VirtualDocument; @@ -915,13 +958,13 @@ export class EditorController implements SlotHost { async openInput(input: EditorInput, o: OpenOptions = {}) { if (input.path && isClassFile(input.path)) return this.openFile(input.path, o); - return this.admitInput(input, o, this.openingGeneration()); + return this.admitInput(input, o, this.openingGeneration(o)); } private async admitInput( input: EditorInput, o: OpenOptions, - generation: { project: number; admission: number }, + generation: OpenRequest, resolution?: FileEditorResolution, ) { await this.projectTransition; @@ -931,7 +974,14 @@ export class EditorController implements SlotHost { this.navSuppress++; try { let s = useTabs.getState(); - let paneId = o.pane && s.panes[o.pane] ? o.pane : s.activePane; + const activate = this.isCurrentOpening(generation); + const preview = + !!o.preview && + editorPrefs().tabs.previewTab && + !documentLifecycle(input, useContributions.getState().editorKinds); + if (!activate && preview) return; + let paneId = s.panes[generation.pane] ? generation.pane : activate ? s.activePane : null; + if (!paneId) return; if (o.side) { const right = neighbourPane(s, paneId); if (right) paneId = right; @@ -942,34 +992,44 @@ export class EditorController implements SlotHost { // The new pane starts with only the requested input. const pane = s.panes[paneId]!; s = { ...s, panes: { ...s.panes, [paneId]: { ...pane, tabs: [], active: null, mru: [] } } }; + if (!activate) s = { ...s, activePane: useTabs.getState().activePane }; } } const found = findTab(s, input.key, paneId); let tabId: string; if (found && (!(o.side || o.pane) || found.paneId === paneId)) { paneId = found.paneId; - s = activateTab(setActivePane(s, paneId), paneId, found.tab.id); + if (activate) s = activateTab(setActivePane(s, paneId), paneId, found.tab.id); if (!o.preview || documentLifecycle(input, useContributions.getState().editorKinds)) s = promoteTab(s, paneId, found.tab.id); tabId = found.tab.id; - if (found.tab.input.data !== input.data && input.data !== undefined) + if (activate && found.tab.input.data !== input.data && input.data !== undefined) s = openTab(s, paneId, input, { preview: o.preview && !documentLifecycle(input, useContributions.getState().editorKinds), + activate, }).state; } else { const tabs = editorPrefs().tabs; - const preview = - !!o.preview && tabs.previewTab && !documentLifecycle(input, useContributions.getState().editorKinds); const r = openTab(s, paneId, input, { preview, + activate, + preservePreview: !activate, ...(tabs.openAtEnd ? { index: s.panes[paneId]?.tabs.length ?? 0 } : {}), }); s = r.state; tabId = r.tabId; if (tabs.sortAlphabetically) s = sortTabs(s, paneId, (t) => tabSortKey(t.input)); } - resolution?.admit(); - useTabs.setState(s, true); + if (o.selection) + this.selectionRequests.set(tabId, Math.max(generation.sequence, this.selectionRequests.get(tabId) ?? 0)); + this.admitting = true; + try { + resolution?.admit(); + useTabs.setState(s, true); + } finally { + this.admitting = false; + } + const canFocus = () => this.isCurrentOpening(generation) && this.activeTabOf()?.id === tabId; const tabPrefs = editorPrefs().tabs; await this.removeGuarded( (current) => @@ -982,19 +1042,26 @@ export class EditorController implements SlotHost { (tab) => inputDirty(tab.input, useContributions.getState().editorKinds) ?? !!(tab.input.path && useDocMeta.getState()[tab.input.path]?.dirty), - tabId, + activate ? tabId : (current.panes[paneId]?.active ?? tabId), (tab) => inputDirty(tab.input, useContributions.getState().editorKinds) === true, ) : current, { protect: (tab) => inputDirty(tab.input, useContributions.getState().editorKinds) === true }, ); if (!isTextInput(input)) { - if (o.focus !== false) requestAnimationFrame(() => this.focus()); + if (o.focus !== false) + requestAnimationFrame(() => { + if (canFocus()) this.focus(); + }); return; } const slot = await this.ensureSlot(tabId); if (!slot) return; - if (o.selection) { + if ( + o.selection && + this.selectionRequests.get(tabId) === generation.sequence && + generation.activation === this.activationGeneration + ) { const state = slot.currentState(); const toOffset = (line: number, col: number) => { const l = state.doc.line(Math.min(Math.max(1, line + 1), state.doc.lines)); @@ -1006,7 +1073,7 @@ export class EditorController implements SlotHost { // Selection runs anchor→head; place the caret at the start like IDEA's navigation. slot.reveal({ anchor: head, head: anchor, center: true }); } - if (o.focus !== false) { + if (o.focus !== false && canFocus()) { if (slot.view) slot.focus(); else slot.wantsFocus = true; } @@ -1017,12 +1084,19 @@ export class EditorController implements SlotHost { } activate(paneId: string, tabId: string, focus = true) { + ++this.activationGeneration; useTabs.setState((s) => activateTab(setActivePane(s, paneId), paneId, tabId), true); if (focus) { const slot = this.slots.get(tabId); if (slot?.view) slot.focus(); else if (slot) slot.wantsFocus = true; - else void this.ensureSlot(tabId).then((sl) => sl && (sl.view ? sl.focus() : (sl.wantsFocus = true))); + else + void this.ensureSlot(tabId).then((sl) => { + if (sl && this.activeTabOf()?.id === tabId) { + if (sl.view) sl.focus(); + else sl.wantsFocus = true; + } + }); } } diff --git a/src/editor/core/file-open-order.test.ts b/src/editor/core/file-open-order.test.ts new file mode 100644 index 00000000..cb863097 --- /dev/null +++ b/src/editor/core/file-open-order.test.ts @@ -0,0 +1,300 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { registerFileEditorProvider } from "@/workbench/contributions"; +import { EditorController, openFilePaths, useTabs } from "./controller"; +import { createMemoryBackend } from "./backend"; +import { initialTabsState } from "./model/tabs"; +import { useProject } from "@/platform/project"; + +let ctrl: EditorController | null = null; +let dispose: (() => void) | undefined; +const initialSettings = useProject.getState().settings; + +afterEach(() => { + dispose?.(); + dispose = undefined; + ctrl?.stop(); + ctrl = null; + useTabs.setState(initialTabsState(), true); + useProject.setState({ settings: initialSettings }); +}); + +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function activePath() { + const state = useTabs.getState(); + const pane = state.panes[state.activePane]!; + return pane.tabs.find((tab) => tab.id === pane.active)?.input.path; +} + +function setup() { + ctrl = new EditorController(createMemoryBackend({ "/p/a.ts": "a\nsecond", "/p/b.ts": "b", "/p/c.ts": "c" })); + ctrl.start(); + return ctrl; +} + +function delayProvider(path = "/p/a.ts") { + const started = deferred(); + const response = deferred(); + let delayed = false; + dispose = registerFileEditorProvider({ + id: "delayed-file", + resolve: async (requested) => { + if (requested === path && !delayed) { + delayed = true; + started.resolve(); + await response.promise; + } + return null; + }, + }); + return { started, response }; +} + +function tabFor(path: string) { + return Object.values(useTabs.getState().panes) + .flatMap((pane) => pane.tabs) + .find((tab) => tab.input.path === path); +} + +describe("file open order", () => { + it.each([1, 30])("keeps the newer file active after an earlier probe (tab limit: %s)", async (tabLimit) => { + useProject.setState({ settings: { editor: { tabs: { tabLimit } } } as never }); + const { started, response } = delayProvider(); + const controller = setup(); + const first = controller.openFile("/p/a.ts", { focus: false }); + await started.promise; + await controller.openFile("/p/b.ts", { focus: false }); + expect(activePath()).toBe("/p/b.ts"); + response.resolve(); + await first; + expect(openFilePaths(useTabs.getState()).sort()).toEqual(tabLimit === 1 ? ["/p/b.ts"] : ["/p/a.ts", "/p/b.ts"]); + expect(activePath()).toBe("/p/b.ts"); + expect(controller.activeSlot?.model.path).toBe("/p/b.ts"); + }); + + it("activates the newer file after an earlier probe finishes while both are pending", async () => { + const a = { started: deferred(), response: deferred() }; + const b = { started: deferred(), response: deferred() }; + dispose = registerFileEditorProvider({ + id: "two-delayed-files", + resolve: async (path) => { + const pending = path === "/p/a.ts" ? a : b; + pending.started.resolve(); + await pending.response.promise; + return null; + }, + }); + const controller = setup(); + const first = controller.openFile("/p/a.ts", { focus: false }); + await a.started.promise; + const second = controller.openFile("/p/b.ts", { focus: false }); + await b.started.promise; + a.response.resolve(); + await first; + b.response.resolve(); + await second; + expect(openFilePaths(useTabs.getState()).sort()).toEqual(["/p/a.ts", "/p/b.ts"]); + expect(activePath()).toBe("/p/b.ts"); + }); + + it("discards an earlier preview without replacing the newer preview", async () => { + const { started, response } = delayProvider(); + const controller = setup(); + const first = controller.openFile("/p/a.ts", { preview: true, focus: false }); + await started.promise; + await controller.openFile("/p/b.ts", { preview: true, focus: false }); + response.resolve(); + await first; + expect(openFilePaths(useTabs.getState())).toEqual(["/p/b.ts"]); + expect(tabFor("/p/b.ts")?.preview).toBe(true); + expect(activePath()).toBe("/p/b.ts"); + }); + + it("keeps an earlier regular file without removing the newer preview", async () => { + const { started, response } = delayProvider(); + const controller = setup(); + const first = controller.openFile("/p/a.ts", { focus: false }); + await started.promise; + await controller.openFile("/p/b.ts", { preview: true, focus: false }); + response.resolve(); + await first; + expect(openFilePaths(useTabs.getState()).sort()).toEqual(["/p/a.ts", "/p/b.ts"]); + expect(tabFor("/p/a.ts")?.preview).toBe(false); + expect(tabFor("/p/b.ts")?.preview).toBe(true); + expect(activePath()).toBe("/p/b.ts"); + }); + + it("keeps manual tab activation while an earlier probe is pending", async () => { + const { started, response } = delayProvider(); + const controller = setup(); + await controller.openFile("/p/b.ts", { focus: false }); + await controller.openFile("/p/c.ts", { focus: false }); + const first = controller.openFile("/p/a.ts", { focus: false }); + await started.promise; + controller.activate(useTabs.getState().activePane, tabFor("/p/b.ts")!.id, false); + response.resolve(); + await first; + expect(openFilePaths(useTabs.getState()).sort()).toEqual(["/p/a.ts", "/p/b.ts", "/p/c.ts"]); + expect(activePath()).toBe("/p/b.ts"); + }); + + it("keeps a newer explicit open regular when the same file's preview resolves late", async () => { + const { started, response } = delayProvider(); + const controller = setup(); + const preview = controller.openFile("/p/a.ts", { preview: true, focus: false }); + await started.promise; + await controller.openFile("/p/a.ts", { focus: false }); + response.resolve(); + await preview; + expect(openFilePaths(useTabs.getState())).toEqual(["/p/a.ts"]); + expect(tabFor("/p/a.ts")?.preview).toBe(false); + expect(activePath()).toBe("/p/a.ts"); + }); + + it("promotes an admitted preview when the same file is opened explicitly", async () => { + const controller = setup(); + await controller.openFile("/p/a.ts", { preview: true, focus: false }); + const tabId = tabFor("/p/a.ts")!.id; + expect(tabFor("/p/a.ts")?.preview).toBe(true); + await controller.openFile("/p/a.ts", { focus: false }); + expect(openFilePaths(useTabs.getState())).toEqual(["/p/a.ts"]); + expect(tabFor("/p/a.ts")).toMatchObject({ id: tabId, preview: false }); + expect(activePath()).toBe("/p/a.ts"); + }); + + it("keeps the newer selection when an earlier open of the same file resolves late", async () => { + const { started, response } = delayProvider(); + const controller = setup(); + const first = controller.openFile("/p/a.ts", { selection: { line: 0, col: 0 }, focus: false }); + await started.promise; + await controller.openFile("/p/a.ts", { selection: { line: 1, col: 0 }, focus: false }); + response.resolve(); + await first; + const state = controller.slotFor(tabFor("/p/a.ts")!.id)!.currentState(); + expect(state.doc.lineAt(state.selection.main.head).number).toBe(2); + }); + + it.each([false, true])("keeps a pending navigation selection after a plain open (same file: %s)", async (same) => { + const backend = createMemoryBackend({ "/p/a.ts": "a\nsecond", "/p/b.ts": "b" }); + const started = deferred(); + const response = deferred(); + const open = backend.open.bind(backend); + backend.open = async (path) => { + if (path === "/p/a.ts") { + started.resolve(); + await response.promise; + } + return open(path); + }; + ctrl = new EditorController(backend); + ctrl.start(); + const navigation = ctrl.openFile("/p/a.ts", { selection: { line: 1, col: 2 } }); + await started.promise; + const plain = ctrl.openFile(same ? "/p/a.ts" : "/p/b.ts", { focus: false }); + if (!same) await plain; + response.resolve(); + await Promise.all([navigation, plain]); + const slot = ctrl.slotFor(tabFor("/p/a.ts")!.id)!; + expect(slot.caret()).toMatchObject({ line: 1, col: 2 }); + expect(slot.wantsFocus).toBe(false); + expect(activePath()).toBe(same ? "/p/a.ts" : "/p/b.ts"); + }); + + it.each([true, false])("keeps a delayed open in its origin pane (explicit: %s)", async (explicit) => { + const { started, response } = delayProvider(); + const controller = setup(); + await controller.openFile("/p/b.ts", { focus: false }); + const origin = useTabs.getState().activePane; + controller.split(origin, "row"); + const other = useTabs.getState().activePane; + expect(other).not.toBe(origin); + controller.activate(origin, tabFor("/p/b.ts")!.id, false); + const first = controller.openFile("/p/a.ts", { ...(explicit ? { pane: origin } : {}), focus: false }); + await started.promise; + await controller.openFile("/p/c.ts", { pane: other, focus: false }); + response.resolve(); + await first; + const state = useTabs.getState(); + expect(state.panes[origin]!.tabs.some((tab) => tab.input.path === "/p/a.ts")).toBe(true); + expect(state.panes[other]!.tabs.some((tab) => tab.input.path === "/p/a.ts")).toBe(false); + expect(activePath()).toBe("/p/c.ts"); + }); + + it("does not request focus when an earlier document finishes loading late", async () => { + const backend = createMemoryBackend({ "/p/a.ts": "a", "/p/b.ts": "b" }); + const started = deferred(); + const response = deferred(); + const open = backend.open.bind(backend); + backend.open = async (path) => { + if (path === "/p/a.ts") { + started.resolve(); + await response.promise; + } + return open(path); + }; + ctrl = new EditorController(backend); + ctrl.start(); + const first = ctrl.openFile("/p/a.ts"); + await started.promise; + await ctrl.openFile("/p/b.ts", { focus: false }); + response.resolve(); + await first; + expect(openFilePaths(useTabs.getState()).sort()).toEqual(["/p/a.ts", "/p/b.ts"]); + expect(activePath()).toBe("/p/b.ts"); + expect(ctrl.slotFor(tabFor("/p/a.ts")!.id)?.wantsFocus).toBe(false); + }); + + it("does not run queued mount focus after a newer file becomes active", async () => { + const controller = setup(); + await controller.openFile("/p/a.ts"); + const slot = controller.slotFor(tabFor("/p/a.ts")!.id)!; + expect(slot.wantsFocus).toBe(true); + const focus = vi.spyOn(slot, "focus"); + const frames: FrameRequestCallback[] = []; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => frames.push(callback)); + try { + controller.onMounted(slot); + expect(frames.length).toBeGreaterThan(0); + await controller.openFile("/p/b.ts", { focus: false }); + for (const frame of frames) frame(0); + expect(focus).not.toHaveBeenCalled(); + expect(activePath()).toBe("/p/b.ts"); + } finally { + vi.unstubAllGlobals(); + focus.mockRestore(); + } + }); + + it("rolls back a superseded custom preview before provider admission", async () => { + const started = deferred(); + const response = deferred(); + const commit = vi.fn(); + const rollback = vi.fn(async () => {}); + dispose = registerFileEditorProvider({ + id: "delayed-custom-preview", + resolve: async (path, context) => { + if (path !== "/p/a.ts") return null; + context.onAdmission(commit, rollback); + started.resolve(); + await response.promise; + return { kind: "image", key: "image:/p/a.ts", path }; + }, + }); + const controller = setup(); + const first = controller.openFile("/p/a.ts", { preview: true, focus: false }); + await started.promise; + await controller.openFile("/p/b.ts", { preview: true, focus: false }); + response.resolve(); + await first; + expect(commit).not.toHaveBeenCalled(); + expect(rollback).toHaveBeenCalledOnce(); + expect(activePath()).toBe("/p/b.ts"); + expect(tabFor("/p/a.ts")).toBeUndefined(); + }); +}); diff --git a/src/editor/core/model/document-open-latency.test.ts b/src/editor/core/model/document-open-latency.test.ts new file mode 100644 index 00000000..7d6795a1 --- /dev/null +++ b/src/editor/core/model/document-open-latency.test.ts @@ -0,0 +1,120 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { LanguageSupport, StreamLanguage } from "@codemirror/language"; +import { createMemoryBackend } from "../backend"; +import { _resetLanguages, loadLanguageInfos, loadSupport } from "../cm/languages"; +import { DocumentManager } from "./document"; + +vi.mock("../cm/languages", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, loadSupport: vi.fn(actual.loadSupport) }; +}); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +afterEach(() => { + _resetLanguages(); + vi.mocked(loadSupport).mockClear(); +}); + +it.each(["/p/a.ts", "jdt://contents/a.ts"])("starts reading %s before the catalog resolves", async (path) => { + const backend = createMemoryBackend({ [path]: "const a = 1" }); + const languages = await backend.languages(); + const catalog = deferred(); + vi.spyOn(backend, "languages").mockReturnValue(catalog.promise); + const open = vi.spyOn(backend, path.startsWith("jdt:") ? "openVirtual" : "open"); + const manager = new DocumentManager(backend, { settings: () => ({}), notify: vi.fn() }); + let exposed = false; + const pending = manager.acquire(path).then((model) => { + exposed = true; + return model; + }); + try { + expect(open).toHaveBeenCalledWith(path); + await open.mock.results[0]!.value; + expect(exposed).toBe(false); + expect(manager.get(path)).toBeUndefined(); + } finally { + catalog.resolve(languages); + const model = await pending; + model.dispose(); + manager.stop(); + } +}); + +it("starts the known mode while document content is still pending", async () => { + const path = "/p/a.ts"; + const backend = createMemoryBackend({ [path]: "const a = 1" }); + const info = await backend.open(path); + const content = deferred(); + vi.spyOn(backend, "open").mockReturnValue(content.promise); + const manager = new DocumentManager(backend, { settings: () => ({}), notify: vi.fn() }); + const pending = manager.acquire(path); + await loadLanguageInfos(backend); + try { + expect(loadSupport).toHaveBeenCalledWith(expect.objectContaining({ name: "TypeScript" })); + } finally { + content.resolve(info); + const model = await pending; + model.dispose(); + manager.stop(); + } +}); + +it("handles a speculative mode rejection when opening the document fails", async () => { + const backend = createMemoryBackend(); + const failure = new Error("cannot read document"); + vi.spyOn(backend, "open").mockRejectedValue(failure); + vi.mocked(loadSupport).mockRejectedValueOnce(new Error("cannot preload mode")); + const manager = new DocumentManager(backend, { settings: () => ({}), notify: vi.fn() }); + try { + await expect(manager.acquire("/p/a.ts")).rejects.toBe(failure); + expect(loadSupport).toHaveBeenCalledWith(expect.objectContaining({ name: "TypeScript" })); + } finally { + manager.stop(); + } +}); + +it("opens within 50 ms with catalog, read and cold mode budgets of 20, 40 and 30 ms", async () => { + const path = "/p/cold.rs"; + const backend = createMemoryBackend({ [path]: "fn main() {}" }); + const catalog = await backend.languages(); + const info = await backend.open(path); + const originalSupport = vi.mocked(loadSupport).getMockImplementation()!; + const support = new LanguageSupport(StreamLanguage.define({ token: (stream) => (stream.skipToEnd(), null) })); + let loading: Promise | undefined; + let readyAt: number | undefined; + const manager = new DocumentManager(backend, { settings: () => ({}), notify: vi.fn() }); + vi.useFakeTimers(); + const start = Date.now(); + vi.spyOn(backend, "languages").mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve(catalog), 20)), + ); + vi.spyOn(backend, "open").mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve(info), 40))); + vi.mocked(loadSupport).mockImplementation( + () => (loading ??= new Promise((resolve) => setTimeout(() => resolve(support), 30))), + ); + const pending = manager.acquire(path).then((model) => { + readyAt = Date.now() - start; + return model; + }); + try { + await vi.advanceTimersByTimeAsync(90); + expect(readyAt).toBeLessThanOrEqual(50); + expect((await pending).language).toBe(support); + } finally { + try { + await vi.runAllTimersAsync(); + (await pending).dispose(); + } finally { + manager.stop(); + vi.mocked(loadSupport).mockImplementation(originalSupport); + vi.useRealTimers(); + } + } +}); diff --git a/src/editor/core/model/document.ts b/src/editor/core/model/document.ts index 9f81ad20..ff94d564 100644 --- a/src/editor/core/model/document.ts +++ b/src/editor/core/model/document.ts @@ -36,6 +36,7 @@ import { loadSupport, loadedSupport, commentTokens, + uiModeForPath, } from "../cm/languages"; import { foldEffect } from "@codemirror/language"; import { backendFolds, setBackendFolds } from "../cm/folding"; @@ -683,8 +684,17 @@ export class DocumentManager { let p = this.opening.get(path); if (!p) { p = (async () => { - await loadLanguageInfos(this.backend); - const info = isVirtualUri(path) ? await this.backend.openVirtual(path) : await this.backend.open(path); + const catalog = loadLanguageInfos(this.backend); + void catalog + .then(() => { + const desc = findLanguageDescription(uiModeForPath(path) ?? null, path); + if (desc) return loadSupport(desc); + }) + .catch(() => {}); + const [info] = await Promise.all([ + isVirtualUri(path) ? this.backend.openVirtual(path) : this.backend.open(path), + catalog, + ]); const raced = this.byPath.get(path); if (raced) { void this.backend.close(info.id); diff --git a/src/editor/core/model/documentLanguage.test.ts b/src/editor/core/model/documentLanguage.test.ts index 0618acf2..0993edbd 100644 --- a/src/editor/core/model/documentLanguage.test.ts +++ b/src/editor/core/model/documentLanguage.test.ts @@ -9,7 +9,7 @@ const loading = vi.hoisted(() => { const ready = new Promise((resolve) => { finish = resolve; }); - return { ready, finish, started: false }; + return { ready, finish, started: false, modes: [] as string[] }; }); vi.mock("../cm/languages", async (importOriginal) => { @@ -18,6 +18,7 @@ vi.mock("../cm/languages", async (importOriginal) => { ...actual, loadSupport: async (...args: Parameters) => { loading.started = true; + loading.modes.push(args[0].name); await loading.ready; return actual.loadSupport(...args); }, @@ -49,6 +50,7 @@ it("waits for the lazy mode before exposing an Angular document to its first slo onContextMenu() {}, }); expect(slot.currentState().facet(languageFacet)?.name).toBe("angular"); + expect(loading.modes).toEqual(expect.arrayContaining(["HTML", "Angular Template"])); slot.dispose(); manager.stop(); }); diff --git a/src/editor/core/model/tabs.ts b/src/editor/core/model/tabs.ts index 269e4b20..56cf6a83 100644 --- a/src/editor/core/model/tabs.ts +++ b/src/editor/core/model/tabs.ts @@ -113,6 +113,7 @@ export interface OpenTabOptions { /** Insert at this index instead of after the active tab. */ index?: number; activate?: boolean; + preservePreview?: boolean; } export function openTab( @@ -152,7 +153,7 @@ export function openTab( tabs.splice(index, 0, tab); } let active = pane.active; - if (!tab.preview && tabs.some((t) => t.preview)) { + if (!tab.preview && !opts.preservePreview && tabs.some((t) => t.preview)) { // A new regular editor closes the preview tab (IDEA trims previews on every open). const previews = new Set(tabs.filter((t) => t.preview).map((t) => t.id)); tabs.splice(0, tabs.length, ...tabs.filter((t) => !previews.has(t.id))); diff --git a/src/editor/lsp/format.ts b/src/editor/lsp/format.ts index 061e377c..5d3031c3 100644 --- a/src/editor/lsp/format.ts +++ b/src/editor/lsp/format.ts @@ -118,19 +118,22 @@ async function format( try { edits = await lspRequest(() => commands.lspFormat(doc.doc, range, { ...options, onSave: opts.onSave })); } catch (e) { - if (e instanceof IpcError && e.data?.type === "tool" && e.data.tool === "prettier") { + if (e instanceof IpcError && e.data?.type === "tool" && (e.data.tool === "prettier" || e.data.tool === "oxfmt")) { + const tool = e.data.tool; if (e.kind === "notFound" && e.data.missing) { - await executeAction("tools.promptInstall", { name: "prettier", sticky: !opts.onSave }); + await executeAction("tools.promptInstall", { name: tool, sticky: !opts.onSave }); } else if (e.kind !== "cancelled") { notifications().notify({ group: "tools", level: "error", - title: tx("Prettier formatting failed"), + title: tool === "oxfmt" ? tx("oxfmt formatting failed") : tx("Prettier formatting failed"), body: e.kind === "timedOut" - ? tx("Prettier formatting timed out") + ? tool === "oxfmt" + ? tx("oxfmt formatting timed out") + : tx("Prettier formatting timed out") : createElement("span", { className: styles.diagnostics }, e.message), - key: `prettier-format-error:${doc.path}`, + key: `${tool}-format-error:${doc.path}`, sticky: false, }); } diff --git a/src/editor/lsp/formatErrors.test.tsx b/src/editor/lsp/formatErrors.test.tsx index aa21c997..3107316b 100644 --- a/src/editor/lsp/formatErrors.test.tsx +++ b/src/editor/lsp/formatErrors.test.tsx @@ -18,7 +18,6 @@ import { import { formatDocument } from "./format"; import * as lsp from "./lsp"; -const tool = { type: "tool", tool: "prettier", missing: false } as const; const views: EditorView[] = []; const viewFor = (path = "/project/file.css") => { const view = new EditorView({ @@ -43,7 +42,9 @@ afterEach(() => { _resetI18nForTests(); }); -describe("external formatter errors", () => { +describe.each(["prettier", "oxfmt"])("%s formatter errors", (name) => { + const tool = { type: "tool", tool: name, missing: false } as const; + const display = name === "prettier" ? "Prettier" : name; it("uses structured missing-tool information to request the shared install prompt", async () => { const prompt = vi.fn(); const dispose = registerAction({ @@ -54,9 +55,9 @@ describe("external formatter errors", () => { try { failWith({ kind: "notFound", message: "unrelated diagnostic", data: { ...tool, missing: true } }); await formatDocument(viewFor()); - expect(prompt).toHaveBeenCalledWith({ name: "prettier", sticky: true }); + expect(prompt).toHaveBeenCalledWith({ name, sticky: true }); await formatDocument(viewFor(), { explicit: false, onSave: true }); - expect(prompt).toHaveBeenLastCalledWith({ name: "prettier", sticky: false }); + expect(prompt).toHaveBeenLastCalledWith({ name, sticky: false }); expect(useNotifications.getState().items).toHaveLength(0); } finally { dispose(); @@ -95,7 +96,7 @@ describe("external formatter errors", () => { for (let i = 0; i < 3; i++) await formatDocument(view, { explicit: false, onSave: true }); const notes = useNotifications.getState().items; expect(notes).toHaveLength(1); - expect(notes[0]).toMatchObject({ sticky: false, title: "Prettier 格式化失败" }); + expect(notes[0]).toMatchObject({ sticky: false, title: `${display} 格式化失败` }); expect(balloonTimeout(notes[0]!)).toBe(10000); render(); expect(screen.getByRole("alert")).toHaveTextContent(stderr); @@ -115,7 +116,7 @@ describe("external formatter errors", () => { const request = failWith({ kind: "timedOut", message: "arbitrary backend text", data: tool }); const view = viewFor(); await formatDocument(view); - expect(useNotifications.getState().items[0]).toMatchObject({ body: "Prettier 格式化超时", sticky: false }); + expect(useNotifications.getState().items[0]).toMatchObject({ body: `${display} 格式化超时`, sticky: false }); clearNotifications(); request.mockRejectedValue(new IpcError({ kind: "cancelled", message: "cancelled", data: tool })); await formatDocument(view); diff --git a/src/features/project-tree/ProjectView.test.tsx b/src/features/project-tree/ProjectView.test.tsx index 05ce9b99..6c68edcf 100644 --- a/src/features/project-tree/ProjectView.test.tsx +++ b/src/features/project-tree/ProjectView.test.tsx @@ -172,6 +172,9 @@ describe("ProjectView", () => { act(() => setViewOption("previewTab", true)); fireEvent.click(content, { detail: 1 }); expect(openFile).toHaveBeenLastCalledWith("/w/acme/README.md", { preview: true, focus: false }); + openFile.mockClear(); + fireEvent.click(content, { detail: 3 }); + expect(openFile).not.toHaveBeenCalled(); act(() => setViewOption("previewTab", false)); }); @@ -207,6 +210,32 @@ describe("ProjectView", () => { act(() => setViewOption("singleClickExpand", false)); }); + it("toggles folders once for each rapid double-click pair", async () => { + const { row, rows } = setup(); + await waitFor(() => expect(rows()).toContain("README.md")); + act(() => setViewOption("singleClickExpand", true)); + const content = () => row("src").querySelector("[data-project-path]")!; + const click = (detail: number) => { + fireEvent.mouseDown(row("src"), { detail }); + fireEvent.click(content(), { detail }); + }; + click(1); + click(2); + fireEvent.doubleClick(content(), { detail: 2 }); + await waitFor(() => expect(rows()).toContain("App.kt")); + click(3); + click(4); + fireEvent.doubleClick(content(), { detail: 4 }); + expect(rows()).not.toContain("App.kt"); + + act(() => setViewOption("singleClickExpand", false)); + click(5); + click(6); + expect(rows()).not.toContain("App.kt"); + fireEvent.doubleClick(content(), { detail: 6 }); + await waitFor(() => expect(rows()).toContain("App.kt")); + }); + it("cancels a new file when its name field loses focus", async () => { const { rows } = setup(); await waitFor(() => expect(rows()).toContain("README.md")); diff --git a/src/features/project-tree/ProjectView.tsx b/src/features/project-tree/ProjectView.tsx index 832cbb51..f4e140f8 100644 --- a/src/features/project-tree/ProjectView.tsx +++ b/src/features/project-tree/ProjectView.tsx @@ -529,10 +529,11 @@ export function ProjectView() { const dragged = useRef(false); const onRowClick = useCallback((node: ProjectNode, e: ReactMouseEvent) => { - if (e.button !== 0 || e.shiftKey || e.metaKey || e.ctrlKey || e.altKey || e.detail > 1) return; + if (e.button !== 0 || e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return; if (node.placeholder || dragged.current) return; const { options: o, editing: ed } = useProjectTree.getState(); if (ed) return; + if (e.detail > 1 && !(node.dir && o.singleClickExpand && e.detail % 2 === 1)) return; if (node.dir) { if (o.singleClickExpand) toggleExpanded(node); return; diff --git a/src/features/settings/pages/vcs.tsx b/src/features/settings/pages/vcs.tsx index 225095a0..db087571 100644 --- a/src/features/settings/pages/vcs.tsx +++ b/src/features/settings/pages/vcs.tsx @@ -492,6 +492,7 @@ export const VCS_PAGES: PageSpec[] = [ keywords: ["Test", "git version"], component: GitExecutableRow, }, + { kind: "toggle", key: "vcs.git.inlineBlame", label: "Show inline blame for the current line" }, ], }, { diff --git a/src/features/terminal/links.ts b/src/features/terminal/links.ts index ca2a4acd..51c97189 100644 --- a/src/features/terminal/links.ts +++ b/src/features/terminal/links.ts @@ -60,14 +60,14 @@ export function findTerminalLinks(text: string): TerminalLink[] { return links.sort((a, b) => a.start - b.start); } -/** Resolves `path` against `cwd` (POSIX); `~` expands with `home` when known. */ +/** Resolves `path` against `cwd` (POSIX, or Windows drive paths); `~` expands with `home` when known. */ export function resolvePath(path: string, cwd: string, home?: string | null): string { - let p = path; + let p = path.replace(/\\/g, "/"); if (p === "~" || p.startsWith("~/")) { if (!home) return p; - p = home + p.slice(1); - } else if (!p.startsWith("/")) { - p = `${cwd.replace(/\/+$/, "")}/${p}`; + p = home.replace(/\\/g, "/") + p.slice(1); + } else if (!p.startsWith("/") && !/^[A-Za-z]:\//.test(p)) { + p = `${cwd.replace(/\\/g, "/").replace(/\/+$/, "")}/${p}`; } const parts: string[] = []; for (const seg of p.split("/")) { @@ -75,5 +75,7 @@ export function resolvePath(path: string, cwd: string, home?: string | null): st if (seg === "..") parts.pop(); else parts.push(seg); } - return "/" + parts.join("/"); + const joined = parts.join("/"); + // A Windows drive prefix is its own root; POSIX results keep the leading slash. + return /^[A-Za-z]:\//.test(joined) ? joined : `/${joined}`; } diff --git a/src/features/terminal/session.test.ts b/src/features/terminal/session.test.ts new file mode 100644 index 00000000..10134ade --- /dev/null +++ b/src/features/terminal/session.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { findTerminalLinks, type TerminalLink } from "./links"; +import { openFileLink } from "./session"; + +const ipc = vi.hoisted(() => ({ + call: vi.fn(async (v: unknown) => v), + commands: { fsReadDir: vi.fn(), findFiles: vi.fn() }, +})); +const workbench = vi.hoisted(() => ({ + openFile: vi.fn(async () => undefined), + notify: vi.fn(), +})); + +vi.mock("@/platform/ipc", () => ({ call: ipc.call, commands: ipc.commands, isTauri: () => true })); +vi.mock("@/workbench/contributions", () => ({ + editorService: () => ({ openFile: workbench.openFile }), + notifications: () => ({ notify: workbench.notify }), + OWNS_FILE_DROP: { "data-owner": true }, +})); + +const fileLink = (text: string): Extract => { + const [l] = findTerminalLinks(text); + if (!l || l.kind !== "file") throw new Error(`not a file link: ${text}`); + return l; +}; + +describe("openFileLink", () => { + beforeEach(() => vi.clearAllMocks()); + + it("opens the resolved path when the directory lists it", async () => { + ipc.commands.fsReadDir.mockResolvedValue([{ name: "main.php", kind: "file" }]); + await openFileLink(fileLink("a/main.php:2"), "C:\\Users\\me\\proj"); + expect(ipc.commands.fsReadDir).toHaveBeenCalledWith("C:/Users/me/proj/a"); + expect(workbench.openFile).toHaveBeenCalledWith("C:/Users/me/proj/a/main.php", { + selection: { line: 1, col: 0 }, + focus: true, + }); + expect(ipc.commands.findFiles).not.toHaveBeenCalled(); + }); + + it("ranks the link's directory over the bare name in the project-wide fallback (#705)", async () => { + ipc.commands.fsReadDir.mockResolvedValue([]); + ipc.commands.findFiles.mockImplementation(async (q: string) => + q === "a/main.php" + ? [ + { path: "/p/b/main.php", relPath: "b/main.php" }, + { path: "/p/a/main.php", relPath: "a/main.php" }, + ] + : [{ path: "/p/c/main.php", relPath: "c/main.php" }], + ); + await openFileLink(fileLink("a/main.php:2"), "/p"); + expect(ipc.commands.findFiles).toHaveBeenCalledWith("a/main.php", 5); + expect(workbench.openFile).toHaveBeenCalledWith("/p/a/main.php", { + selection: { line: 1, col: 0 }, + focus: true, + }); + }); + + it("falls back to the bare name for a basename-only link", async () => { + ipc.commands.fsReadDir.mockResolvedValue([]); + ipc.commands.findFiles.mockResolvedValue([{ path: "/p/x/main.php", relPath: "x/main.php" }]); + await openFileLink(fileLink("main.php:1"), "/p"); + expect(ipc.commands.findFiles).toHaveBeenCalledTimes(1); + expect(workbench.openFile).toHaveBeenCalledWith("/p/x/main.php", { selection: { line: 0, col: 0 }, focus: true }); + }); + + it("warns when nothing matches", async () => { + ipc.commands.fsReadDir.mockResolvedValue([]); + ipc.commands.findFiles.mockResolvedValue([]); + await openFileLink(fileLink("gone.php:1"), "/p"); + expect(workbench.openFile).not.toHaveBeenCalled(); + expect(workbench.notify).toHaveBeenCalledWith({ level: "warning", title: "File not found", body: "gone.php" }); + }); +}); diff --git a/src/features/terminal/session.ts b/src/features/terminal/session.ts index 1f620980..da94bad3 100644 --- a/src/features/terminal/session.ts +++ b/src/features/terminal/session.ts @@ -137,25 +137,32 @@ export const LINK_HANDLER: ILinkHandler = { export async function openFileLink(link: Extract, cwd: string) { const selection = link.line ? { line: link.line - 1, col: Math.max(0, (link.col ?? 1) - 1) } : undefined; const path = resolvePath(link.path, cwd); - const name = link.path.split("/").pop() ?? link.path; + const rel = link.path.replace(/\\/g, "/"); + const name = rel.split("/").pop() ?? rel; try { const slash = path.lastIndexOf("/"); - const entries = await call(commands.fsReadDir(path.slice(0, slash) || "/")); + const entries = await call(commands.fsReadDir(slash > 0 ? path.slice(0, slash) : "/")); if (entries.some((e) => e.name === name && e.kind !== "dir")) { await editorService().openFile(path, { selection, focus: true }); return; } } catch { - /* fall back to a project-wide lookup by file name */ + /* fall back to a project-wide lookup */ } - try { - const [hit] = await call(commands.findFiles(name, 1)); - if (hit && hit.path.endsWith(name)) { - await editorService().openFile(hit.path, { selection, focus: true }); - return; + // The basename alone is ambiguous (`a/x.ts` vs `b/x.ts`): rank the link's + // directory part too and verify the hit actually sits on that path, settling + // for a basename match only when the link names no directory. + for (const q of rel === name ? [name] : [rel, name]) { + try { + const hits = await call(commands.findFiles(q, 5)); + const hit = hits.find((h) => h.relPath === q || h.relPath.endsWith(`/${q}`)); + if (hit) { + await editorService().openFile(hit.path, { selection, focus: true }); + return; + } + } catch { + /* ignore */ } - } catch { - /* ignore */ } notifications().notify({ level: "warning", title: "File not found", body: link.path }); } diff --git a/src/features/terminal/terminal.test.ts b/src/features/terminal/terminal.test.ts index 433749ce..548cb5ed 100644 --- a/src/features/terminal/terminal.test.ts +++ b/src/features/terminal/terminal.test.ts @@ -96,6 +96,14 @@ describe("terminal links", () => { expect(resolvePath("/abs/c.ts", "/p")).toBe("/abs/c.ts"); expect(resolvePath("~/d.ts", "/p", "/Users/me")).toBe("/Users/me/d.ts"); }); + + it("resolves paths against a Windows cwd (#705)", () => { + expect(resolvePath("a/main.php", "C:\\Users\\me\\proj")).toBe("C:/Users/me/proj/a/main.php"); + expect(resolvePath("a\\main.php", "C:/Users/me/proj")).toBe("C:/Users/me/proj/a/main.php"); + expect(resolvePath("C:\\abs\\d.ts", "C:/p")).toBe("C:/abs/d.ts"); + expect(resolvePath("~/e.ts", "C:/p", "C:\\Users\\me")).toBe("C:/Users/me/e.ts"); + expect(resolvePath("../f.ts", "C:/p/web")).toBe("C:/p/f.ts"); + }); }); describe("terminal keys", () => { diff --git a/src/features/tools/__tests__/tools.test.tsx b/src/features/tools/__tests__/tools.test.tsx index 0d4bd57c..49c64365 100644 --- a/src/features/tools/__tests__/tools.test.tsx +++ b/src/features/tools/__tests__/tools.test.tsx @@ -7,7 +7,7 @@ import { useTasks } from "@/workbench/tasks"; import { MemoryToolsBackend, sampleTools, setToolsBackend } from "../backend"; import { cancelInstall, installTool, refreshTools, uninstallTool, useTools } from "../store"; import { filterTools, LanguageServersPage } from "../LanguageServersPage"; -import { installMissingServerPrompts, missingKey, promptInstall } from "../missing"; +import { installMissingServerPrompts, installServerWarningPrompts, missingKey, promptInstall } from "../missing"; import { resetTestForm, stagedFor, TestSettingsForm } from "./settingsForm"; type Note = Parameters[0]; @@ -273,3 +273,49 @@ describe("tools.install action", () => { } }); }); + +describe("language server capability warning", () => { + const event = { + type: "languageServerWarning", + name: "clangd", + message: + "Apple's clangd does not run clang-tidy, so checks from .clang-tidy are not shown. Install clangd (LLVM) to get them.", + installTool: "clangd", + } as const; + + it("offers a managed replacement for system clangd once and restarts it after installation", async () => { + backend.tools.push({ + ...sampleTools()[0]!, + name: "clangd", + displayName: "clangd", + command: "clangd", + status: "onPath", + path: "/usr/bin/clangd", + languages: ["C", "C++", "Objective-C", "Objective-C++"], + }); + const off = installServerWarningPrompts(); + try { + emitLocalIdeEvent(event); + emitLocalIdeEvent(event); + await waitFor(() => expect(notes).toHaveLength(1)); + expect(notes[0]).toMatchObject({ title: "clangd", level: "warning", sticky: true, body: event.message }); + expect(notes[0]!.actions!.map((a) => a.label)).toEqual(["Install", "Language Servers…"]); + notes[0]!.actions![0]!.run(); + await waitFor(() => expect(backend.restarted).toEqual(["clangd"])); + expect(notes.at(-1)).toMatchObject({ title: "clangd installed", key: notes[0]!.key }); + } finally { + off(); + } + }); + + it("keeps the Settings action when the tool cannot be installed", async () => { + const off = installServerWarningPrompts(); + try { + emitLocalIdeEvent(event); + await waitFor(() => expect(notes).toHaveLength(1)); + expect(notes[0]!.actions!.map((a) => a.label)).toEqual(["Language Servers…"]); + } finally { + off(); + } + }); +}); diff --git a/src/features/tools/index.ts b/src/features/tools/index.ts index 76736a60..f9c79d9a 100644 --- a/src/features/tools/index.ts +++ b/src/features/tools/index.ts @@ -9,7 +9,7 @@ import { lazyComponent } from "@/ui"; import { executeAction, registerActions } from "@/platform/actions"; import { registerSettingsPage } from "@/workbench/contributions"; import { installJdkPrompts, registerJdkActions } from "./jdk"; -import { installMissingServerPrompts, promptInstall } from "./missing"; +import { installMissingServerPrompts, installServerWarningPrompts, promptInstall } from "./missing"; import { installTool, refreshTools, SETTINGS_PAGE_ID, toolByName, useTools } from "./store"; /** The Language Servers page (also used by the Settings window). */ @@ -79,6 +79,7 @@ export function activate(): () => void { }, ]), installMissingServerPrompts(), + installServerWarningPrompts(), registerJdkActions(), installJdkPrompts(), ]; diff --git a/src/features/tools/missing.ts b/src/features/tools/missing.ts index c1c48d1a..6bd53191 100644 --- a/src/features/tools/missing.ts +++ b/src/features/tools/missing.ts @@ -56,3 +56,31 @@ export function installMissingServerPrompts(): () => void { void promptInstall(e.name).catch(() => {}); }); } + +/** A system server that lacks a capability can be replaced by the managed tool. */ +export function installServerWarningPrompts(): () => void { + const prompted = new Set(); + return onIdeEvent("languageServerWarning", (e) => { + const key = `lsp-warning-${e.name}-${e.installTool}`; + if (prompted.has(key)) return; + prompted.add(key); + void (async () => { + if (!useTools.getState().loaded) await refreshTools(); + const tool = toolByName(e.installTool); + notifications().notify({ + group: "lsp", + level: "warning", + title: e.name, + body: tx(e.message), + key, + sticky: true, + actions: [ + ...(tool?.canInstall + ? [{ label: "Install", run: () => void installTool(e.installTool, { notificationKey: key }) }] + : []), + { label: "Language Servers…", run: () => void executeAction("file.settings", SETTINGS_PAGE_ID) }, + ], + }); + })(); + }); +} diff --git a/src/features/vcs/__tests__/inlineBlame.test.ts b/src/features/vcs/__tests__/inlineBlame.test.ts new file mode 100644 index 00000000..e49cb3de --- /dev/null +++ b/src/features/vcs/__tests__/inlineBlame.test.ts @@ -0,0 +1,255 @@ +import { EditorSelection, EditorState } from "@codemirror/state"; +import { EditorView, activateHover, closeHoverTooltips, hasHoverTooltips } from "@codemirror/view"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { BlameLine, Settings } from "@/bindings"; +import { HEAVY_LIMIT, _emitEditorEvent } from "@/editor/api"; +import { useProject } from "@/platform/project"; +import { useI18n } from "@/platform/i18n"; +import { formatInlineBlame, inlineBlame, relativeBlameDate } from "../inlineBlame"; +import { useVcs } from "../store"; + +const mocks = vi.hoisted(() => ({ blame: vi.fn(), file: vi.fn(), log: vi.fn(), action: vi.fn() })); +vi.mock("@/platform/actions", async (original) => ({ + ...(await original()), + executeAction: mocks.action, +})); +vi.mock("@/platform/ipc", async (original) => { + const actual = await original(); + return { + ...actual, + isTauri: () => true, + commands: { ...actual.commands, vcsBlame: mocks.blame, vcsFileAt: mocks.file, vcsLog: mocks.log }, + }; +}); + +const now = Date.UTC(2026, 10, 2, 12); +const alice: BlameLine = { + commit: "a".repeat(40), + author: "Alice Smith", + timeMs: now - 86400_000, + summary: "First line", +}; +const bob: BlameLine = { commit: "b".repeat(40), author: "Bob Jones", timeMs: now - 3600_000, summary: "Second line" }; +const uncommitted: BlameLine = { commit: "0".repeat(40), author: "Not Committed Yet", timeMs: now, summary: "" }; +const ctx = { doc: 1, path: "/repo/file.ts", language: "typescript", uiMode: null, readOnly: false }; +let view: EditorView | undefined; + +function settings(enabled: boolean) { + useProject.setState({ settings: { vcs: { git: { inlineBlame: enabled } } } as Settings }); +} + +function editor(doc = "one\ntwo\nthree", context = ctx) { + view = new EditorView({ + parent: document.body, + state: EditorState.create({ doc, extensions: [inlineBlame(context)] }), + }); + return view; +} + +const hint = () => view?.dom.querySelector(".cm-vcs-inline-blame"); +const tick = () => vi.advanceTimersByTimeAsync(300); + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(now); + vi.clearAllMocks(); + useI18n.setState({ locale: "en", texts: {} }); + settings(true); + useVcs.setState({ available: true, roots: ["/repo"], statusLoaded: true, byPath: new Map(), version: 0 }); + mocks.file.mockResolvedValue({ status: "ok", data: "one\ntwo\nthree" }); + mocks.blame.mockResolvedValue({ status: "ok", data: [alice, bob, uncommitted] }); +}); + +afterEach(() => { + view?.destroy(); + view = undefined; + useProject.setState({ settings: null }); + vi.useRealTimers(); +}); + +describe("inline blame formatting", () => { + it("formats authors, relative dates, summaries and missing dates", () => { + expect(formatInlineBlame(alice, now)).toBe("Alice Smith, 1 day ago · First line"); + expect(formatInlineBlame({ ...bob, timeMs: null, summary: "Long\nmessage" }, now)).toBe("Bob Jones · Long message"); + expect(formatInlineBlame(uncommitted, now)).toBe("You, now · Uncommitted changes"); + expect(relativeBlameDate(now + 1000, now)).toBe("now"); + expect(relativeBlameDate(now - 120_000, now)).toBe("2 minutes ago"); + expect(relativeBlameDate(now - 400 * 86400_000, now)).toBe("1 year ago"); + }); +}); + +describe("current line decoration", () => { + it("debounces caret moves and reuses blame for the same document version", async () => { + const v = editor(); + await vi.advanceTimersByTimeAsync(299); + expect(mocks.blame).not.toHaveBeenCalled(); + v.dispatch({ selection: { anchor: 4 } }); + await vi.advanceTimersByTimeAsync(299); + expect(mocks.blame).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(hint()?.textContent).toContain("Bob Jones"); + v.dispatch({ selection: { anchor: 0 } }); + expect(hint()).toBeNull(); + await tick(); + expect(hint()?.textContent).toContain("Alice Smith"); + expect(mocks.blame).toHaveBeenCalledTimes(1); + expect(mocks.file).toHaveBeenCalledTimes(1); + expect(hint()?.contentEditable).toBe("false"); + hint()?.click(); + expect(mocks.action).toHaveBeenCalledWith("vcs.showCommit", alice.commit); + }); + + it("uses only the primary selection and hides multiline selections", async () => { + const v = editor(); + v.dispatch({ selection: EditorSelection.create([EditorSelection.cursor(0), EditorSelection.cursor(4)], 1) }); + await tick(); + expect(hint()?.textContent).toContain("Bob Jones"); + v.dispatch({ selection: { anchor: 0, head: 5 } }); + await tick(); + expect(hint()).toBeNull(); + v.dispatch({ selection: { anchor: 4, head: 6 } }); + await tick(); + expect(hint()?.textContent).toContain("Bob Jones"); + }); + + it("loads and caches the full commit message on hover and closes it on caret movement", async () => { + const message = "First line\n\nThe complete commit body."; + mocks.log.mockResolvedValue({ status: "ok", data: [{ message }] }); + const v = editor(); + await tick(); + activateHover(v, 3, 1); + await vi.advanceTimersByTimeAsync(0); + expect(hasHoverTooltips(v.state)).toBe(true); + const tooltip = v.dom.querySelector(".cm-vcs-inline-blame-tooltip"); + expect(tooltip?.textContent).toContain(message); + expect(tooltip?.textContent).toContain(alice.author); + expect(tooltip?.textContent).toContain(alice.commit); + expect(tooltip?.textContent).toContain(new Date(alice.timeMs!).toLocaleString()); + v.dispatch({ effects: closeHoverTooltips }); + activateHover(v, 3, 1); + await vi.advanceTimersByTimeAsync(0); + expect(mocks.log).toHaveBeenCalledTimes(1); + v.dispatch({ selection: { anchor: 4 } }); + expect(hasHoverTooltips(v.state)).toBe(false); + }); + + it("closes an open tooltip when the setting is disabled", async () => { + mocks.log.mockResolvedValue({ status: "ok", data: [{ message: "Full message" }] }); + const v = editor(); + await tick(); + activateHover(v, 3, 1); + await vi.advanceTimersByTimeAsync(0); + expect(hasHoverTooltips(v.state)).toBe(true); + settings(false); + expect(hasHoverTooltips(v.state)).toBe(false); + }); + + it("refetches edits, saves and VCS refreshes", async () => { + const v = editor(); + await tick(); + mocks.blame.mockResolvedValue({ status: "ok", data: [uncommitted, bob, uncommitted] }); + v.dispatch({ changes: { from: 0, insert: "x" } }); + expect(hint()).toBeNull(); + await tick(); + expect(mocks.blame).toHaveBeenLastCalledWith(ctx.path, "xone\ntwo\nthree", null); + expect(hint()?.textContent).toBe("You, now · Uncommitted changes"); + _emitEditorEvent({ type: "docSaved", doc: 1, path: ctx.path }); + await tick(); + useVcs.setState({ version: 1 }); + await tick(); + expect(mocks.blame).toHaveBeenCalledTimes(4); + }); + + it("discards a response from an older document version", async () => { + let resolve!: (result: { status: "ok"; data: BlameLine[] }) => void; + mocks.blame.mockReturnValueOnce(new Promise((done) => (resolve = done))); + const v = editor(); + await tick(); + v.dispatch({ changes: { from: 0, insert: "x" } }); + resolve({ status: "ok", data: [alice] }); + await vi.advanceTimersByTimeAsync(0); + expect(hint()).toBeNull(); + mocks.blame.mockResolvedValue({ status: "ok", data: [uncommitted] }); + await tick(); + expect(hint()?.textContent).toBe("You, now · Uncommitted changes"); + }); + + it("coalesces an in-flight blame when the caret moves", async () => { + let resolve!: (result: { status: "ok"; data: BlameLine[] }) => void; + mocks.blame.mockReturnValueOnce(new Promise((done) => (resolve = done))); + const v = editor(); + await tick(); + v.dispatch({ selection: { anchor: 4 } }); + await tick(); + resolve({ status: "ok", data: [alice, bob] }); + await vi.advanceTimersByTimeAsync(0); + expect(hint()?.textContent).toContain("Bob Jones"); + expect(mocks.blame).toHaveBeenCalledTimes(1); + }); + + it("responds to the setting immediately", async () => { + settings(false); + editor(); + await tick(); + expect(mocks.blame).not.toHaveBeenCalled(); + settings(true); + await tick(); + expect(hint()).not.toBeNull(); + settings(false); + expect(hint()).toBeNull(); + await tick(); + expect(mocks.blame).toHaveBeenCalledTimes(1); + }); + + it("ignores a pending response after disabling", async () => { + let resolve!: (result: { status: "ok"; data: BlameLine[] }) => void; + mocks.blame.mockReturnValueOnce(new Promise((done) => (resolve = done))); + editor(); + await tick(); + settings(false); + resolve({ status: "ok", data: [alice] }); + await vi.advanceTimersByTimeAsync(0); + expect(hint()).toBeNull(); + await tick(); + expect(hint()).toBeNull(); + }); + + it.each(["untracked", "ignored"] as const)("skips %s paths", async (unstaged) => { + useVcs.setState({ + byPath: new Map([[ctx.path, { path: ctx.path, relPath: "file.ts", unstaged, staged: null, renamedFrom: null }]]), + }); + editor(); + await tick(); + expect(mocks.file).not.toHaveBeenCalled(); + expect(hint()).toBeNull(); + }); + + it("skips ignored paths missing from status and HEAD", async () => { + mocks.file.mockResolvedValue({ status: "ok", data: null }); + editor(); + await tick(); + expect(mocks.blame).not.toHaveBeenCalled(); + expect(hint()).toBeNull(); + }); + + it("skips paths outside repositories", async () => { + editor("one", { ...ctx, path: "/elsewhere/file.ts" }); + await tick(); + expect(mocks.file).not.toHaveBeenCalled(); + }); + + it("skips large, virtual and read-only documents", async () => { + editor("x".repeat(HEAVY_LIMIT + 1)); + await tick(); + expect(mocks.file).not.toHaveBeenCalled(); + expect(inlineBlame({ ...ctx, virtual: true })).toEqual([]); + expect(inlineBlame({ ...ctx, readOnly: true })).toEqual([]); + }); + + it("cancels pending work when the view is destroyed", async () => { + editor().destroy(); + view = undefined; + await tick(); + expect(mocks.file).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/vcs/index.ts b/src/features/vcs/index.ts index 80198517..9e74b30c 100644 --- a/src/features/vcs/index.ts +++ b/src/features/vcs/index.ts @@ -23,6 +23,7 @@ import { registerHistoryActions, trackOperationContext } from "./historyActions" import { registerLogPaneActions } from "./logPaneActions"; import { controlLabel, controlOperation, inProgress } from "./operations"; import { annotationsShown, blameSlot } from "./blame"; +import { inlineBlame } from "./inlineBlame"; import { BranchWidget, useBranchesPopup } from "./BranchWidget"; import { showConflictsDialog } from "./conflictsUi"; import { registerCompareActions } from "./compare"; @@ -212,7 +213,7 @@ export function activate(): () => void { registerEditorContribution({ id: "vcs.changeMarkers", order: 60, - extensions: (ctx) => [changeMarkers(ctx), blameSlot()], + extensions: (ctx) => [changeMarkers(ctx), blameSlot(), inlineBlame(ctx)], }), registerMenuGroup({ id: "vcs.gutter", diff --git a/src/features/vcs/inlineBlame.ts b/src/features/vcs/inlineBlame.ts new file mode 100644 index 00000000..047b3136 --- /dev/null +++ b/src/features/vcs/inlineBlame.ts @@ -0,0 +1,281 @@ +import { StateEffect, StateField, type Extension, type Text } from "@codemirror/state"; +import { + Decoration, + EditorView, + ViewPlugin, + WidgetType, + hoverTooltip, + type DecorationSet, + type ViewUpdate, +} from "@codemirror/view"; +import type { BlameLine } from "@/bindings"; +import { HEAVY_LIMIT, onEditorEvent, type EditorDocContext } from "@/editor/api"; +import { executeAction } from "@/platform/actions"; +import { tx, useI18n } from "@/platform/i18n"; +import { call, commands, isTauri } from "@/platform/ipc"; +import { onWorkflowSettingsChange, workflowSettings } from "@/platform/workflowSettings"; +import { isUncommitted } from "./blame"; +import { repoOf, statusOf, useVcs } from "./store"; + +export function relativeBlameDate(timeMs: number | null, now = Date.now()): string { + if (timeMs === null) return ""; + const seconds = Math.max(0, (now - timeMs) / 1000); + const units = [ + ["year", 365 * 86400], + ["month", 30 * 86400], + ["day", 86400], + ["hour", 3600], + ["minute", 60], + ] as const; + const unit = units.find(([, size]) => seconds >= size); + return unit + ? new Intl.RelativeTimeFormat(useI18n.getState().locale, { numeric: "always" }).format( + -Math.floor(seconds / unit[1]), + unit[0], + ) + : tx("now"); +} + +export function formatInlineBlame(line: BlameLine, now = Date.now()): string { + if (isUncommitted(line)) return tx("You, now · Uncommitted changes"); + const date = relativeBlameDate(line.timeMs, now); + return `${line.author}${date ? `, ${date}` : ""} · ${line.summary.replace(/\s+/g, " ")}`; +} + +/** Only the current document version is retained, including its in-flight request. */ +class BlameCache { + private doc: Text | undefined; + private pending: Promise | undefined; + + clear() { + this.doc = undefined; + this.pending = undefined; + } + + get(doc: Text, load: () => Promise): Promise { + if (doc !== this.doc || !this.pending) { + this.doc = doc; + this.pending = load(); + } + return this.pending; + } +} + +class BlameWidget extends WidgetType { + constructor(readonly line: BlameLine) { + super(); + } + + eq(other: BlameWidget) { + return formatInlineBlame(other.line) === formatInlineBlame(this.line) && other.line.commit === this.line.commit; + } + + toDOM() { + const el = document.createElement("span"); + el.className = "cm-vcs-inline-blame"; + el.textContent = formatInlineBlame(this.line); + el.contentEditable = "false"; + if (!isUncommitted(this.line)) { + el.setAttribute("role", "button"); + el.tabIndex = 0; + const open = () => void executeAction("vcs.showCommit", this.line.commit); + el.addEventListener("mousedown", (e) => e.preventDefault()); + el.addEventListener("click", open); + el.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + open(); + } + }); + } + return el; + } +} + +const setInlineBlame = StateEffect.define(); +const inlineBlameField = StateField.define({ + create: () => Decoration.none, + update(value, tr) { + if (tr.docChanged || tr.selection) return Decoration.none; + for (const effect of tr.effects) if (effect.is(setInlineBlame)) return effect.value; + return value; + }, + provide: (field) => EditorView.decorations.from(field), +}); + +export function inlineBlame(ctx: EditorDocContext): Extension { + if (ctx.virtual || ctx.readOnly) return []; + const plugin = ViewPlugin.fromClass( + class { + private timer: ReturnType | undefined; + private readonly cache = new BlameCache(); + private readonly messages = new Map>(); + private readonly off: (() => void)[]; + private generation = 0; + private destroyed = false; + line: BlameLine | undefined; + + constructor(readonly view: EditorView) { + this.off = [ + useVcs.subscribe((s, prev) => { + if (s.version !== prev.version) this.invalidate(); + }), + onEditorEvent((e) => { + if (e.type === "docSaved" && e.path === ctx.path) this.invalidate(); + }), + onWorkflowSettingsChange( + (s) => s.vcs.git.inlineBlame, + () => this.invalidate(), + ), + useI18n.subscribe(() => this.invalidate()), + ]; + this.schedule(); + } + + private enabled() { + const vcs = useVcs.getState(); + const status = statusOf(ctx.path); + return ( + workflowSettings().vcs.git.inlineBlame && + isTauri() && + vcs.available && + vcs.statusLoaded && + !!repoOf(ctx.path) && + status?.unstaged !== "untracked" && + status?.unstaged !== "ignored" && + this.view.state.doc.length <= HEAVY_LIMIT + ); + } + + private clear() { + this.line = undefined; + if (this.view.state.field(inlineBlameField).size) + this.view.dispatch({ effects: setInlineBlame.of(Decoration.none) }); + } + + private invalidate() { + this.cache.clear(); + this.clear(); + this.schedule(); + } + + update(update: ViewUpdate) { + if (update.docChanged) this.cache.clear(); + if (update.docChanged || update.selectionSet) { + this.line = undefined; + this.schedule(); + } + } + + private schedule() { + clearTimeout(this.timer); + ++this.generation; + this.timer = setTimeout(() => void this.refresh(), 300); + } + + private async refresh() { + if (this.destroyed || !this.enabled()) return; + const { doc, selection } = this.view.state; + const caret = doc.lineAt(selection.main.head); + if (doc.lineAt(selection.main.from).number !== doc.lineAt(selection.main.to).number) return; + const generation = this.generation; + try { + const lines = await this.cache.get(doc, async () => { + // Blame also returns synthetic entries for paths absent from HEAD (ignored / new files). + if ((await call(commands.vcsFileAt(ctx.path, "HEAD"))) === null) return []; + return call(commands.vcsBlame(ctx.path, doc.toString(), null)); + }); + if (this.destroyed || generation !== this.generation || doc !== this.view.state.doc || !this.enabled()) + return; + this.line = lines[caret.number - 1]; + if (this.line) { + const widget = Decoration.widget({ widget: new BlameWidget(this.line), side: 1 }).range(caret.to); + this.view.dispatch({ effects: setInlineBlame.of(Decoration.set([widget])) }); + } + } catch { + // Repository removed, unborn HEAD, inaccessible file, etc.: leave the line undecorated. + } + } + + message(line: BlameLine): Promise { + if (isUncommitted(line)) return Promise.resolve(tx("You, now · Uncommitted changes")); + let message = this.messages.get(line.commit); + if (!message) { + message = call( + commands.vcsLog(repoOf(ctx.path), { + branch: line.commit, + path: null, + author: null, + text: null, + skip: 0, + limit: 1, + }), + ).then( + (commits) => commits[0]?.message ?? line.summary, + () => line.summary, + ); + if (this.messages.size >= 100) this.messages.delete(this.messages.keys().next().value!); + this.messages.set(line.commit, message); + } + return message; + } + + destroy() { + this.destroyed = true; + clearTimeout(this.timer); + this.cache.clear(); + this.off.forEach((off) => off()); + } + }, + ); + return [ + inlineBlameField, + plugin, + hoverTooltip( + async (view, pos, side) => { + const instance = view.plugin(plugin); + const line = instance?.line; + const end = view.state.doc.lineAt(view.state.selection.main.head).to; + if (!instance || !line || pos !== end || side < 0) return null; + const message = await instance.message(line); + if (view.plugin(plugin)?.line !== line) return null; + return { + pos: end, + above: true, + create() { + const dom = document.createElement("div"); + dom.className = "cm-vcs-inline-blame-tooltip"; + dom.textContent = isUncommitted(line) + ? message + : `${line.author}\n${line.timeMs === null ? "" : new Date(line.timeMs).toLocaleString()}\n${line.commit}\n\n${message}`; + return { dom }; + }, + }; + }, + { + hideOnChange: true, + hideOn: (tr) => tr.effects.some((effect) => effect.is(setInlineBlame)), + }, + ), + EditorView.baseTheme({ + ".cm-vcs-inline-blame": { + color: "var(--fg-inlay)", + marginLeft: "3ch", + display: "inline-block", + maxWidth: "min(60ch, 50vw)", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + verticalAlign: "bottom", + userSelect: "none", + }, + '.cm-vcs-inline-blame[role="button"]': { cursor: "pointer" }, + ".cm-vcs-inline-blame-tooltip": { + padding: "8px 12px", + maxWidth: "min(70ch, 80vw)", + whiteSpace: "pre-wrap", + overflowWrap: "anywhere", + }, + }), + ]; +} diff --git a/src/features/viewers/file-probe.test.ts b/src/features/viewers/file-probe.test.ts new file mode 100644 index 00000000..49ae1bd4 --- /dev/null +++ b/src/features/viewers/file-probe.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { commands, type FileProbe } from "@/platform/ipc"; +import { resolveFileEditor } from "@/workbench/contributions"; +import { activate } from "./index"; + +let dispose: (() => void) | undefined; + +afterEach(() => { + dispose?.(); + dispose = undefined; + vi.restoreAllMocks(); +}); + +describe("file probes", () => { + it.each([false, true])("shares an in-flight probe without caching its result (binary: %s)", async (binary) => { + let finish!: (result: { status: "ok"; data: FileProbe }) => void; + const pending = new Promise<{ status: "ok"; data: FileProbe }>((resolve) => { + finish = resolve; + }); + const probe = vi.spyOn(commands, "fsProbe").mockReturnValue(pending); + dispose = activate(); + const first = resolveFileEditor("/p/a.ts"); + const second = resolveFileEditor("/p/a.ts"); + await vi.waitFor(() => expect(probe).toHaveBeenCalled()); + finish({ status: "ok", data: { size: 12, binary, tooLarge: false } }); + const resolutions = await Promise.all([first, second]); + expect(probe).toHaveBeenCalledTimes(1); + expect(resolutions.map((r) => r?.input.kind ?? "file")).toEqual(binary ? ["binary", "binary"] : ["file", "file"]); + if (binary) expect(resolutions[0]).not.toBe(resolutions[1]); + probe.mockResolvedValue({ status: "ok", data: { size: 20, binary: !binary, tooLarge: false } }); + expect((await resolveFileEditor("/p/a.ts"))?.input.kind ?? "file").toBe(binary ? "file" : "binary"); + expect(probe).toHaveBeenCalledTimes(2); + }); + + it("retries a failed probe", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + const probe = vi + .spyOn(commands, "fsProbe") + .mockRejectedValueOnce(new Error("read failed")) + .mockResolvedValue({ status: "ok", data: { size: 20, binary: true, tooLarge: false } }); + dispose = activate(); + expect(await resolveFileEditor("/p/a.ts")).toBeNull(); + expect((await resolveFileEditor("/p/a.ts"))?.input.kind).toBe("binary"); + expect(probe).toHaveBeenCalledTimes(2); + }); + + it("probes different files independently", async () => { + const probe = vi.spyOn(commands, "fsProbe").mockResolvedValue({ + status: "ok", + data: { size: 20, binary: false, tooLarge: false }, + }); + dispose = activate(); + await Promise.all([resolveFileEditor("/p/a.ts"), resolveFileEditor("/p/b.ts")]); + expect(probe.mock.calls).toEqual([["/p/a.ts"], ["/p/b.ts"]]); + }); +}); diff --git a/src/features/viewers/index.ts b/src/features/viewers/index.ts index 92ac8c15..a0137a65 100644 --- a/src/features/viewers/index.ts +++ b/src/features/viewers/index.ts @@ -5,7 +5,7 @@ * Editor / Editor and Preview / Preview layouts around the text editor. */ import { registerActions, type Action } from "@/platform/actions"; -import { call, commands } from "@/platform/ipc"; +import { call, commands, type FileProbe } from "@/platform/ipc"; import { getContext } from "@/platform/context"; import { basename } from "@/platform/project"; import { fileIconName, lazyComponent } from "@/ui"; @@ -78,6 +78,7 @@ function markdownActions(): Action[] { } export function activate(): () => void { + const probes = new Map>(); const disposers = [ registerEditorKind({ kind: IMAGE_KIND, component: ImageViewer, title: titleOf, icon: iconOf }), registerEditorKind({ kind: BINARY_KIND, component: HexViewer, title: titleOf, icon: iconOf }), @@ -90,7 +91,12 @@ export function activate(): () => void { id: "viewers.hex", order: 50, resolve: async (path) => { - const probe = await call(commands.fsProbe(path)); + let pending = probes.get(path); + if (!pending) { + pending = call(commands.fsProbe(path)).finally(() => probes.delete(path)); + probes.set(path, pending); + } + const probe = await pending; return wantsHexView(probe) ? binaryInput(path, probe) : null; }, }), diff --git a/src/platform/clientSettings.ts b/src/platform/clientSettings.ts index b2c2c83b..742e797d 100644 --- a/src/platform/clientSettings.ts +++ b/src/platform/clientSettings.ts @@ -77,6 +77,7 @@ export const CLIENT_SETTINGS_KEYS = [ "run.confirmTerminate", "run.showOnStart", "vcs.showDirtyDirectories", + "vcs.git.inlineBlame", "vcs.git.showPushDialog", "vcs.git.pushDialogOnlyProtected", "vcs.git.combineStashAndShelf", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index daeb595f..844d5081 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1,4 +1,5 @@ { + "Apple's clangd does not run clang-tidy, so checks from .clang-tidy are not shown. Install clangd (LLVM) to get them.": "Apple 的 clangd 不运行 clang-tidy,因此不会显示 .clang-tidy 中配置的检查。请安装 clangd(LLVM)以启用这些检查。", "Newer dependency version available": "有更新的依赖版本可用", "Reports newer crate versions that Cargo.toml requirements do not already allow.": "报告 Cargo.toml 版本要求尚未允许的较新 crate 版本。", "Update to {latest}": "更新至 {latest}", @@ -1613,16 +1614,18 @@ "Install Missing Tool": "安装缺失的工具", "Prettier formatting failed": "Prettier 格式化失败", "Prettier formatting timed out": "Prettier 格式化超时", - "Prettier does not support selection formatting for this language; formatted the whole file": "Prettier 不支持此语言的选区格式化,已格式化整个文件", + "oxfmt formatting failed": "oxfmt 格式化失败", + "oxfmt formatting timed out": "oxfmt 格式化超时", + "{name} does not support selection formatting for this language; formatted the whole file": "{name} 不支持此语言的选区格式化,已格式化整个文件", "Formatting timed out": "格式化超时", - "Prettier: Node.js is required": "Prettier:需要 Node.js", + "{name}: Node.js is required": "{name}:需要 Node.js", "Formatter": "格式化工具", "JSON with Comments": "带注释的 JSON", "Unknown external formatter: {tool}": "未知的外部格式化工具:{tool}", - "Prettier: package has no CLI entry point": "Prettier:软件包未提供命令行入口", - "Prettier: CLI does not exist: {path}": "Prettier:命令行程序不存在:{path}", - "Prettier: file has no directory": "Prettier:文件路径不含目录", - "Prettier: {error}": "Prettier:{error}", + "{name}: package has no CLI entry point": "{name}:软件包未提供命令行入口", + "{name}: CLI does not exist: {path}": "{name}:命令行程序不存在:{path}", + "{name}: file has no directory": "{name}:文件路径不含目录", + "{name}: {error}": "{name}:{error}", "Reformat File": "重新格式化文件", "Reformat code": "重新格式化代码", "Reformat code on save": "保存时重新格式化代码", @@ -5494,5 +5497,8 @@ "Cannot paste image": "无法粘贴图片", "Remote server does not support image paste.": "远程服务器不支持粘贴图片。", "Clipboard image reading is unavailable.": "无法读取剪贴板图片。", - "Image is too large to paste (limit {limit} MB).": "图片过大,无法粘贴(上限 {limit} MB)。" + "Image is too large to paste (limit {limit} MB).": "图片过大,无法粘贴(上限 {limit} MB)。", + "Show inline blame for the current line": "为当前行显示内联 Git Blame", + "You, now · Uncommitted changes": "你,刚刚 · 未提交的更改", + "now": "刚刚" } diff --git a/src/platform/workflowSettings.test.ts b/src/platform/workflowSettings.test.ts index 1721e2ca..d12da7d5 100644 --- a/src/platform/workflowSettings.test.ts +++ b/src/platform/workflowSettings.test.ts @@ -3,6 +3,12 @@ import { splitCommandLine } from "./commandLine"; import { DEFAULT_WORKFLOW, validateWorkflow } from "./workflowSettings"; describe("validateWorkflow", () => { + it("defaults inline blame off and accepts only boolean values", () => { + expect(DEFAULT_WORKFLOW.vcs.git.inlineBlame).toBe(false); + expect(validateWorkflow({ vcs: { git: { inlineBlame: true } } }).vcs.git.inlineBlame).toBe(true); + expect(validateWorkflow({ vcs: { git: { inlineBlame: "true" } } }).vcs.git.inlineBlame).toBe(false); + }); + it("falls back to defaults for missing or malformed values", () => { expect(validateWorkflow(undefined)).toEqual(DEFAULT_WORKFLOW); const v = validateWorkflow({ diff --git a/src/platform/workflowSettings.ts b/src/platform/workflowSettings.ts index 42871480..559d20d9 100644 --- a/src/platform/workflowSettings.ts +++ b/src/platform/workflowSettings.ts @@ -18,6 +18,7 @@ export interface IssueLinkValid { } export interface GitSettingsValid { + inlineBlame: boolean; executable: string; updateMethod: UpdateMethod; autoUpdateOnPushRejected: boolean; @@ -183,6 +184,7 @@ export const DEFAULT_WORKFLOW: WorkflowSettings = { limitHistory: true, historyLimit: 1000, git: { + inlineBlame: false, executable: "", updateMethod: "merge", autoUpdateOnPushRejected: false, @@ -309,6 +311,7 @@ export function validateVcs(raw: unknown): VcsSettingsValid { limitHistory: bool(v.limitHistory, d.limitHistory), historyLimit: num(v.historyLimit, d.historyLimit, L.historyLimit, true), git: { + inlineBlame: bool(g.inlineBlame, d.git.inlineBlame), executable: text(g.executable, d.git.executable).trim(), updateMethod: oneOf(g.updateMethod, ["merge", "rebase"] as const, d.git.updateMethod), autoUpdateOnPushRejected: bool(g.autoUpdateOnPushRejected, d.git.autoUpdateOnPushRejected), diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index bcf50a27..bcaf888b 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -130,6 +130,8 @@ const ICONS = { "file-gdscript": "openidea/fileTypes/gdscript", "file-godot": "openidea/fileTypes/godot", "file-gdshader": "openidea/fileTypes/gdshader", + "file-asciidoc": "openidea/fileTypes/asciidoc", + "file-d": "openidea/fileTypes/d", "file-dart": "openidea/fileTypes/dart", "file-elixir": "openidea/fileTypes/elixir", "file-erlang": "openidea/fileTypes/erlang", @@ -825,7 +827,7 @@ const EXT_ICONS = table({ "file-rst": "rst", "file-yaml": "yaml yml", "file-toml": "toml", - "file-text": "txt text adoc", + "file-text": "txt text", "file-log": "log", "file-csv": "csv tsv", "file-html": "html htm shtml html.tmpl", @@ -878,6 +880,8 @@ const EXT_ICONS = table({ "file-gdscript": "gd", "file-godot": "tscn tres godot import", "file-gdshader": "gdshader gdshaderinc", + "file-asciidoc": "adoc asciidoc", + "file-d": "d di", "file-dart": "dart", "file-elixir": "ex exs", "file-erlang": "erl hrl escript app.src", diff --git a/src/ui/openidea/fileTypes/asciidoc.svg b/src/ui/openidea/fileTypes/asciidoc.svg new file mode 100644 index 00000000..81076e49 --- /dev/null +++ b/src/ui/openidea/fileTypes/asciidoc.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/openidea/fileTypes/asciidoc_dark.svg b/src/ui/openidea/fileTypes/asciidoc_dark.svg new file mode 100644 index 00000000..2f60166f --- /dev/null +++ b/src/ui/openidea/fileTypes/asciidoc_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/openidea/fileTypes/d.svg b/src/ui/openidea/fileTypes/d.svg new file mode 100644 index 00000000..861238ca --- /dev/null +++ b/src/ui/openidea/fileTypes/d.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/openidea/fileTypes/d_dark.svg b/src/ui/openidea/fileTypes/d_dark.svg new file mode 100644 index 00000000..216cac95 --- /dev/null +++ b/src/ui/openidea/fileTypes/d_dark.svg @@ -0,0 +1,3 @@ + + +