mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 00:07:50 +00:00
Merge remote-tracking branch 'origin/main' into pr-758
This commit is contained in:
@@ -82,6 +82,12 @@ pub enum IdeEvent {
|
||||
status: ServerStatus,
|
||||
message: Option<String>,
|
||||
},
|
||||
/// 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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Vec<TextEdit>> = 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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<dyn Handler>,
|
||||
on_stderr: Box<dyn Fn(String) + Send + Sync>,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<(Peer, Option<PathBuf>), 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<dyn Fn(String) + Send + Sync>,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Peer, LaunchError> {
|
||||
self.launch_resolved(config, root, handler, on_stderr, cancel).map(|(peer, _)| peer)
|
||||
}
|
||||
|
||||
fn launch_resolved(
|
||||
&self,
|
||||
config: &LanguageServerConfig,
|
||||
root: &Path,
|
||||
handler: Arc<dyn Handler>,
|
||||
on_stderr: Box<dyn Fn(String) + Send + Sync>,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<(Peer, Option<PathBuf>), 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())))
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ pub struct Server {
|
||||
pub config: LanguageServerConfig,
|
||||
/// `config` with the settings hook applied, as the current instance was launched.
|
||||
effective: Mutex<Option<LanguageServerConfig>>,
|
||||
pub resolved_binary: RwLock<Option<PathBuf>>,
|
||||
languages: Mutex<Vec<LanguageId>>,
|
||||
state: Mutex<State>,
|
||||
/// 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()),
|
||||
|
||||
@@ -21,6 +21,7 @@ use std::time::Duration;
|
||||
struct MockState {
|
||||
caps: Mutex<Value>,
|
||||
missing: std::sync::atomic::AtomicBool,
|
||||
binary: Mutex<Option<PathBuf>>,
|
||||
launches: AtomicUsize,
|
||||
notifications: Mutex<Vec<(String, Value)>>,
|
||||
requests: Mutex<Vec<(String, Value)>>,
|
||||
@@ -148,6 +149,18 @@ impl MockLauncher {
|
||||
}
|
||||
|
||||
impl Launcher for MockLauncher {
|
||||
fn launch_resolved(
|
||||
&self,
|
||||
config: &LanguageServerConfig,
|
||||
root: &Path,
|
||||
handler: Arc<dyn Handler>,
|
||||
on_stderr: Box<dyn Fn(String) + Send + Sync>,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> Result<(Peer, Option<PathBuf>), 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;
|
||||
}
|
||||
|
||||
@@ -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<String>, 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<String>, 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() };
|
||||
|
||||
@@ -13,20 +13,30 @@ pub fn find_up(file: &Path, relative: &Path) -> Option<PathBuf> {
|
||||
file.parent()?.ancestors().map(|dir| dir.join(relative)).find(|p| p.is_file())
|
||||
}
|
||||
|
||||
fn package_bin(package: &Path) -> IdeResult<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<Command> {
|
||||
@@ -34,22 +44,26 @@ impl Tools {
|
||||
}
|
||||
|
||||
fn resolve_formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult<Command> {
|
||||
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<OsString> {
|
||||
let parser = if config.parser == "markdown" && file.extension().is_some_and(|s| s == "mdx") {
|
||||
"mdx"
|
||||
} else {
|
||||
&config.parser
|
||||
};
|
||||
let mut args: Vec<OsString> = [
|
||||
"--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<OsString> = 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<String> {
|
||||
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<String> {
|
||||
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::<Vec<_>>(),
|
||||
root.join("node_modules/oxfmt/bin/oxfmt").components().collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
id = "asciidoc"
|
||||
name = "AsciiDoc"
|
||||
extensions = ["adoc", "asciidoc"]
|
||||
line-comment = "//"
|
||||
indent = { tab-size = 2, insert-spaces = true }
|
||||
ui-mode = "AsciiDoc"
|
||||
@@ -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`"
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -119,6 +119,8 @@ const EXT_LANG: Record<string, string> = {
|
||||
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",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { StreamLanguage, type StreamParser } from "@codemirror/language";
|
||||
|
||||
type State = { block: string | null };
|
||||
|
||||
const parser: StreamParser<State> = {
|
||||
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);
|
||||
@@ -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 [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -171,6 +171,14 @@ function isTextTab(tab: Tab | null | undefined): tab is Tab & { input: { path: s
|
||||
|
||||
type EditorKinds = ReturnType<typeof useContributions.getState>["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<string, number>();
|
||||
private activationGeneration = 0;
|
||||
private admitting = false;
|
||||
private closeRequests = 0;
|
||||
private projectTransition: Promise<void> = 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<string>();
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void>((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();
|
||||
});
|
||||
});
|
||||
@@ -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<typeof import("../cm/languages")>();
|
||||
return { ...actual, loadSupport: vi.fn(actual.loadSupport) };
|
||||
});
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((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<typeof languages>();
|
||||
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<typeof info>();
|
||||
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<LanguageSupport> | 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();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -9,7 +9,7 @@ const loading = vi.hoisted(() => {
|
||||
const ready = new Promise<void>((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<typeof actual.loadSupport>) => {
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(<Balloons />);
|
||||
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);
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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<TerminalLink, { kind: "file" }> => {
|
||||
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" });
|
||||
});
|
||||
});
|
||||
@@ -137,25 +137,32 @@ export const LINK_HANDLER: ILinkHandler = {
|
||||
export async function openFileLink(link: Extract<TerminalLink, { kind: "file" }>, 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 });
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<NotificationService["notify"]>[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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
|
||||
@@ -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<string>();
|
||||
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) },
|
||||
],
|
||||
});
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<typeof import("@/platform/actions")>()),
|
||||
executeAction: mocks.action,
|
||||
}));
|
||||
vi.mock("@/platform/ipc", async (original) => {
|
||||
const actual = await original<typeof import("@/platform/ipc")>();
|
||||
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<HTMLElement>(".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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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<BlameLine[]> | undefined;
|
||||
|
||||
clear() {
|
||||
this.doc = undefined;
|
||||
this.pending = undefined;
|
||||
}
|
||||
|
||||
get(doc: Text, load: () => Promise<BlameLine[]>): Promise<BlameLine[]> {
|
||||
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<DecorationSet>();
|
||||
const inlineBlameField = StateField.define<DecorationSet>({
|
||||
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<typeof setTimeout> | undefined;
|
||||
private readonly cache = new BlameCache();
|
||||
private readonly messages = new Map<string, Promise<string>>();
|
||||
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<string> {
|
||||
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",
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -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"]]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, Promise<FileProbe>>();
|
||||
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;
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "刚刚"
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
|
||||
+5
-1
@@ -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",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g stroke-linejoin="round"><path d="M14 4C14 2.89543 13.1046 2 12 2H4C2.89543 2 2 2.89543 2 4V12C2 13.1046 2.89543 14 4 14H12C13.1046 14 14 13.1046 14 12V4Z" fill="#C94F6D"/><path d="M4.5 11.5L8 4L11.5 11.5" stroke="#2B2D30" stroke-linecap="round"/><path d="M5.9 8.5H10.1" stroke="#2B2D30"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 399 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g stroke-linejoin="round"><path d="M14 4C14 2.89543 13.1046 2 12 2H4C2.89543 2 2 2.89543 2 4V12C2 13.1046 2.89543 14 4 14H12C13.1046 14 14 13.1046 14 12V4Z" fill="#E0708A"/><path d="M4.5 11.5L8 4L11.5 11.5" stroke="#2B2D30" stroke-linecap="round"/><path d="M5.9 8.5H10.1" stroke="#2B2D30"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 399 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g stroke-linejoin="round"><path d="M14 4C14 2.89543 13.1046 2 12 2H4C2.89543 2 2 2.89543 2 4V12C2 13.1046 2.89543 14 4 14H12C13.1046 14 14 13.1046 14 12V4Z" fill="#BA4032"/><path transform="translate(4 3.5)" d="M0.5 0.5V8.5" stroke="#2B2D30"/><path transform="translate(4 3.5)" d="M0.5 0.5H2.7C5.8 0.5 7.4 2.2 7.4 4.5C7.4 6.8 5.8 8.5 2.7 8.5H0.5" stroke="#2B2D30"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 474 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g stroke-linejoin="round"><path d="M14 4C14 2.89543 13.1046 2 12 2H4C2.89543 2 2 2.89543 2 4V12C2 13.1046 2.89543 14 4 14H12C13.1046 14 14 13.1046 14 12V4Z" fill="#CE5B4C"/><path transform="translate(4 3.5)" d="M0.5 0.5V8.5" stroke="#2B2D30"/><path transform="translate(4 3.5)" d="M0.5 0.5H2.7C5.8 0.5 7.4 2.2 7.4 4.5C7.4 6.8 5.8 8.5 2.7 8.5H0.5" stroke="#2B2D30"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 474 B |
Reference in New Issue
Block a user