diff --git a/crates/ide-core/src/editor_settings.rs b/crates/ide-core/src/editor_settings.rs index b6f879a4..8bf44084 100644 --- a/crates/ide-core/src/editor_settings.rs +++ b/crates/ide-core/src/editor_settings.rs @@ -39,7 +39,7 @@ impl Default for EditorGeneralSettings { caret_scroll: "minimize".into(), soft_wrap_files: "*.md; *.txt; *.rst; *.adoc".into(), soft_wrap_indent: true, - scroll_past_end: true, + scroll_past_end: false, strip_trailing_scope: "modified".into(), keep_trailing_on_caret_line: true, remove_trailing_blank_lines: false, @@ -227,7 +227,7 @@ impl Default for SmartKeysSettings { pair_quotes: true, surround_selection: true, tab_out_of_pair: true, - reformat_on_paste: "indentBlock".into(), + reformat_on_paste: "indentEachLine".into(), smart_indent: true, backspace_unindent: "autoindent".into(), } diff --git a/crates/ide-core/src/workflow_settings.rs b/crates/ide-core/src/workflow_settings.rs index c574b84f..03053995 100644 --- a/crates/ide-core/src/workflow_settings.rs +++ b/crates/ide-core/src/workflow_settings.rs @@ -212,6 +212,8 @@ impl Default for PythonDebuggerSettings { #[serde(default, rename_all = "camelCase")] pub struct RunSettings { pub confirm_rerun: bool, + /// Ask before closing a Run tab whose process is still running. + pub confirm_terminate: bool, pub show_on_start: bool, /// Grace period between interrupt and kill when stopping a process. pub termination_timeout_ms: u32, @@ -219,7 +221,7 @@ pub struct RunSettings { impl Default for RunSettings { fn default() -> Self { - Self { confirm_rerun: true, show_on_start: true, termination_timeout_ms: 3000 } + Self { confirm_rerun: true, confirm_terminate: true, show_on_start: true, termination_timeout_ms: 3000 } } } diff --git a/crates/ide-language/src/lib.rs b/crates/ide-language/src/lib.rs index 1320e490..8807d99a 100644 --- a/crates/ide-language/src/lib.rs +++ b/crates/ide-language/src/lib.rs @@ -13,8 +13,9 @@ mod registry; mod syntax; pub use registry::LanguageRegistry; pub use syntax::{ - CSS_CLASS, CSS_ID, CSS_VARIABLE, FileReference, MAX_CACHED_TREES, MAX_PARSE_BYTES, ParsedTree, Syntax, SyntaxCache, - SyntaxCacheStats, SyntaxHighlightKind, SyntaxHighlights, TreeError, WebReference, WebReferenceKind, + CSS_CLASS, CSS_ID, CSS_VARIABLE, FileReference, Literal, LiteralKind, MAX_CACHED_TREES, MAX_PARSE_BYTES, + ParsedTree, Syntax, SyntaxCache, SyntaxCacheStats, SyntaxHighlightKind, SyntaxHighlights, TreeError, WebReference, + WebReferenceKind, }; /// Parsed form of `languages/.toml`. Backend-only; the UI gets [`LanguageInfo`]. diff --git a/crates/ide-language/src/syntax/literals.rs b/crates/ide-language/src/syntax/literals.rs new file mode 100644 index 00000000..78031fdd --- /dev/null +++ b/crates/ide-language/src/syntax/literals.rs @@ -0,0 +1,59 @@ +//! Comments and string literals of a text, for Find in Files' search context +//! filter ("In comments", "Except string literals", …). + +use super::folding::is_comment; +use tree_sitter::{Node, Tree}; + +/// What a [`Literal`] range is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LiteralKind { + Comment, + String, +} + +/// A comment or string literal: byte range of the outermost node. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Literal { + pub start: usize, + pub end: usize, + pub kind: LiteralKind, +} + +fn kind_of(node: &Node<'_>) -> Option { + if !node.is_named() { + return None; + } + let kind = node.kind(); + if is_comment(kind) { + Some(LiteralKind::Comment) + } else if kind.contains("string") || matches!(kind, "char_literal" | "character_literal" | "rune_literal") { + Some(LiteralKind::String) + } else { + None + } +} + +/// Comments and string literals in document order; nested ones (a string in a +/// comment, interpolations) are covered by the outermost. +pub(crate) fn literals(tree: &Tree) -> Vec { + let mut out = Vec::new(); + let mut cursor = tree.walk(); + loop { + let node = cursor.node(); + let kind = kind_of(&node); + if let Some(kind) = kind { + out.push(Literal { start: node.start_byte(), end: node.end_byte(), kind }); + } + if kind.is_none() && cursor.goto_first_child() { + continue; + } + loop { + if cursor.goto_next_sibling() { + break; + } + if !cursor.goto_parent() { + return out; + } + } + } +} diff --git a/crates/ide-language/src/syntax/mod.rs b/crates/ide-language/src/syntax/mod.rs index bd24fc77..879cbb64 100644 --- a/crates/ide-language/src/syntax/mod.rs +++ b/crates/ide-language/src/syntax/mod.rs @@ -5,6 +5,7 @@ mod folding; mod grammars; mod highlight; pub(crate) mod lines; +mod literals; mod outline; mod selection; mod web; @@ -14,6 +15,7 @@ pub use cache::{MAX_CACHED_TREES, SyntaxCache, SyntaxCacheStats}; use grammars::{SPECS, Spec}; pub use highlight::{SyntaxHighlightKind, SyntaxHighlights}; use ide_core::{LanguageId, OutlineItem, Pos, Range}; +pub use literals::{Literal, LiteralKind}; use std::cell::RefCell; use std::collections::HashMap; use std::sync::OnceLock; @@ -227,6 +229,12 @@ impl Syntax { }) } + /// Comments and string literals of `text` (byte ranges, document order); + /// `None` without a grammar for `lang` or for texts too large to parse. + pub fn literals(&self, lang: &LanguageId, text: &str) -> Option> { + self.analyze(lang, text, |_, tree, _| Some(literals::literals(tree))) + } + /// Syntax-aware selection ranges for IDEA "Extend Selection" (⌥↑), innermost first. pub fn selection_ranges(&self, lang: &LanguageId, text: &str, offset_utf16: u32) -> Vec { match self.analyze(lang, text, |_, tree, text| Some(selection::selection_ranges(tree, text, offset_utf16))) { diff --git a/crates/ide-language/src/syntax/tests.rs b/crates/ide-language/src/syntax/tests.rs index f61c8590..293fe341 100644 --- a/crates/ide-language/src/syntax/tests.rs +++ b/crates/ide-language/src/syntax/tests.rs @@ -1170,3 +1170,20 @@ fn web_file_references_in_attributes_and_stylesheets() { let first = &syntax().web_file_references(&lang("html"), html)[0]; assert_eq!((first.range.start.col, first.range.end.col), (12, 21)); } + +#[test] +fn literals_are_outermost_comments_and_strings() { + let src = "// note \"q\"\nfn main() { let s = \"a // b\"; /* c */ let c = 'x'; }\n"; + let found: Vec<(&str, LiteralKind)> = + syntax().literals(&lang("rust"), src).unwrap().iter().map(|l| (&src[l.start..l.end], l.kind)).collect(); + assert_eq!( + found, + vec![ + ("// note \"q\"", LiteralKind::Comment), + ("\"a // b\"", LiteralKind::String), + ("/* c */", LiteralKind::Comment), + ("'x'", LiteralKind::String), + ] + ); + assert!(syntax().literals(&lang("plaintext"), "// x").is_none()); +} diff --git a/crates/ide-lsp/src/convert.rs b/crates/ide-lsp/src/convert.rs index 8f1fc841..34646c6a 100644 --- a/crates/ide-lsp/src/convert.rs +++ b/crates/ide-lsp/src/convert.rs @@ -733,19 +733,34 @@ pub fn signature_help(conv_enc: Encoding, h: lsp::SignatureHelp) -> Option InlayHint { - let label = match h.label { - lsp::InlayHintLabel::String(s) => s, - lsp::InlayHintLabel::LabelParts(parts) => parts.into_iter().map(|p| p.value).collect(), + let tooltip_text = |t: lsp::InlayHintTooltip| match t { + lsp::InlayHintTooltip::String(s) => s, + lsp::InlayHintTooltip::MarkupContent(m) => m.value, + }; + let (label, parts) = match h.label { + lsp::InlayHintLabel::String(s) => (s, Vec::new()), + lsp::InlayHintLabel::LabelParts(parts) => { + let label = parts.iter().map(|p| p.value.as_str()).collect(); + let parts = parts + .into_iter() + .map(|p| InlayLabelPart { + value: p.value, + tooltip: p.tooltip.map(|t| match t { + lsp::InlayHintLabelPartTooltip::String(s) => s, + lsp::InlayHintLabelPartTooltip::MarkupContent(m) => m.value, + }), + location: p.location.as_ref().and_then(|l| conv.location(l)), + }) + .collect(); + (label, parts) + } }; let kind = match h.kind { Some(lsp::InlayHintKind::TYPE) => InlayKind::Type, Some(lsp::InlayHintKind::PARAMETER) => InlayKind::Parameter, _ => InlayKind::Other, }; - let tooltip = h.tooltip.map(|t| match t { - lsp::InlayHintTooltip::String(s) => s, - lsp::InlayHintTooltip::MarkupContent(m) => m.value, - }); + let tooltip = h.tooltip.map(tooltip_text); InlayHint { position: conv.pos(path, h.position), label, @@ -753,6 +768,7 @@ pub fn inlay_hint(conv: &Conv, path: &Path, h: lsp::InlayHint) -> InlayHint { padding_left: h.padding_left.unwrap_or(false), padding_right: h.padding_right.unwrap_or(false), tooltip, + parts, } } @@ -1132,6 +1148,32 @@ mod tests { assert_eq!(h.label, ": Vec"); assert_eq!(h.kind, InlayKind::Type); assert!(h.padding_left && !h.padding_right); + assert_eq!(h.parts.len(), 2); + assert!(h.parts.iter().all(|p| p.location.is_none())); + } + + #[cfg(unix)] + #[test] + fn inlay_label_parts_keep_locations() { + let conv = Conv::utf16(); + let h: lsp::InlayHint = serde_json::from_value(json!({ + "position": {"line": 0, "character": 3}, + "label": [ + {"value": ": "}, + {"value": "Foo", "tooltip": "struct Foo", + "location": {"uri": "file:///p/foo.rs", "range": {"start": {"line": 4, "character": 7}, "end": {"line": 4, "character": 10}}}} + ] + })) + .unwrap(); + let h = inlay_hint(&conv, Path::new("/a"), h); + assert_eq!(h.label, ": Foo"); + let part = &h.parts[1]; + assert_eq!(part.value, "Foo"); + assert_eq!(part.tooltip.as_deref(), Some("struct Foo")); + let loc = part.location.as_ref().unwrap(); + assert_eq!(loc.path, PathBuf::from("/p/foo.rs")); + assert_eq!(loc.range.start, p(4, 7)); + assert_eq!(h.kind, InlayKind::Other); } #[cfg(unix)] diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 14f0ed79..5e7994ea 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -1478,8 +1478,9 @@ impl LspManager { let t = &targets[0]; let items: Option> = self.call(t, "textDocument/prepareCallHierarchy", snap.pos_params(t, pos), None, DEFAULT_TIMEOUT).await?; + let items = items.filter(|i| !i.is_empty()).ok_or_else(no_hierarchy_target)?; let mut out = Vec::new(); - for item in items.unwrap_or_default() { + for item in items { let params = json!({ "item": item }); match direction { HierarchyDirection::Incoming => { @@ -1520,12 +1521,13 @@ impl LspManager { // and lsp-types mistypes `tags` (jdtls sends an array). let items: Option> = self.call(t, "textDocument/prepareTypeHierarchy", snap.pos_params(t, pos), None, DEFAULT_TIMEOUT).await?; + let items = items.filter(|i| !i.is_empty()).ok_or_else(no_hierarchy_target)?; let method = match direction { HierarchyDirection::Incoming => "typeHierarchy/supertypes", HierarchyDirection::Outgoing => "typeHierarchy/subtypes", }; let mut out = Vec::new(); - for item in items.unwrap_or_default() { + for item in items { let related: Option> = self.call(t, method, json!({ "item": item }), None, LONG_TIMEOUT).await?; let conv = self.conv(t.enc()); @@ -1781,6 +1783,12 @@ fn source_actions_only(only: &[String]) -> bool { !only.is_empty() && only.iter().all(|k| k == "source" || k.starts_with("source.")) } +/// Preparing a call / type hierarchy found no symbol at the position: the UI +/// then retries at the enclosing declaration. +fn no_hierarchy_target() -> IdeError { + IdeError::not_found("No symbol to show the hierarchy of at the caret") +} + #[cfg(test)] mod send_check { use super::*; diff --git a/crates/ide-lsp/src/types.rs b/crates/ide-lsp/src/types.rs index 5fe534d4..140a8282 100644 --- a/crates/ide-lsp/src/types.rs +++ b/crates/ide-lsp/src/types.rs @@ -195,6 +195,19 @@ pub struct InlayHint { pub padding_left: bool, pub padding_right: bool, pub tooltip: Option, + /// The label's parts when the server split it (empty for a plain label); + /// their values concatenate to `label`. + pub parts: Vec, +} + +/// One part of an inlay hint label, e.g. a type name that navigates to its declaration. +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct InlayLabelPart { + pub value: String, + pub tooltip: Option, + /// Declaration the part stands for (⌘-click navigates there). + pub location: Option, } /// Decoded semantic tokens (absolute positions) with the server legend applied. diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 8853ce80..562466f2 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -806,6 +806,11 @@ async fn navigation_rename_format_and_decorations() { assert_eq!(hints[0].label, "x:"); assert_eq!(hints[0].kind, InlayKind::Parameter); + // Nothing to prepare at the position: NotFound, so the UI can retry at the enclosing declaration. + s.respond("textDocument/prepareCallHierarchy", json!(null)); + let err = h.mgr.call_hierarchy(doc, Pos::new(0, 1), HierarchyDirection::Incoming).await.unwrap_err(); + assert_eq!(err.kind, ide_core::error::ErrorKind::NotFound); + let item = json!({ "name": "foo", "kind": 12, "uri": uri, "range": r(0,0,0,11), "selectionRange": r(0,3,0,6), "data": "id" }); s.respond("textDocument/prepareCallHierarchy", json!([item])); s.respond("callHierarchy/incomingCalls", json!([{ "from": { "name": "main", "kind": 12, "uri": uri, "range": r(1,0,1,6), "selectionRange": r(1,0,1,3) }, "fromRanges": [r(1,0,1,3)] }])); diff --git a/crates/ide-search/benches/search.rs b/crates/ide-search/benches/search.rs index 3a8d373c..7f24e8c1 100644 --- a/crates/ide-search/benches/search.rs +++ b/crates/ide-search/benches/search.rs @@ -35,6 +35,7 @@ fn query(pattern: &str, is_regex: bool) -> SearchQuery { exclude: vec![], scope: SearchScope::Project, max_matches: 0, + context: Default::default(), } } diff --git a/crates/ide-search/src/engine.rs b/crates/ide-search/src/engine.rs index 11fef045..eeca9988 100644 --- a/crates/ide-search/src/engine.rs +++ b/crates/ide-search/src/engine.rs @@ -4,7 +4,10 @@ use crate::iter::for_each_match; use crate::line::line_match; use crate::mask::FileMask; use crate::query::{Compiled, line_terminator}; -use crate::{FileMatches, LineMatch, Overlay, PathFilter, SearchScope, SearchSummary}; +use crate::{ + FileMatches, LineMatch, LiteralKind, LiteralRange, Literals, Overlay, PathFilter, SearchContext, SearchScope, + SearchSummary, +}; use grep_regex::RegexMatcher; use grep_searcher::{BinaryDetection, Searcher, SearcherBuilder, Sink, SinkMatch}; use ide_core::{CancellationToken, IdeError, IdeResult}; @@ -25,6 +28,8 @@ pub(crate) struct Request<'a> { pub compiled: Compiled, pub mask: FileMask, pub filter: Option>, + pub context: SearchContext, + pub literals: Option>, pub max_matches: u32, pub overlay: Overlay<'a>, pub cancel: &'a CancellationToken, @@ -86,6 +91,8 @@ struct Ctx<'a> { root: &'a Path, mask: &'a FileMask, filter: Option>, + context: SearchContext, + literals: Option>, overlay: Overlay<'a>, on_file: &'a (dyn Fn(FileMatches) + Sync), ctl: Control<'a>, @@ -98,6 +105,8 @@ pub(crate) fn run(req: Request<'_>) -> IdeResult { root: req.root, mask: &req.mask, filter: req.filter, + context: req.context, + literals: if req.context == SearchContext::Any { None } else { req.literals }, overlay: req.overlay, on_file: req.on_file, ctl: Control { @@ -225,6 +234,25 @@ impl Ctx<'_> { if !self.mask.matches(&rel) || self.filter.is_some_and(|keep| !keep(&rel)) { return; } + // The search context needs the whole text to find comments and string literals. + let in_context = match self.literals { + Some(literals) => { + let text = match (self.overlay)(path) { + Some(text) => text, + // Unreadable or not UTF-8 (binary): nothing to classify. + None => match std::fs::read_to_string(path) { + Ok(text) => text, + Err(_) => return, + }, + }; + let ranges = literals(path, &text); + if ranges.is_none() && self.context.needs_literals() { + return; + } + Some((text, ranges.unwrap_or_default())) + } + None => None, + }; let mut sink = FileSink { matcher, ctl: &self.ctl, @@ -234,12 +262,16 @@ impl Ctx<'_> { ticks: 0, found: Vec::new(), ranges: Vec::new(), + context: in_context.as_ref().map(|(_, ranges)| (self.context, ranges.as_slice())), }; - let result = match (self.overlay)(path) { - Some(text) => searcher.search_slice(matcher, text.as_bytes(), &mut sink), - None => match std::fs::File::open(path) { - Ok(file) => searcher.search_reader(matcher, StopReader { inner: file, ctl: &self.ctl }, &mut sink), - Err(err) => Err(err), + let result = match &in_context { + Some((text, _)) => searcher.search_slice(matcher, text.as_bytes(), &mut sink), + None => match (self.overlay)(path) { + Some(text) => searcher.search_slice(matcher, text.as_bytes(), &mut sink), + None => match std::fs::File::open(path) { + Ok(file) => searcher.search_reader(matcher, StopReader { inner: file, ctl: &self.ctl }, &mut sink), + Err(err) => Err(err), + }, }, }; if let Err(err) = result { @@ -288,6 +320,14 @@ struct FileSink<'a> { /// Scratch buffers reused across lines. found: Vec<(usize, usize)>, ranges: Vec<(usize, usize)>, + /// Search context filter and the file's comments / string literals. + context: Option<(SearchContext, &'a [LiteralRange])>, +} + +/// Kind of the comment or string literal containing byte `pos` (`literals` sorted). +fn literal_at(literals: &[LiteralRange], pos: usize) -> Option { + let i = literals.partition_point(|l| l.start <= pos); + literals[..i].last().filter(|l| pos < l.end).map(|l| l.kind) } impl Sink for FileSink<'_> { @@ -311,6 +351,10 @@ impl Sink for FileSink<'_> { for_each_match(self.matcher, &buffer[..range.end], range.start, |mm| { found.push((mm.start() - range.start, mm.end() - range.start)); }); + if let Some((context, literals)) = self.context { + let block_start = usize::try_from(m.absolute_byte_offset()).unwrap_or(usize::MAX); + found.retain(|&(a, _)| context.accepts(literal_at(literals, block_start.saturating_add(a)))); + } let mut next = found.iter().copied().peekable(); let mut line_start = 0; diff --git a/crates/ide-search/src/lib.rs b/crates/ide-search/src/lib.rs index ba0cfe75..0e8b8fcf 100644 --- a/crates/ide-search/src/lib.rs +++ b/crates/ide-search/src/lib.rs @@ -41,8 +41,64 @@ pub struct SearchQuery { pub scope: SearchScope, /// Stop after this many matches overall (0 = 10 000). pub max_matches: u32, + /// Where in the code a match may lie (IDEA's search context filter). + #[serde(default)] + #[specta(optional)] + pub context: SearchContext, } +/// IDEA's search context: matches anywhere, only in, or outside comments and +/// string literals. Files whose language has no syntax tree contain neither. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum SearchContext { + #[default] + Any, + InComments, + InStringLiterals, + ExceptComments, + ExceptStringLiterals, + ExceptCommentsAndStringLiterals, +} + +/// What a [`LiteralRange`] is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LiteralKind { + Comment, + String, +} + +/// Byte range of a comment or string literal in a file's text. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LiteralRange { + pub start: usize, + pub end: usize, + pub kind: LiteralKind, +} + +impl SearchContext { + /// Whether a match starting in `kind` (`None`: plain code) is kept. + pub fn accepts(self, kind: Option) -> bool { + match self { + Self::Any => true, + Self::InComments => kind == Some(LiteralKind::Comment), + Self::InStringLiterals => kind == Some(LiteralKind::String), + Self::ExceptComments => kind != Some(LiteralKind::Comment), + Self::ExceptStringLiterals => kind != Some(LiteralKind::String), + Self::ExceptCommentsAndStringLiterals => kind.is_none(), + } + } + + /// Only matches inside comments / string literals: files without any are skipped. + fn needs_literals(self) -> bool { + matches!(self, Self::InComments | Self::InStringLiterals) + } +} + +/// Comments and string literals of a file's text (sorted, non-overlapping), +/// or `None` when its language has no syntax tree. +pub type Literals<'a> = &'a (dyn Fn(&Path, &str) -> Option> + Sync); + #[derive(Clone, Debug, Serialize, Deserialize, Type)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum SearchScope { @@ -131,6 +187,20 @@ pub fn search_filtered( overlay: Overlay<'_>, cancel: &CancellationToken, on_file: &(dyn Fn(FileMatches) + Sync), +) -> IdeResult { + search_in_context(root, query, filter, None, overlay, cancel, on_file) +} + +/// [`search_filtered`] honouring [`SearchQuery::context`] with the comments and +/// string literals `literals` finds; without it every match is plain code. +pub fn search_in_context( + root: &Path, + query: &SearchQuery, + filter: Option>, + literals: Option>, + overlay: Overlay<'_>, + cancel: &CancellationToken, + on_file: &(dyn Fn(FileMatches) + Sync), ) -> IdeResult { let mask = FileMask::new(&query.include, &query.exclude)?; if query.pattern.is_empty() { @@ -142,6 +212,8 @@ pub fn search_filtered( compiled: query::compile(query)?, mask, filter, + context: query.context, + literals, max_matches: query.max_matches, overlay, cancel, diff --git a/crates/ide-search/src/query.rs b/crates/ide-search/src/query.rs index e13cb4c4..cedc370d 100644 --- a/crates/ide-search/src/query.rs +++ b/crates/ide-search/src/query.rs @@ -97,6 +97,7 @@ mod tests { exclude: vec![], scope: SearchScope::Project, max_matches: 0, + context: Default::default(), } } diff --git a/crates/ide-search/tests/replace.rs b/crates/ide-search/tests/replace.rs index ecd0ea69..2fb7945b 100644 --- a/crates/ide-search/tests/replace.rs +++ b/crates/ide-search/tests/replace.rs @@ -17,6 +17,7 @@ fn query(pattern: &str) -> SearchQuery { exclude: vec![], scope: SearchScope::Project, max_matches: 0, + context: Default::default(), } } diff --git a/crates/ide-search/tests/search.rs b/crates/ide-search/tests/search.rs index a088748a..c37cab14 100644 --- a/crates/ide-search/tests/search.rs +++ b/crates/ide-search/tests/search.rs @@ -27,6 +27,7 @@ fn query(pattern: &str) -> SearchQuery { exclude: vec![], scope: SearchScope::Project, max_matches: 0, + context: Default::default(), } } @@ -548,3 +549,75 @@ fn file_set_scope_applies_the_path_filter() { let (all, _) = run(p.path(), &q); assert_eq!(paths(&all), vec!["README.md", "src/main/java/App.java", "src/test/java/AppTest.java"]); } + +/// Test classifier: `//` to end of line is a comment, `"…"` a string (only in `.rs` files). +fn toy_literals(path: &Path, text: &str) -> Option> { + use ide_search::{LiteralKind, LiteralRange}; + if path.extension().is_none_or(|e| e != "rs") { + return None; + } + let mut out = Vec::new(); + let bytes = text.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i..].starts_with(b"//") { + let end = memchr_newline(bytes, i); + out.push(LiteralRange { start: i, end, kind: LiteralKind::Comment }); + i = end; + } else if bytes[i] == b'"' { + let end = bytes[i + 1..].iter().position(|&b| b == b'"').map_or(bytes.len(), |p| i + p + 2); + out.push(LiteralRange { start: i, end, kind: LiteralKind::String }); + i = end; + } else { + i += 1; + } + } + Some(out) +} + +fn memchr_newline(bytes: &[u8], from: usize) -> usize { + bytes[from..].iter().position(|&b| b == b'\n').map_or(bytes.len(), |p| from + p) +} + +/// `(rel_path, line, ranges)` of every matched line. +type Row = (String, u32, Vec<(u32, u32)>); + +fn run_context(root: &Path, context: ide_search::SearchContext) -> Vec { + let out = Mutex::new(Vec::new()); + let q = SearchQuery { context, ..query("foo") }; + ide_search::search_in_context(root, &q, None, Some(&toy_literals), &|_| None, &CancellationToken::new(), &|f| { + out.lock().unwrap().push(f) + }) + .unwrap(); + let mut rows: Vec<_> = out + .into_inner() + .unwrap() + .into_iter() + .flat_map(|f| f.matches.into_iter().map(move |m| (f.rel_path.clone(), m.line, m.ranges))) + .collect(); + rows.sort(); + rows +} + +#[test] +fn search_context_filters_comments_and_strings() { + use ide_search::SearchContext as C; + let p = project(&[("a.rs", b"foo(\"foo\"); // foo\nfoo\n"), ("b.txt", b"foo // foo\n")]); + let rows = |c| run_context(p.path(), c); + let s = |r: &str, l: u32, ranges: &[(u32, u32)]| (r.to_owned(), l, ranges.to_vec()); + assert_eq!( + rows(C::Any), + vec![s("a.rs", 0, &[(0, 3), (5, 8), (15, 18)]), s("a.rs", 1, &[(0, 3)]), s("b.txt", 0, &[(0, 3), (7, 10)])] + ); + assert_eq!(rows(C::InComments), vec![s("a.rs", 0, &[(15, 18)])]); + assert_eq!(rows(C::InStringLiterals), vec![s("a.rs", 0, &[(5, 8)])]); + // Files without a syntax tree have no comments or strings. + assert_eq!( + rows(C::ExceptComments), + vec![s("a.rs", 0, &[(0, 3), (5, 8)]), s("a.rs", 1, &[(0, 3)]), s("b.txt", 0, &[(0, 3), (7, 10)])] + ); + assert_eq!( + rows(C::ExceptCommentsAndStringLiterals), + vec![s("a.rs", 0, &[(0, 3)]), s("a.rs", 1, &[(0, 3)]), s("b.txt", 0, &[(0, 3), (7, 10)])] + ); +} diff --git a/crates/ide-vcs/src/history.rs b/crates/ide-vcs/src/history.rs index 9c4965df..6c007448 100644 --- a/crates/ide-vcs/src/history.rs +++ b/crates/ide-vcs/src/history.rs @@ -53,7 +53,8 @@ impl TextFilter { pub(crate) fn log(r: &Repo, q: &LogQuery) -> IdeResult> { let repo = r.gix()?; let range = q.range.as_deref().map(str::trim).filter(|s| !s.is_empty()); - let tips = if range.is_some() { Vec::new() } else { tips(&repo, q.branch.as_deref())? }; + let revs: Vec<&str> = q.branch.iter().chain(&q.branches).map(|b| b.trim()).filter(|b| !b.is_empty()).collect(); + let tips = if range.is_some() { Vec::new() } else { tips(&repo, &revs)? }; if tips.is_empty() && range.is_none() { return Ok(Vec::new()); } @@ -65,7 +66,8 @@ pub(crate) fn log(r: &Repo, q: &LogQuery) -> IdeResult> { } None => None, }; - let author = q.author.as_deref().map(str::to_lowercase).filter(|s| !s.is_empty()); + let authors: Vec = + q.author.iter().chain(&q.authors).map(|a| a.trim().to_lowercase()).filter(|a| !a.is_empty()).collect(); let text = q.text.as_deref().map(|t| TextFilter::new(t, q.text_regex, q.text_match_case)).transpose()?.flatten(); let limit = if q.limit == 0 { usize::MAX } else { q.limit as usize }; let mut to_skip = q.skip as usize; @@ -105,11 +107,11 @@ pub(crate) fn log(r: &Repo, q: &LogQuery) -> IdeResult> { let sig = commit.author().map_err(gx)?; let name = to_string(sig.name.trim().as_bstr()); let email = to_string(sig.email.trim().as_bstr()); - if let Some(needle) = author.as_deref() - && !name.to_lowercase().contains(needle) - && !email.to_lowercase().contains(needle) - { - continue; + if !authors.is_empty() { + let (name, email) = (name.to_lowercase(), email.to_lowercase()); + if !authors.iter().any(|a| name.contains(a.as_str()) || email.contains(a.as_str())) { + continue; + } } let message = to_string(commit.message_raw_sloppy()); if let Some(filter) = text.as_ref() @@ -163,14 +165,19 @@ fn range_ids(r: &Repo, range: &str, path: Option<&str>) -> IdeResult) -> IdeResult> { - if let Some(rev) = branch.filter(|b| !b.trim().is_empty()) { - let id = resolve(repo, rev)?; - let commit = repo.find_object(id).map_err(gx)?.peel_to_commit().map_err(gx)?; - return Ok(vec![commit.id]); - } +/// Starting points of the walk: the given revisions, or HEAD + all branches. +fn tips(repo: &gix::Repository, revs: &[&str]) -> IdeResult> { let mut tips = Vec::new(); + if !revs.is_empty() { + for rev in revs { + let id = resolve(repo, rev)?; + let commit = repo.find_object(id).map_err(gx)?.peel_to_commit().map_err(gx)?; + if !tips.contains(&commit.id) { + tips.push(commit.id); + } + } + return Ok(tips); + } if let Ok(id) = repo.head_id() { tips.push(id.detach()); } diff --git a/crates/ide-vcs/src/lib.rs b/crates/ide-vcs/src/lib.rs index 8cb4d85c..6194db2a 100644 --- a/crates/ide-vcs/src/lib.rs +++ b/crates/ide-vcs/src/lib.rs @@ -109,6 +109,12 @@ pub struct LogQuery { /// `text` matches case-sensitively (Log filter `Cc`). #[serde(default)] pub text_match_case: bool, + /// More starting revisions next to `branch` (Log Branch filter with several branches). + #[serde(default)] + pub branches: Vec, + /// More author needles next to `author`; a commit matches any of them (Log User filter). + #[serde(default)] + pub authors: Vec, } #[derive(Clone, Debug, Serialize, Deserialize, Type)] @@ -359,6 +365,10 @@ pub struct MergeVersions { pub ours: Option, pub theirs: Option, pub sides: MergeSides, + /// Three-way merge of the versions above: non-conflicting changes merged, each + /// conflict left as a diff3 block (yours / base / theirs). `None` when a side is + /// missing (deleted on one side). + pub merged: Option, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] @@ -599,11 +609,18 @@ impl Repo { pub fn rebase(&self, onto: &str) -> IdeResult<()> { write::rebase(self, onto) } - pub fn stash_push(&self, message: Option<&str>, include_untracked: bool) -> IdeResult<()> { - write::stash_push(self, message, include_untracked) + /// `keep_index`: changes already staged stay in the index (and the working tree). + pub fn stash_push(&self, message: Option<&str>, include_untracked: bool, keep_index: bool) -> IdeResult<()> { + write::stash_push(self, message, include_untracked, keep_index) } - pub fn stash_apply(&self, index: u32, pop: bool) -> IdeResult<()> { - write::stash_apply(self, index, pop) + /// `reinstate_index`: also restore what was staged when the stash was made. + pub fn stash_apply(&self, index: u32, pop: bool, reinstate_index: bool) -> IdeResult<()> { + write::stash_apply(self, index, pop, reinstate_index) + } + /// Creates and checks out `branch` at the commit the stash was made on, applies the + /// stash there (index included) and drops it. + pub fn stash_branch(&self, index: u32, branch: &str) -> IdeResult<()> { + write::stash_branch(self, index, branch) } /// Removes `stash@{index}` without applying it. pub fn stash_drop(&self, index: u32) -> IdeResult<()> { @@ -786,7 +803,11 @@ impl Repo { let (base, ours, theirs) = self.conflict_versions(path)?; let sides = self.merge_sides()?; let (ours, theirs) = if sides.reversed { (theirs, ours) } else { (ours, theirs) }; - Ok(MergeVersions { base, ours, theirs, sides }) + let merged = match (&ours, &theirs) { + (Some(o), Some(t)) => Some(ops::merge_file(self, o, base.as_deref().unwrap_or(""), t)?), + _ => None, + }; + Ok(MergeVersions { base, ours, theirs, sides, merged }) } /// For conflict resolution: (base, ours, theirs) versions of a conflicted file. pub fn conflict_versions(&self, path: &Path) -> IdeResult<(Option, Option, Option)> { diff --git a/crates/ide-vcs/src/ops.rs b/crates/ide-vcs/src/ops.rs index 892a2ef6..24f25b0b 100644 --- a/crates/ide-vcs/src/ops.rs +++ b/crates/ide-vcs/src/ops.rs @@ -56,11 +56,43 @@ pub(crate) fn merge_sides(r: &Repo) -> IdeResult { } Some("cherry-pick") => sides.commit = read("CHERRY_PICK_HEAD").map(short), Some("revert") => sides.commit = read("REVERT_HEAD").map(short), + // Conflicts outside an operation come from restoring stashed changes (Smart + // Checkout, update with autostash): stage 2 is the new HEAD, stage 3 the user's. + None if !crate::write::unmerged_paths(r)?.is_empty() => { + sides.operation = Some("unstash".into()); + sides.reversed = true; + } _ => {} } Ok(sides) } +/// Three-way merge of file contents (`git merge-file --diff3`): changes that don't +/// conflict are merged, each conflict is left as a diff3 block. +pub(crate) fn merge_file(r: &Repo, ours: &str, base: &str, theirs: &str) -> IdeResult { + struct TempDir(std::path::PathBuf); + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let dir = TempDir(r.git_dir.join(format!("openidea-merge-{}", std::process::id()))); + std::fs::create_dir_all(&dir.0)?; + let file = |name: &str, text: &str| -> IdeResult { + let path = dir.0.join(name); + std::fs::write(&path, text)?; + Ok(path.to_string_lossy().into_owned()) + }; + let (o, b, t) = (file("ours", ours)?, file("base", base)?, file("theirs", theirs)?); + let args = ["merge-file", "-p", "--diff3", "-L", "yours", "-L", "base", "-L", "theirs", &o, &b, &t]; + let out = cli::run_raw(r.root(), &args, None)?; + // The exit status is the number of conflicts; only a negative one is a failure. + match out.code { + Some(code) if code >= 0 => Ok(String::from_utf8_lossy(&out.stdout).into_owned()), + code => Err(cli::failure(&args, &out.stderr, &out.stdout, code)), + } +} + /// The single branch (local first, then remote) pointing at `commit`. fn branch_at(r: &Repo, commit: &str) -> Option { if commit.starts_with('-') { diff --git a/crates/ide-vcs/src/write.rs b/crates/ide-vcs/src/write.rs index 943808cb..d34e3eb5 100644 --- a/crates/ide-vcs/src/write.rs +++ b/crates/ide-vcs/src/write.rs @@ -598,20 +598,40 @@ pub(crate) fn rebase(r: &Repo, onto: &str) -> IdeResult<()> { run(r.root(), &["rebase", "--autostash", check_rev(onto)?], None).map(|_| ()) } -pub(crate) fn stash_push(r: &Repo, message: Option<&str>, include_untracked: bool) -> IdeResult<()> { +pub(crate) fn stash_push(r: &Repo, message: Option<&str>, include_untracked: bool, keep_index: bool) -> IdeResult<()> { let mut args = vec!["stash", "push", "-q"]; if include_untracked { args.push("--include-untracked"); } + if keep_index { + args.push("--keep-index"); + } if let Some(m) = message.filter(|m| !m.trim().is_empty()) { args.extend(["-m", m]); } run(r.root(), &args, None).map(|_| ()) } -pub(crate) fn stash_apply(r: &Repo, index: u32, pop: bool) -> IdeResult<()> { +pub(crate) fn stash_apply(r: &Repo, index: u32, pop: bool, reinstate_index: bool) -> IdeResult<()> { let spec = format!("stash@{{{index}}}"); - run(r.root(), &["stash", if pop { "pop" } else { "apply" }, "-q", &spec], None).map(|_| ()) + let mut args = vec!["stash", if pop { "pop" } else { "apply" }, "-q"]; + if reinstate_index { + args.push("--index"); + } + args.push(&spec); + run(r.root(), &args, None).map(|_| ()) +} + +pub(crate) fn stash_branch(r: &Repo, index: u32, branch: &str) -> IdeResult<()> { + let branch = branch.trim(); + let valid = !branch.is_empty() + && !branch.starts_with('-') + && cli::run_raw(r.root(), &["check-ref-format", "--branch", branch], None)?.code == Some(0); + if !valid { + return Err(IdeError::invalid(format!("'{branch}' is not a valid branch name"))); + } + let spec = format!("stash@{{{index}}}"); + run(r.root(), &["stash", "branch", branch, &spec], None).map(|_| ()) } pub(crate) fn stash_drop(r: &Repo, index: u32) -> IdeResult<()> { diff --git a/crates/ide-vcs/tests/history_ops.rs b/crates/ide-vcs/tests/history_ops.rs index f29c1d02..7116a98c 100644 --- a/crates/ide-vcs/tests/history_ops.rs +++ b/crates/ide-vcs/tests/history_ops.rs @@ -122,6 +122,8 @@ fn merge_versions_put_the_users_work_on_the_left() { assert_eq!(v.sides.branch.as_deref(), Some("topic")); assert_eq!(v.sides.other_branch.as_deref(), Some("main")); assert_eq!(v.sides.commit.as_deref(), Some(&topic[..8])); + // The merged text puts the user's side first, whatever git's stage order. + assert_eq!(v.merged.as_deref(), Some("<<<<<<< yours\ntopic\n||||||| base\nbase\n=======\nmain\n>>>>>>> theirs\n")); let (cancel, out) = quiet(); repo.abort_operation(&cancel, &out).unwrap(); diff --git a/crates/ide-vcs/tests/repo.rs b/crates/ide-vcs/tests/repo.rs index cc198bed..40f6a95b 100644 --- a/crates/ide-vcs/tests/repo.rs +++ b/crates/ide-vcs/tests/repo.rs @@ -615,11 +615,11 @@ fn stash_push_list_apply_pop() { let repo = fx.repo(); fx.write("s.txt", "change 1\n"); - repo.stash_push(Some("first stash"), false).unwrap(); + repo.stash_push(Some("first stash"), false, false).unwrap(); assert_eq!(fx.read("s.txt"), "base\n"); fx.write("s.txt", "change 2\n"); fx.write("u.txt", "untracked\n"); - repo.stash_push(None, true).unwrap(); + repo.stash_push(None, true, false).unwrap(); assert!(!fx.path("u.txt").exists()); let stashes = repo.stashes().unwrap(); @@ -629,11 +629,11 @@ fn stash_push_list_apply_pop() { assert!(stashes[1].message.contains("first stash"), "{stashes:?}"); assert!(stashes[0].time_ms > 0.0); - repo.stash_apply(1, false).unwrap(); + repo.stash_apply(1, false, false).unwrap(); assert_eq!(fx.read("s.txt"), "change 1\n"); assert_eq!(repo.stashes().unwrap().len(), 2); repo.discard(&[fx.path("s.txt")]).unwrap(); - repo.stash_apply(0, true).unwrap(); + repo.stash_apply(0, true, false).unwrap(); assert_eq!(fx.read("s.txt"), "change 2\n"); assert!(fx.path("u.txt").exists()); assert_eq!(repo.stashes().unwrap().len(), 1); @@ -642,6 +642,72 @@ fn stash_push_list_apply_pop() { assert!(repo.stash_drop(0).is_err()); } +#[test] +fn stash_keep_index_reinstate_index_and_branch() { + let fx = Fixture::new(); + fx.write("a.txt", "a\n"); + fx.write("b.txt", "b\n"); + fx.commit_all("base"); + let repo = fx.repo(); + let staged = |fx: &Fixture| fx.git(&["diff", "--cached", "--name-only"]).trim().to_string(); + + fx.write("a.txt", "a staged\n"); + fx.git(&["add", "a.txt"]); + fx.write("b.txt", "b unstaged\n"); + repo.stash_push(Some("keep"), false, true).unwrap(); + // Keep index: the staged change stays, the unstaged one is stashed. + assert_eq!(fx.read("a.txt"), "a staged\n"); + assert_eq!(fx.read("b.txt"), "b\n"); + assert_eq!(staged(&fx), "a.txt"); + + fx.git(&["reset", "-q", "--hard"]); + repo.stash_apply(0, false, true).unwrap(); + // Reinstate index: what was staged is staged again. + assert_eq!(staged(&fx), "a.txt"); + assert_eq!(fx.read("b.txt"), "b unstaged\n"); + fx.git(&["reset", "-q", "--hard"]); + repo.stash_apply(0, false, false).unwrap(); + assert_eq!(staged(&fx), ""); + + fx.git(&["reset", "-q", "--hard"]); + assert!(repo.stash_branch(0, "-bad").is_err()); + repo.stash_branch(0, "from-stash").unwrap(); + assert_eq!(fx.git(&["rev-parse", "--abbrev-ref", "HEAD"]).trim(), "from-stash"); + assert_eq!(fx.read("b.txt"), "b unstaged\n"); + assert!(repo.stashes().unwrap().is_empty()); +} + +#[test] +fn log_filters_several_branches_and_authors() { + let fx = Fixture::new(); + fx.write("a.txt", "1\n"); + let c1 = fx.commit_all("one"); + fx.git(&["checkout", "-q", "-b", "left"]); + fx.write("l.txt", "l\n"); + let left = fx.commit_all("left"); + fx.git(&["checkout", "-q", "-b", "right", &c1]); + fx.write("r.txt", "r\n"); + fx.git(&["add", "-A"]); + fx.git(&["-c", "user.name=Someone Else", "-c", "user.email=else@corp.test", "commit", "-q", "-m", "right"]); + let right = fx.git(&["rev-parse", "HEAD"]).trim().to_string(); + fx.git(&["checkout", "-q", "-b", "third", &c1]); + fx.write("t.txt", "t\n"); + let third = fx.commit_all("third"); + let repo = fx.repo(); + let ids = |q: LogQuery| repo.log(&q).unwrap().into_iter().map(|c| c.id).collect::>(); + + let two = ids(LogQuery { branch: Some("left".into()), branches: vec!["right".into()], ..Default::default() }); + assert!(two.contains(&left) && two.contains(&right) && two.contains(&c1)); + assert!(!two.contains(&third)); + let only = ids(LogQuery { branches: vec!["third".into()], ..Default::default() }); + assert_eq!(only, vec![third.clone(), c1.clone()]); + + let everyone = ids(LogQuery { authors: vec!["else@corp".into(), "nobody".into()], ..Default::default() }); + assert_eq!(everyone, vec![right.clone()]); + let both = ids(LogQuery { author: Some("ELSE".into()), authors: vec!["test@".into()], ..Default::default() }); + assert!(both.contains(&right) && both.len() > 1); +} + #[test] fn log_range_lists_outgoing_commits() { let (fx, _bare) = with_remote(); @@ -802,6 +868,11 @@ fn smart_and_force_checkout_with_local_changes_in_the_way() { assert_eq!(outcome, CheckoutOutcome::Conflicts { files: vec!["f.txt".into()] }); assert_eq!(repo.branch_info().unwrap().head.as_deref(), Some("other")); assert_eq!(repo.stashes().unwrap().len(), 1, "git keeps the stash after a conflicting pop"); + // The stashed edit is the user's side of the merge, the checked-out branch the other. + let v = repo.merge_versions(&fx.path("f.txt")).unwrap(); + assert_eq!(v.sides.operation.as_deref(), Some("unstash")); + assert!(v.sides.reversed); + assert_eq!(v.ours.as_deref(), Some("1\nmine\n3\n4\n5\n")); // Force discards the change in the way. fx.git(&["reset", "-q", "--hard"]); @@ -1066,7 +1137,7 @@ fn errors_carry_git_messages() { assert!(!err.message.is_empty()); let err = repo.stage(&[PathBuf::from("../outside")]).unwrap_err(); assert_eq!(err.kind, ErrorKind::InvalidInput); - assert!(repo.stash_apply(5, false).is_err()); + assert!(repo.stash_apply(5, false, false).is_err()); } #[test] diff --git a/src-tauri/src/commands/lsp.rs b/src-tauri/src/commands/lsp.rs index 1a22b9f3..75e3859b 100644 --- a/src-tauri/src/commands/lsp.rs +++ b/src-tauri/src/commands/lsp.rs @@ -156,6 +156,21 @@ pub async fn lsp_code_action_query( result } +/// Resolves a code action's edit without applying it (intention preview). +#[tauri::command] +#[specta::specta] +pub async fn lsp_resolve_code_action( + window: Window, + state: State<'_, AppState>, + doc: DocId, + action: CodeAction, +) -> IdeResult { + if action.edit.is_some() { + return Ok(action); + } + project!(window, state).lsp.resolve_code_action(doc, action).await +} + /// Resolves (if needed), applies the edit, then runs the command of a code action. #[tauri::command] #[specta::specta] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index c3d02530..da71eee9 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -123,6 +123,7 @@ pub fn builder() -> tauri_specta::Builder { lsp::lsp_workspace_symbols, lsp::lsp_code_actions, lsp::lsp_code_action_query, + lsp::lsp_resolve_code_action, lsp::lsp_apply_code_action, lsp::lsp_prepare_rename, lsp::lsp_rename, diff --git a/src-tauri/src/commands/search.rs b/src-tauri/src/commands/search.rs index e59e501e..3b5d3265 100644 --- a/src-tauri/src/commands/search.rs +++ b/src-tauri/src/commands/search.rs @@ -40,6 +40,8 @@ pub async fn search_start( let cancel = state.begin_request(request); let documents = project.documents.clone(); let root = project.root.clone(); + let languages = state.languages.clone(); + let syntax = state.syntax(); let events = on_event.clone(); let summary = tauri::async_runtime::spawn_blocking(move || { let filter = |rel: &str| match set { @@ -47,9 +49,23 @@ pub async fn search_start( Some(ProjectFileSet::Test) => roots.is_test(rel), Some(ProjectFileSet::All) | None => true, }; - ide_search::search_filtered(&root, &query, Some(&filter), &|p| documents.text_of(p), &cancel, &|file| { - let _ = events.send(SearchEvent::File { file }); - }) + // Comments and string literals for the search context filter. + let literals = |path: &Path, text: &str| { + let language = languages.detect(path, text.lines().next()); + let found = syntax.literals(&language, text)?; + Some(found.into_iter().map(literal_range).collect()) + }; + ide_search::search_in_context( + &root, + &query, + Some(&filter), + Some(&literals), + &|p| documents.text_of(p), + &cancel, + &|file| { + let _ = events.send(SearchEvent::File { file }); + }, + ) }) .await .map_err(|e| ide_core::IdeError::internal(e.to_string()))?; @@ -116,6 +132,14 @@ pub async fn replace_preview( .map_err(|e| ide_core::IdeError::internal(e.to_string()))? } +fn literal_range(l: ide_language::Literal) -> ide_search::LiteralRange { + let kind = match l.kind { + ide_language::LiteralKind::Comment => ide_search::LiteralKind::Comment, + ide_language::LiteralKind::String => ide_search::LiteralKind::String, + }; + ide_search::LiteralRange { start: l.start, end: l.end, kind } +} + struct DocumentsHost<'a>(&'a Documents); impl ReplaceHost for DocumentsHost<'_> { diff --git a/src-tauri/src/commands/vcs.rs b/src-tauri/src/commands/vcs.rs index 1dbc9baf..5dcd0057 100644 --- a/src-tauri/src/commands/vcs.rs +++ b/src-tauri/src/commands/vcs.rs @@ -299,11 +299,12 @@ pub async fn vcs_stash_push( repo: Option, message: Option, include_untracked: bool, + keep_index: bool, ) -> IdeResult<()> { let project = state.project(window.label())?; blocking(move || { project.with_repo_recorded(repo.as_deref(), Some("Git stash"), |r| { - r.stash_push(message.as_deref(), include_untracked) + r.stash_push(message.as_deref(), include_untracked, keep_index) }) }) .await @@ -317,10 +318,17 @@ pub async fn vcs_stash_apply( repo: Option, index: u32, pop: bool, + reinstate_index: bool, + branch: Option, ) -> IdeResult<()> { let project = state.project(window.label())?; - blocking(move || project.with_repo_recorded(repo.as_deref(), Some("Git unstash"), |r| r.stash_apply(index, pop))) - .await + blocking(move || { + project.with_repo_recorded(repo.as_deref(), Some("Git unstash"), |r| match branch.as_deref() { + Some(name) => r.stash_branch(index, name), + None => r.stash_apply(index, pop, reinstate_index), + }) + }) + .await } #[tauri::command] diff --git a/src/bindings.ts b/src/bindings.ts index 2d57f0ac..5ede6f18 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -240,6 +240,8 @@ export const commands = { lspCodeActions: (doc: DocId, range: Range, only: string[]) => typedError(__TAURI_INVOKE("lsp_code_actions", { doc, range, only })), /** Cancellable code actions with the kinds the servers advertise (Refactor This, refactoring availability). */ lspCodeActionQuery: (request: RequestId, doc: DocId, range: Range, only: string[]) => typedError(__TAURI_INVOKE("lsp_code_action_query", { request, doc, range, only })), + /** Resolves a code action's edit without applying it (intention preview). */ + lspResolveCodeAction: (doc: DocId, action: CodeAction) => typedError(__TAURI_INVOKE("lsp_resolve_code_action", { doc, action })), /** Resolves (if needed), applies the edit, then runs the command of a code action. */ lspApplyCodeAction: (doc: DocId, action: CodeAction) => typedError(__TAURI_INVOKE("lsp_apply_code_action", { doc, action })), lspPrepareRename: (doc: DocId, pos: Pos) => typedError<{ @@ -309,8 +311,8 @@ export const commands = { vcsRenameBranch: (repo: string | null, from: string, to: string) => typedError(__TAURI_INVOKE("vcs_rename_branch", { repo, from, to })), vcsMerge: (repo: string | null, branch: string) => typedError(__TAURI_INVOKE("vcs_merge", { repo, branch })), vcsRebase: (repo: string | null, onto: string) => typedError(__TAURI_INVOKE("vcs_rebase", { repo, onto })), - vcsStashPush: (repo: string | null, message: string | null, includeUntracked: boolean) => typedError(__TAURI_INVOKE("vcs_stash_push", { repo, message, includeUntracked })), - vcsStashApply: (repo: string | null, index: number, pop: boolean) => typedError(__TAURI_INVOKE("vcs_stash_apply", { repo, index, pop })), + vcsStashPush: (repo: string | null, message: string | null, includeUntracked: boolean, keepIndex: boolean) => typedError(__TAURI_INVOKE("vcs_stash_push", { repo, message, includeUntracked, keepIndex })), + vcsStashApply: (repo: string | null, index: number, pop: boolean, reinstateIndex: boolean, branch: string | null) => typedError(__TAURI_INVOKE("vcs_stash_apply", { repo, index, pop, reinstateIndex, branch })), vcsStashDrop: (repo: string | null, index: number) => typedError(__TAURI_INVOKE("vcs_stash_drop", { repo, index })), /** Network operation; progress lines stream through `on_output`. Cancellable. */ vcsRemote: (repo: string | null, request: RequestId, op: RemoteOp, onOutput: Channel) => typedError(__TAURI_INVOKE("vcs_remote", { repo, request, op, onOutput })), @@ -2393,6 +2395,11 @@ export type InlayHint = { paddingLeft: boolean, paddingRight: boolean, tooltip: string | null, + /** + * The label's parts when the server split it (empty for a plain label); + * their values concatenate to `label`. + */ + parts: InlayLabelPart[], }; /** Editor › Inlay Hints. */ @@ -2408,6 +2415,14 @@ export type InlayHintsSettings = { export type InlayKind = "type" | "parameter" | "other"; +/** One part of an inlay hint label, e.g. a type name that navigates to its declaration. */ +export type InlayLabelPart = { + value: string, + tooltip: string | null, + /** Declaration the part stands for (⌘-click navigates there). */ + location: Location | null, +}; + export type InspectEvent = /** Files about to be inspected. */ { type: "started"; total: number } | @@ -2741,6 +2756,10 @@ export type LogQuery = { textRegex?: boolean, /** `text` matches case-sensitively (Log filter `Cc`). */ textMatchCase?: boolean, + /** More starting revisions next to `branch` (Log Branch filter with several branches). */ + branches?: string[], + /** More author needles next to `author`; a commit matches any of them (Log User filter). */ + authors?: string[], }; export type LspRefreshKind = "inlayHints" | "semanticTokens" | "codeLens"; @@ -2888,6 +2907,12 @@ export type MergeVersions = { ours: string | null, theirs: string | null, sides: MergeSides, + /** + * Three-way merge of the versions above: non-conflicting changes merged, each + * conflict left as a diff3 block (yours / base / theirs). `None` when a side is + * missing (deleted on one side). + */ + merged: string | null, }; /** Per-function totals (a recursive function's samples count once in `total`). */ @@ -3503,6 +3528,8 @@ export type RunInfo = { /** Run/Debug configuration behaviour. */ export type RunSettings = { confirmRerun?: boolean, + /** Ask before closing a Run tab whose process is still running. */ + confirmTerminate?: boolean, showOnStart?: boolean, /** Grace period between interrupt and kill when stopping a process. */ terminationTimeoutMs?: number, @@ -3557,6 +3584,12 @@ export type SdkKind = "python" | "node" | /** Maven homes. */ "maven"; +/** + * IDEA's search context: matches anywhere, only in, or outside comments and + * string literals. Files whose language has no syntax tree contain neither. + */ +export type SearchContext = "any" | "inComments" | "inStringLiterals" | "exceptComments" | "exceptStringLiterals" | "exceptCommentsAndStringLiterals"; + export type SearchEvent = { type: "file"; file: FileMatches } | { type: "done"; summary: SearchSummary }; export type SearchQuery = { @@ -3573,6 +3606,8 @@ export type SearchQuery = { scope: SearchScope, /** Stop after this many matches overall (0 = 10 000). */ maxMatches: number, + /** Where in the code a match may lie (IDEA's search context filter). */ + context?: SearchContext, }; export type SearchScope = { kind: "project" } | { kind: "directory"; path: IpcPath } | { kind: "files"; paths: IpcPath[] } | diff --git a/src/editor/api.ts b/src/editor/api.ts index 6a08c48b..67bdbb2e 100644 --- a/src/editor/api.ts +++ b/src/editor/api.ts @@ -445,3 +445,21 @@ export function registerLanguageMode(desc: LanguageDescription): () => void { export function _languageMode(name: string): LanguageDescription | undefined { return languageModes.get(name); } + +// --- read-only code previews ------------------------------------------------------ + +let codePreviewProvider: ((path: string) => Promise) | null = null; + +/** + * Extensions for a read-only CodeMirror view showing `path` like an editor tab + * (editor font and colours, line numbers, syntax highlighting): previews + * outside editor tabs such as Find in Files. Empty before editor/core starts. + */ +export function codePreviewExtensions(path: string): Promise { + return codePreviewProvider ? codePreviewProvider(path) : Promise.resolve([]); +} + +/** @internal editor/core only. */ +export function _provideCodePreview(provider: ((path: string) => Promise) | null) { + codePreviewProvider = provider; +} diff --git a/src/editor/core/actions.ts b/src/editor/core/actions.ts index 8371a8a2..29d11845 100644 --- a/src/editor/core/actions.ts +++ b/src/editor/core/actions.ts @@ -1,21 +1,22 @@ /** Editor actions (ids from keymaps/intellij-macos.ts). */ import { EditorSelection, type EditorState } from "@codemirror/state"; import type { EditorView } from "@codemirror/view"; -import { addCursorAbove, addCursorBelow, deleteLine, moveLineDown, moveLineUp, toggleBlockComment } from "@codemirror/commands"; -import { foldAll, syntaxTree, toggleFold, unfoldAll } from "@codemirror/language"; +import { addCursorAbove, addCursorBelow, moveLineDown, moveLineUp, toggleBlockComment } from "@codemirror/commands"; +import { syntaxTree, toggleFold } from "@codemirror/language"; import { acceptCompletion, completionStatus } from "@codemirror/autocomplete"; import { executeAction, registerActions, type Action } from "@/platform/actions"; import { getContext } from "@/platform/context"; import { editorController, useGotoLine, useTabs } from "./controller"; import { - addCaretNextOccurrence, addCaretsToLineEnds, commentLine, completeStatement, + deleteLine, duplicateLineOrSelection, joinLines, moveStatementDown, moveStatementUp, + nextOccurrence, selectAllOccurrences, startNewLine, startNewLineBefore, @@ -25,9 +26,10 @@ import { import { emptyStack, extendSelection, shrinkSelection, type SelectionStack, type SimpleRange } from "./model/selectionStack"; import type { EditorSlot } from "./model/slot"; import { findAgain, openFindBar } from "./view/FindBar"; -import { foldAtCaret, foldSelection, unfoldAtCaret } from "./cm/folding"; +import { collapseAll, collapseRecursively, expandAll, expandRecursively, foldAtCaret, foldSelection, unfoldAtCaret } from "./cm/folding"; import { reverseLines, sortLines } from "./cm/lines"; -import { cursorCodeBlockEnd, cursorCodeBlockStart, scrollToCenter, splitLine } from "./cm/caretCommands"; +import { cursorCodeBlockEnd, cursorCodeBlockStart, cursorMatchBrace, scrollToCenter, splitLine } from "./cm/caretCommands"; +import { isOverwrite, toggleOverwrite } from "./cm/overwrite"; import { isColumnMode, toggleColumnMode } from "./cm/columnMode"; import { pasteAsPlainText, pasteFromHistory, unwrapAtCaret } from "./extras"; import { cyclicExpandWord } from "./wordCompletion"; @@ -289,6 +291,11 @@ function copyReference(view: EditorView, slot: EditorSlot) { void executeAction("editor.showHint", { text: `Copied reference: ${reference}` }); } +/** ⌃G; when nothing more is found, a hint first and the search from the top on the next press (IDEA). */ +function addNextOccurrence(view: EditorView) { + if (nextOccurrence(view) === "notFound") void executeAction("editor.showHint", { text: "Not found, press again to search from the top" }); +} + // --------------------------------------------------------------------------------- export function registerEditorActions(): () => void { @@ -341,9 +348,11 @@ export function registerEditorActions(): () => void { toggled: () => activeSlot()?.model.softWrapOn() ?? false, run: () => activeSlot()?.model.toggleSoftWrap(), }, - text("code.foldAll", "Collapse All", (v) => foldAll(v), { category: "Folding", icon: "collapse-all" }), - text("code.unfoldAll", "Expand All", (v) => unfoldAll(v), { category: "Folding", icon: "expand-all" }), - text("code.addCaretNextOccurrence", "Add Selection for Next Occurrence", (v) => addCaretNextOccurrence(v), { category: "Edit" }), + text("code.foldAll", "Collapse All", (v) => collapseAll(v), { category: "Folding", icon: "collapse-all" }), + text("code.unfoldAll", "Expand All", (v) => expandAll(v), { category: "Folding", icon: "expand-all" }), + text("code.collapseRecursively", "Collapse Recursively", (v) => collapseRecursively(v), { category: "Folding", keywords: ["fold"] }), + text("code.expandRecursively", "Expand Recursively", (v) => expandRecursively(v), { category: "Folding", keywords: ["unfold"] }), + text("code.addCaretNextOccurrence", "Add Selection for Next Occurrence", (v) => addNextOccurrence(v), { category: "Edit" }), text("code.selectAllOccurrences", "Select All Occurrences", (v) => selectAllOccurrences(v), { category: "Edit" }), text("code.completeStatement", "Complete Current Statement", (v, slot) => completeCurrentStatement(v, slot)), text("code.startNewLine", "Start New Line", (v) => startNewLine(v)), @@ -354,6 +363,19 @@ export function registerEditorActions(): () => void { text("editor.codeBlockEnd", "Move Caret to Code Block End", (v) => cursorCodeBlockEnd(v), { category: "Editor Actions" }), text("editor.cloneCaretAbove", "Clone Caret Above", (v) => addCursorAbove(v), { category: "Editor Actions" }), text("editor.cloneCaretBelow", "Clone Caret Below", (v) => addCursorBelow(v), { category: "Editor Actions" }), + text("editor.matchBrace", "Move Caret to Matching Brace", (v) => cursorMatchBrace(v), { + category: "Editor Actions", + // ⌃M is Show Commit Message History in the commit window. + keyWhen: "editorTextFocus", + }), + text("editor.toggleInsertState", "Toggle Insert/Overwrite", (v) => toggleOverwrite(v), { + category: "Editor Actions", + keywords: ["overwrite mode", "overtype"], + toggled: () => { + const view = activeSlot()?.view; + return view ? isOverwrite(view.state) : false; + }, + }), text("code.joinLines", "Join Lines", (v) => joinLines(v)), text("code.toggleCase", "Toggle Case", (v) => toggleCase(v), { keywords: ["upper", "lower"] }), text("code.addCaretsToLineEnds", "Add Carets to Ends of Selected Lines", (v) => addCaretsToLineEnds(v), { category: "Edit" }), diff --git a/src/editor/core/cm/caretCommands.ts b/src/editor/core/cm/caretCommands.ts index 11dd8d38..7fd9b742 100644 --- a/src/editor/core/cm/caretCommands.ts +++ b/src/editor/core/cm/caretCommands.ts @@ -1,8 +1,8 @@ -/** IDEA caret commands that CodeMirror has no equivalent for: Split Line, Scroll to Center, code block navigation. */ +/** IDEA caret commands that CodeMirror has no equivalent for: Split Line, Scroll to Center, code block and brace navigation. */ import { EditorSelection, type EditorState, type Transaction } from "@codemirror/state"; import { EditorView, type Command } from "@codemirror/view"; import { insertNewlineAndIndent } from "@codemirror/commands"; -import { syntaxTree } from "@codemirror/language"; +import { matchBrackets, syntaxTree } from "@codemirror/language"; /** Split Line: break the line at each caret, indented, leaving the caret before the break. */ export const splitLine: Command = (view) => { @@ -90,3 +90,41 @@ function moveToBlock(find: (state: EditorState, pos: number) => number | null): export const cursorCodeBlockStart = moveToBlock(codeBlockStart); export const cursorCodeBlockEnd = moveToBlock(codeBlockEnd); + +/** + * Where Go to Matching Brace (⌃M) goes from `pos`: from a brace next to the + * caret to its match; elsewhere to the unclosed opening brace before the caret. + */ +export function matchBraceTarget(state: EditorState, pos: number): number | null { + const found = + matchBrackets(state, pos, -1) || + matchBrackets(state, pos, 1) || + (pos > 0 && matchBrackets(state, pos - 1, 1)) || + (pos < state.doc.length && matchBrackets(state, pos + 1, -1)); + if (found && found.end) return found.start.from === pos ? found.end.to : found.end.from; + const tree = syntaxTree(state); + let depth = 0; + for (let p = pos - 1; p >= 0; p--) { + const ch = state.sliceDoc(p, p + 1); + const open = "([{".includes(ch); + if (!open && !")]}".includes(ch)) continue; + if (tree.length && NOT_CODE.test(tree.resolveInner(p, 1).name)) continue; + if (!open) depth++; + else if (depth-- === 0) return p; + } + return null; +} + +export const cursorMatchBrace: Command = (view) => { + const { state } = view; + let moved = false; + const ranges = state.selection.ranges.map((r) => { + const target = matchBraceTarget(state, r.head); + if (target == null) return r; + moved = true; + return EditorSelection.cursor(target); + }); + if (!moved) return false; + view.dispatch({ selection: EditorSelection.create(ranges, state.selection.mainIndex), scrollIntoView: true, userEvent: "select.navigate" }); + return true; +}; diff --git a/src/editor/core/cm/columnMode.ts b/src/editor/core/cm/columnMode.ts index 2308c582..97945b33 100644 --- a/src/editor/core/cm/columnMode.ts +++ b/src/editor/core/cm/columnMode.ts @@ -2,9 +2,10 @@ * Edit › Column Selection Mode (⇧⌘8): while on, a plain mouse drag and * ⇧+arrows make rectangular (block) selections, one range per line. */ -import { EditorSelection, Prec, StateEffect, StateField, type EditorState, type Extension } from "@codemirror/state"; +import { EditorSelection, Prec, StateEffect, StateField, countColumn, findColumn, type EditorState, type Extension } from "@codemirror/state"; import { EditorView, keymap, rectangularSelection } from "@codemirror/view"; +/** A block between two visual columns (tabs expanded) of two lines. */ interface Block { anchorLine: number; anchorCol: number; @@ -43,18 +44,30 @@ export function toggleColumnMode(view: EditorView): boolean { return true; } -/** One range per line from the anchor column to the head column (clamped to each line). */ +/** One range per line from the anchor column to the head column (visual columns, clamped to each line). */ export function blockSelection(state: EditorState, b: Block): EditorSelection { const ranges = []; const step = b.headLine >= b.anchorLine ? 1 : -1; + const at = (text: string, col: number) => findColumn(text, col, state.tabSize); for (let n = b.anchorLine; ; n += step) { const line = state.doc.line(n); - ranges.push(EditorSelection.range(line.from + Math.min(b.anchorCol, line.length), line.from + Math.min(b.headCol, line.length))); + ranges.push(EditorSelection.range(line.from + at(line.text, b.anchorCol), line.from + at(line.text, b.headCol))); if (n === b.headLine) break; } return EditorSelection.create(ranges, ranges.length - 1); } +/** The visual column one character left / right of `col` (a tab is one step; past the line end, one column). */ +export function stepColumn(text: string, col: number, d: number, tabSize: number): number { + if (!d) return col; + const width = countColumn(text, tabSize); + if (d > 0 ? col >= width : col > width) return Math.max(0, col + d); + const pos = findColumn(text, col, tabSize); + const at = countColumn(text, tabSize, pos); + if (d > 0) return at > col ? at : countColumn(text, tabSize, pos + 1); + return pos === 0 ? 0 : countColumn(text, tabSize, pos - 1); +} + /** ⇧+arrow in column mode: moves the block's head by a line / column. */ export function extendBlock(view: EditorView, dLine: number, dCol: number): boolean { const state = view.state; @@ -65,10 +78,11 @@ export function extendBlock(view: EditorView, dLine: number, dCol: number): bool const main = state.selection.main; const a = state.doc.lineAt(main.anchor); const h = state.doc.lineAt(main.head); - b = { anchorLine: a.number, anchorCol: main.anchor - a.from, headLine: h.number, headCol: main.head - h.from }; + const col = (text: string, offset: number) => countColumn(text, state.tabSize, offset); + b = { anchorLine: a.number, anchorCol: col(a.text, main.anchor - a.from), headLine: h.number, headCol: col(h.text, main.head - h.from) }; } const headLine = Math.max(1, Math.min(state.doc.lines, b.headLine + dLine)); - const next = { ...b, headLine, headCol: Math.max(0, b.headCol + dCol) }; + const next = { ...b, headLine, headCol: stepColumn(state.doc.line(b.headLine).text, b.headCol, dCol, state.tabSize) }; view.dispatch({ selection: blockSelection(state, next), effects: setBlock.of(next), diff --git a/src/editor/core/cm/editing.test.ts b/src/editor/core/cm/editing.test.ts index 0272acc5..10c13654 100644 --- a/src/editor/core/cm/editing.test.ts +++ b/src/editor/core/cm/editing.test.ts @@ -151,6 +151,10 @@ describe("multiple carets", () => { expect(show(v)).toBe("[a] [a] a"); expect(v.state.selection.mainIndex).toBe(1); unselectOccurrence(v); + // A single selection collapses to its caret. + expect(show(v)).toBe("[a] a a"); + expect(unselectOccurrence(v)).toBe(true); + expect(show(v)).toBe("a| a a"); expect(unselectOccurrence(v)).toBe(false); }); diff --git a/src/editor/core/cm/editing.ts b/src/editor/core/cm/editing.ts index 8d9a70df..8f29b380 100644 --- a/src/editor/core/cm/editing.ts +++ b/src/editor/core/cm/editing.ts @@ -1,21 +1,34 @@ /** * IDEA editing commands on top of @codemirror/commands. */ -import { EditorSelection, type ChangeSpec, type EditorState, type SelectionRange, type Transaction, type TransactionSpec } from "@codemirror/state"; +import { + EditorSelection, + StateEffect, + StateField, + countColumn, + type ChangeSpec, + type EditorState, + type SelectionRange, + type Transaction, + type TransactionSpec, +} from "@codemirror/state"; import { EditorView, type Command } from "@codemirror/view"; -import { syntaxTree, getIndentUnit, indentString, getIndentation } from "@codemirror/language"; +import { foldedRanges, forceParsing, syntaxTree, getIndentUnit, indentString, getIndentation } from "@codemirror/language"; import { copyLineDown, + deleteLine as deleteDocLine, insertNewlineAndIndent, moveLineDown, moveLineUp, } from "@codemirror/commands"; -import { selectNextOccurrence, selectSelectionMatches } from "@codemirror/search"; -import { humpLeft, humpRight } from "../model/camelHumps"; +import { SearchCursor } from "@codemirror/search"; +import { humpLeft, humpRight, wordStartLeft } from "../model/camelHumps"; + +type Node = ReturnType["resolveInner"]>; // --- CamelHumps ------------------------------------------------------------- -function humpTarget(state: EditorState, pos: number, forward: boolean): number { +function humpTarget(state: EditorState, pos: number, forward: boolean, camel = true): number { const line = state.doc.lineAt(pos); const col = pos - line.from; if (forward) { @@ -23,7 +36,7 @@ function humpTarget(state: EditorState, pos: number, forward: boolean): number { return line.from + humpRight(line.text, col); } if (col <= 0) return Math.max(0, line.from - 1); - return line.from + humpLeft(line.text, col); + return line.from + (camel ? humpLeft(line.text, col) : wordStartLeft(line.text, col)); } function moveHump(forward: boolean, extend: boolean): Command { @@ -46,12 +59,12 @@ export const cursorHumpRight = moveHump(true, false); export const selectHumpLeft = moveHump(false, true); export const selectHumpRight = moveHump(true, true); -function deleteHump(forward: boolean): Command { +function deleteHump(forward: boolean, camel = true): Command { return (view) => { if (view.state.readOnly) return false; const tr = view.state.changeByRange((r) => { if (!r.empty) return { changes: { from: r.from, to: r.to }, range: EditorSelection.cursor(r.from) }; - const target = humpTarget(view.state, r.head, forward); + const target = humpTarget(view.state, r.head, forward, camel); const from = Math.min(target, r.head); const to = Math.max(target, r.head); return { changes: { from, to }, range: EditorSelection.cursor(from) }; @@ -63,14 +76,65 @@ function deleteHump(forward: boolean): Command { export const deleteHumpBackward = deleteHump(false); export const deleteHumpForward = deleteHump(true); +/** ⌥⌫ / Ctrl+Backspace without CamelHumps: the whitespace before the caret and the word (or punctuation run) before it. */ +export const deleteWordBackward = deleteHump(false, false); // --- lines / selections ------------------------------------------------------- +/** + * The whole lines `[from, to]` touches, grown to the visual lines they are + * part of: a folded region's lines go together with the line it is shown on. + */ +export function visualLineSpan(state: EditorState, from: number, to: number): { from: number; to: number } { + let a = state.doc.lineAt(from).from; + let b = state.doc.lineAt(to).to; + const folded = foldedRanges(state); + for (let changed = true; changed; ) { + changed = false; + folded.between(a, b, (f, t) => { + const na = Math.min(a, state.doc.lineAt(f).from); + const nb = Math.max(b, state.doc.lineAt(t).to); + if (na !== a || nb !== b) { + a = na; + b = nb; + changed = true; + } + }); + } + return { from: a, to: b }; +} + +/** Visual line spans of the selection ranges, merged; `null` when no fold is involved. */ +function foldedLineSpans(state: EditorState): Array<{ from: number; to: number }> | null { + const spans: Array<{ from: number; to: number }> = []; + let folds = false; + for (const r of state.selection.ranges) { + const end = r.to > r.from && state.doc.lineAt(r.to).from === r.to ? r.to - 1 : r.to; + const span = visualLineSpan(state, r.from, end); + if (span.from !== state.doc.lineAt(r.from).from || span.to !== state.doc.lineAt(end).to) folds = true; + const last = spans[spans.length - 1]; + if (last && span.from <= last.to + 1) last.to = Math.max(last.to, span.to); + else spans.push(span); + } + return folds ? spans : null; +} + /** ⌘D: duplicate the line (caret moves to the copy) or the selection (copy selected). */ export const duplicateLineOrSelection: Command = (view) => { const { state } = view; if (state.readOnly) return false; - if (state.selection.ranges.every((r) => r.empty)) return copyLineDown(view); + if (state.selection.ranges.every((r) => r.empty)) { + const spans = foldedLineSpans(state); + if (!spans) return copyLineDown(view); + // On a folded region's line the whole folded block is duplicated. + const changes = state.changes(spans.map((s) => ({ from: s.to, insert: state.lineBreak + state.sliceDoc(s.from, s.to) }))); + const ranges = state.selection.ranges.map((r) => { + const s = spans.find((sp) => sp.from <= r.head && r.head <= sp.to)!; + return EditorSelection.cursor(changes.mapPos(r.head, -1) + s.to - s.from + state.lineBreak.length); + }); + view.dispatch({ changes, selection: EditorSelection.create(ranges, state.selection.mainIndex), scrollIntoView: true, userEvent: "input.duplicate" }); + return true; + } const tr = state.changeByRange((r) => { if (r.empty) { const line = state.doc.lineAt(r.head); @@ -89,18 +153,139 @@ export const duplicateLineOrSelection: Command = (view) => { return true; }; -/** ⌃G: add the next occurrence of the word / selection as a caret. */ -export const addCaretNextOccurrence: Command = (view) => selectNextOccurrence(view); +/** ⌘⌫: Delete Line — on a folded region's line, every line of the folded block. */ +export const deleteLine: Command = (view) => { + const { state } = view; + if (state.readOnly) return false; + const spans = foldedLineSpans(state); + if (!spans) return deleteDocLine(view); + const len = state.doc.length; + const changes = state.changes( + spans.map((s) => (s.to < len ? { from: s.from, to: s.to + 1 } : { from: Math.max(0, s.from - 1), to: s.to })), + ); + const doc = changes.apply(state.doc); + const ranges = state.selection.ranges.map((r) => { + const col = r.head - state.doc.lineAt(r.head).from; + const line = doc.lineAt(changes.mapPos(r.head, -1)); + return EditorSelection.cursor(line.from + Math.min(col, line.length)); + }); + view.dispatch({ changes, selection: EditorSelection.create(ranges, state.selection.mainIndex), scrollIntoView: true, userEvent: "delete.line" }); + return true; +}; -/** ⌃⌘G: select every occurrence of the word / selection. */ +// --- occurrences -------------------------------------------------------------- + +interface OccurrenceSearch { + /** The sequence started from a bare caret: occurrences match whole words only. */ + wholeWords: boolean; + /** The last search found nothing: the next one starts from the top. */ + notFound: boolean; +} + +const setOccurrenceSearch = StateEffect.define(); + +/** State of a run of Add Selection for Next Occurrence; any other selection change or edit ends it. */ +export const occurrenceSearch = StateField.define({ + create: () => null, + update(value, tr) { + for (const e of tr.effects) if (e.is(setOccurrenceSearch)) return e.value; + return tr.docChanged || tr.selection ? null : value; + }, +}); + +const IDENT = /[\p{L}\p{N}_$]/u; +const identAt = (state: EditorState, pos: number) => pos >= 0 && pos < state.doc.length && IDENT.test(state.sliceDoc(pos, pos + 1)); + +/** The identifier at or right before `pos`. */ +export function identifierAt(state: EditorState, pos: number): { from: number; to: number } | null { + let from = pos; + let to = pos; + while (from > 0 && identAt(state, from - 1)) from--; + while (to < state.doc.length && identAt(state, to)) to++; + return from < to ? { from, to } : null; +} + +function isWholeWord(state: EditorState, from: number, to: number): boolean { + if (identAt(state, from - 1) && identAt(state, from)) return false; + return !(identAt(state, to) && identAt(state, to - 1)); +} + +/** Case-sensitive matches of `text` from `from` on (whole words only when asked). */ +function* matches(state: EditorState, text: string, from: number, wholeWords: boolean) { + const cursor = new SearchCursor(state.doc, text, from); + for (cursor.next(); !cursor.done; cursor.next()) { + const { from: a, to: b } = cursor.value; + if (!wholeWords || isWholeWord(state, a, b)) yield { from: a, to: b }; + } +} + +/** A selection of `m` with the head on the same side as in `like`. */ +const rangeLike = (like: SelectionRange, m: { from: number; to: number }) => + like.head === like.from && like.from !== like.to ? EditorSelection.range(m.to, m.from) : EditorSelection.range(m.from, m.to); + +/** + * ⌃G: Add Selection for Next Occurrence. From a bare caret it selects the + * word (and the run then matches whole words); with a selection it adds the + * next match after the last added one. Nothing found reports `notFound` (the + * caller hints); the next invocation then searches from the top. + */ +export function nextOccurrence(view: EditorView): "selected" | "added" | "notFound" | "none" { + const { state } = view; + const main = state.selection.main; + if (main.empty) { + const word = identifierAt(state, main.head); + if (!word) return "none"; + view.dispatch({ + selection: state.selection.replaceRange(EditorSelection.range(word.from, word.to)), + effects: setOccurrenceSearch.of({ wholeWords: true, notFound: false }), + scrollIntoView: true, + userEvent: "select", + }); + return "selected"; + } + const run = state.field(occurrenceSearch, false) ?? null; + const wholeWords = !!run?.wholeWords; + const text = state.sliceDoc(main.from, main.to); + const hit = matches(state, text, run?.notFound ? 0 : main.to, wholeWords).next().value; + if (!hit) { + view.dispatch({ effects: setOccurrenceSearch.of({ wholeWords, notFound: true }) }); + return "notFound"; + } + if (state.selection.ranges.some((r) => r.from === hit.from && r.to === hit.to)) { + view.dispatch({ effects: setOccurrenceSearch.of({ wholeWords, notFound: !!run?.notFound }) }); + return "none"; + } + view.dispatch({ + selection: state.selection.addRange(rangeLike(main, hit)), + effects: [setOccurrenceSearch.of({ wholeWords, notFound: false }), EditorView.scrollIntoView(hit.from)], + userEvent: "select", + }); + return "added"; +} + +export const addCaretNextOccurrence: Command = (view) => nextOccurrence(view) !== "none"; + +/** ⌃⌘G: select every occurrence of the selection, or of the word at the caret (whole words). */ export const selectAllOccurrences: Command = (view) => { const { state } = view; - if (state.selection.main.empty) { - const word = state.wordAt(state.selection.main.head); + let main = state.selection.main; + let wholeWords = false; + if (main.empty) { + const word = identifierAt(state, main.head); if (!word) return false; - view.dispatch({ selection: EditorSelection.single(word.from, word.to) }); + main = EditorSelection.range(word.from, word.to); + wholeWords = true; } - return selectSelectionMatches(view); + const text = state.sliceDoc(main.from, main.to); + const ranges: SelectionRange[] = []; + let mainIndex = 0; + for (const m of matches(state, text, 0, wholeWords)) { + if (m.from === main.from) mainIndex = ranges.length; + ranges.push(rangeLike(main, m)); + } + if (!ranges.length) return false; + view.dispatch({ selection: EditorSelection.create(ranges, mainIndex), userEvent: "select" }); + return true; }; // --- move statement ----------------------------------------------------------- @@ -110,65 +295,192 @@ interface LineSpan { to: number; // line end (excl. break) } -function wholeLines(state: EditorState, from: number, to: number): LineSpan { - return { from: state.doc.lineAt(from).from, to: state.doc.lineAt(to).to }; -} +/** Nodes whose children are statements / members (plus the top node). */ +const CONTAINER = + /^(Program|Script|Module|SourceFile|Chunk|Document|StyleSheet|JsonText|Object|Array|BlockMapping|BlockSequence|Suite|CompoundStatement|DeclarationList|FieldDeclarationList|StatementList)$|Block$|Body$/; +/** Function / class-like nodes: Move Statement never carries code into or out of them. */ +const GUARD = /Function|Method|Class|Interface|Constructor|Enum|Struct|Trait|Impl|Lambda|Arrow|Closure|Func/; +const NOT_GUARD = /Body|Block|Call|Name|Type|Keyword|Modifier|List|Param|Signature|Invocation|Reference|Access/; -/** The statement-like syntax node starting on the caret's line, as whole lines. */ -function statementAt(state: EditorState, pos: number): { span: LineSpan; node: { from: number; to: number; parent: unknown } } | null { - const line = state.doc.lineAt(pos); +const isContainer = (n: Node | null) => !n || !n.parent || CONTAINER.test(n.type.name); +const isGuard = (n: Node) => GUARD.test(n.type.name) && !NOT_GUARD.test(n.type.name); + +function firstToken(state: EditorState, lineNo: number): number | null { + const line = state.doc.line(lineNo); const indent = /^\s*/.exec(line.text)![0].length; - const start = line.from + indent; - if (start >= line.to) return null; - let node = syntaxTree(state).resolveInner(start, 1); - // Climb to the outermost node that starts at the line's first token. - while (node.parent && node.parent.from === start && node.parent.parent) node = node.parent; - if (node.from !== start) return null; - const span = wholeLines(state, node.from, node.to); - if (state.doc.lineAt(node.to).to !== span.to) return null; - return { span, node }; + return indent === line.length ? null : line.from + indent; } -function siblingStatement(state: EditorState, pos: number, up: boolean): LineSpan | null { - const cur = statementAt(state, pos); - if (!cur) return null; - const tree = syntaxTree(state); - let node = tree.resolveInner(cur.node.from, 1); - while (node.parent && node.parent.from === cur.node.from && node.parent.parent) node = node.parent; - let sib = up ? node.prevSibling : node.nextSibling; - // Skip punctuation / comments-only siblings on the same line. - while (sib && state.doc.lineAt(sib.from).number === state.doc.lineAt(node.from).number) sib = up ? sib.prevSibling : sib.nextSibling; - if (!sib) return null; - // Don't move a statement past its block's braces. - if (/^[{}()[\]]$/.test(state.sliceDoc(sib.from, sib.to))) return null; - const span = wholeLines(state, sib.from, sib.to); - if (state.doc.lineAt(sib.from).from !== span.from) return null; - return span; +/** The statement (or member, comment, block-closing brace) that starts at the first token of a line. */ +function statementStartingAt(state: EditorState, lineNo: number): Node | null { + const pos = firstToken(state, lineNo); + if (pos === null) return null; + for (let n: Node | null = syntaxTree(state).resolveInner(pos, 1); n && n.parent; n = n.parent) { + if (n.from !== pos) return null; + if (isContainer(n.parent)) return n; + } + return null; } -function swapSpans(state: EditorState, a: LineSpan, b: LineSpan, caretOffset: number, up: boolean): TransactionSpec { - // a is above b; the moved one is `up ? b : a`. - const textA = state.sliceDoc(a.from, a.to); - const textB = state.sliceDoc(b.from, b.to); - const between = state.sliceDoc(a.to, b.from); - const insert = textB + between + textA; - const changes: ChangeSpec = { from: a.from, to: b.to, insert }; - const anchor = up ? a.from + caretOffset : a.from + textB.length + between.length + caretOffset; - return { changes, selection: { anchor }, scrollIntoView: true, userEvent: "move.line" }; +/** Lines a moved statement stops at: blank lines, statement starts and closing braces of blocks. */ +const isStopLine = (state: EditorState, lineNo: number) => firstToken(state, lineNo) === null || !!statementStartingAt(state, lineNo); + +/** The statement containing the first token of the caret line. */ +function statementAround(state: EditorState, pos: number): Node | null { + const start = firstToken(state, state.doc.lineAt(pos).number); + if (start === null) return null; + for (let n: Node | null = syntaxTree(state).resolveInner(start, 1); n && n.parent; n = n.parent) if (isContainer(n.parent)) return n; + return null; +} + +/** The statement ending on a line (at its last token). */ +function statementEndingOn(state: EditorState, lineNo: number): Node | null { + const line = state.doc.line(lineNo); + const end = line.from + line.text.trimEnd().length; + if (end === line.from) return null; + for (let n: Node | null = syntaxTree(state).resolveInner(end, -1); n && n.parent; n = n.parent) { + if (isContainer(n.parent)) return state.doc.lineAt(n.to).number === lineNo ? n : null; + } + return null; +} + +/** A function / class (or a statement holding one, e.g. a call with a callback) moves as one unit. */ +function holdsGuard(n: Node): boolean { + if (isGuard(n)) return true; + for (let c = n.firstChild; c; c = c.nextSibling) if (!CONTAINER.test(c.type.name) && holdsGuard(c)) return true; + return false; +} + +/** Moving `cur` across lines `[s, e]` would carry it into or out of a function / class. */ +function crossesGuard(state: EditorState, cur: Node, s: number, e: number): boolean { + const from = state.doc.line(s).from; + const to = state.doc.line(e).to; + let crosses = false; + syntaxTree(state).iterate({ + from, + to, + enter: (n) => { + if (crosses) return false; + if (!isGuard(n.node)) return; + if (n.from <= cur.from && n.to >= cur.to) { + const gs = state.doc.lineAt(n.from).number; + const ge = state.doc.lineAt(n.to).number; + if ((gs >= s && gs <= e) || (ge >= s && ge <= e)) crosses = true; + } else if (n.from < from || n.to > to) crosses = true; + }, + }); + return crosses; +} + +/** Comment lines directly above line `a` (a declaration's doc comment moves with it). */ +function leadingCommentStart(state: EditorState, a: number): number { + while (a > 1) { + const n = statementStartingAt(state, a - 1); + if (!n || !/Comment/.test(n.type.name)) break; + a = state.doc.lineAt(n.from).number; + } + return a; +} + +export type StatementMove = { spec: TransactionSpec } | "noop" | "line"; + +/** + * Move Statement as in IDEA: the statement around the caret (whole lines) + * moves past the next statement; into a block when the neighbour is a block + * header or closing brace, but never into or out of a function or class (then + * nothing happens). A neighbouring function / class (or a statement holding + * one) is passed as a unit. The moved lines take the indentation of their new + * place. `line` when there is no statement at the caret (move the line). + */ +export function planStatementMove(state: EditorState, up: boolean): StatementMove { + const head = state.selection.main.head; + const cur = statementAround(state, head); + if (!cur) return "line"; + const doc = state.doc; + if (/^[{}]$/.test(cur.type.name)) { + for (let g: Node | null = cur.parent; g; g = g.parent) + if (isGuard(g)) { + const curLine = doc.lineAt(cur.from).number; + if (doc.lineAt(g.from).number === curLine || doc.lineAt(g.to).number === curLine) return "noop"; + break; + } + } + let a = doc.lineAt(cur.from).number; + const b = doc.lineAt(cur.to).number; + if (holdsGuard(cur)) a = leadingCommentStart(state, a); + let s: number; + let e: number; + if (up) { + e = a - 1; + if (e < 1) return "noop"; + const prev = statementEndingOn(state, e); + if (prev && prev.from < cur.from && holdsGuard(prev)) s = leadingCommentStart(state, doc.lineAt(prev.from).number); + else { + s = e; + while (s >= 1 && !isStopLine(state, s)) s--; + if (s < 1) return "noop"; + } + } else { + s = b + 1; + if (s > doc.lines) return "noop"; + const next = statementStartingAt(state, s); + if (next && next.to > cur.to && holdsGuard(next)) e = doc.lineAt(next.to).number; + else { + e = s + 1; + while (e <= doc.lines && !isStopLine(state, e)) e++; + e--; + } + } + if (crossesGuard(state, cur, s, e)) return "noop"; + const moved: LineSpan = { from: doc.line(a).from, to: doc.line(b).to }; + const other: LineSpan = { from: doc.line(s).from, to: doc.line(e).to }; + const [first, second] = up ? [other, moved] : [moved, other]; + const insert = state.sliceDoc(second.from, second.to) + state.lineBreak + state.sliceDoc(first.from, first.to); + const changes = state.changes({ from: first.from, to: second.to, insert }); + const movedFrom = up ? first.from : first.from + (second.to - second.from) + state.lineBreak.length; + const after = state.update({ changes }).state; + const reindent = shiftToIndentation(after, movedFrom, movedFrom + (moved.to - moved.from)); + const caret = movedFrom + (head - moved.from); + return { + spec: { + changes: reindent ? changes.compose(reindent) : changes, + selection: EditorSelection.cursor(reindent ? reindent.mapPos(caret, 1) : caret), + scrollIntoView: true, + userEvent: "move.line", + }, + }; +} + +/** Shifts the lines of `[from, to]` so the first one gets the indentation the language computes for it. */ +function shiftToIndentation(state: EditorState, from: number, to: number) { + if (!syntaxTree(state).length) return null; + const first = state.doc.lineAt(from); + const proper = getIndentation(state, first.from); + if (proper === null) return null; + const delta = proper - countColumn(/^\s*/.exec(first.text)![0], state.tabSize); + if (!delta) return null; + const changes: ChangeSpec[] = []; + for (let pos = from; pos <= to; ) { + const line = state.doc.lineAt(pos); + pos = line.to + 1; + const ws = /^\s*/.exec(line.text)![0]; + if (ws.length === line.length) continue; + const col = Math.max(0, countColumn(ws, state.tabSize) + delta); + changes.push({ from: line.from, to: line.from + ws.length, insert: indentString(state, col) }); + } + return state.changes(changes); } function moveStatement(up: boolean): Command { return (view) => { - const { state } = view; - if (state.readOnly) return false; - if (state.selection.ranges.length === 1 && state.selection.main.empty) { - const head = state.selection.main.head; - const cur = statementAt(state, head); - const sib = cur && siblingStatement(state, head, up); - if (cur && sib && (cur.span.to - cur.span.from > 0)) { - const caretOffset = head - cur.span.from; - const spec = up ? swapSpans(state, sib, cur.span, caretOffset, true) : swapSpans(state, cur.span, sib, caretOffset, false); - view.dispatch(spec); + if (view.state.readOnly) return false; + if (view.state.selection.ranges.length === 1 && view.state.selection.main.empty) { + // The statement and its neighbours need a parsed tree around the caret. + forceParsing(view, Math.min(view.state.doc.length, view.state.selection.main.head + 20_000), 100); + const plan = planStatementMove(view.state, up); + if (plan === "noop") return true; + if (plan !== "line") { + view.dispatch(plan.spec); return true; } } @@ -427,10 +739,17 @@ export const addCaretsToLineEnds: Command = (view) => { return true; }; -/** ⌃⇧G: Unselect Occurrence — drops the most recently added selection. */ +/** ⌃⇧G: Unselect Occurrence — drops the most recently added selection; a single one collapses to its caret. */ export const unselectOccurrence: Command = (view) => { - const sel = view.state.selection; - if (sel.ranges.length < 2) return false; + const { state } = view; + const sel = state.selection; + const run = state.field(occurrenceSearch, false) ?? null; + const effects = setOccurrenceSearch.of({ wholeWords: !!run?.wholeWords, notFound: false }); + if (sel.ranges.length < 2) { + if (sel.main.empty) return false; + view.dispatch({ selection: EditorSelection.cursor(sel.main.head), effects, userEvent: "select" }); + return true; + } const ranges = sel.ranges.filter((_, i) => i !== sel.mainIndex); // The previous occurrence becomes main: the one closest before the removed one, else the last. const removed = sel.main.from; @@ -440,6 +759,6 @@ export const unselectOccurrence: Command = (view) => { main = i; break; } - view.dispatch({ selection: EditorSelection.create(ranges, main), scrollIntoView: true, userEvent: "select" }); + view.dispatch({ selection: EditorSelection.create(ranges, main), effects, scrollIntoView: true, userEvent: "select" }); return true; }; diff --git a/src/editor/core/cm/folding.ts b/src/editor/core/cm/folding.ts index f9fc6251..fa6d71f5 100644 --- a/src/editor/core/cm/folding.ts +++ b/src/editor/core/cm/folding.ts @@ -155,18 +155,23 @@ export function unfoldAtCaret(view: EditorView): boolean { } /** - * IDEA "Fold Selection / Remove Region" (⌘.): folds each selection; with no - * selection, expands the fold at the caret. + * IDEA "Fold Selection / Remove Region" (⌘.): folds each selection (without + * a trailing line break); with no selection, toggles the region at the caret. */ export function foldSelectionEffects(state: EditorState): StateEffect[] { const effects: StateEffect[] = []; for (const range of state.selection.ranges) { if (!range.empty) { - effects.push(foldEffect.of({ from: range.from, to: range.to })); + const to = state.sliceDoc(range.to - 1, range.to) === "\n" ? range.to - 1 : range.to; + if (to > range.from) effects.push(foldEffect.of({ from: range.from, to })); continue; } - const r = foldToExpand(state, range.head); - if (r) effects.push(unfoldEffect.of(r)); + const folded = foldToExpand(state, range.head); + if (folded) effects.push(unfoldEffect.of(folded)); + else { + const r = enclosingFoldRange(state, range.head); + if (r) effects.push(foldEffect.of(r)); + } } return effects; } @@ -175,11 +180,98 @@ export function foldSelection(view: EditorView): boolean { const effects = foldSelectionEffects(view.state); if (!effects.length) return false; const sel = view.state.selection; - // The caret lands after the fold, like IDEA. - view.dispatch({ effects, selection: sel.ranges.some((r) => !r.empty) ? { anchor: sel.main.to } : undefined }); + const folded = effects.filter((e) => e.is(foldEffect)).map((e) => e.value as { from: number; to: number }); + // The caret lands after the new fold, like IDEA. + const after = sel.ranges.some((r) => !r.empty) && folded.length ? { anchor: folded[folded.length - 1]!.to } : undefined; + view.dispatch({ effects, selection: after }); return true; } +function isFolded(state: EditorState, r: { from: number; to: number }): boolean { + let hit = false; + foldedRanges(state).between(r.from, r.from, (a, b) => { + if (a === r.from && b === r.to) hit = true; + }); + return hit; +} + +/** Every foldable region (nested ones included) of the lines in `[from, to]`, kept within `[from, to]`. */ +export function foldableRegions(state: EditorState, from = 0, to = state.doc.length): Array<{ from: number; to: number }> { + const out: Array<{ from: number; to: number }> = []; + for (let pos = state.doc.lineAt(from).from; pos <= to; ) { + const line = state.doc.lineAt(pos); + const r = foldable(state, line.from, line.to); + if (r && r.from >= from && r.to <= to) out.push(r); + if (line.to >= state.doc.length) break; + pos = line.to + 1; + } + return out; +} + +/** The selection, when it holds whole regions to work on; else the whole document. */ +function regionScope(state: EditorState): { from: number; to: number } | null { + const main = state.selection.main; + return main.empty ? null : { from: main.from, to: main.to }; +} + +/** + * IDEA "Collapse All" (⌘⇧-): every region, nested ones included; with a + * selection, the regions inside it (all of them when it holds none). + */ +export function collapseAllEffects(state: EditorState): StateEffect[] { + const scope = regionScope(state); + let regions = scope ? foldableRegions(state, scope.from, scope.to) : []; + if (!regions.length) regions = foldableRegions(state); + return regions.filter((r) => !isFolded(state, r)).map((r) => foldEffect.of(r)); +} + +/** IDEA "Expand All" (⌘⇧=): every folded region, or those inside the selection. */ +export function expandAllEffects(state: EditorState): StateEffect[] { + const all: Array<{ from: number; to: number }> = []; + foldedRanges(state).between(0, state.doc.length, (from, to) => void all.push({ from, to })); + const scope = regionScope(state); + const inside = scope ? all.filter((r) => r.from >= scope.from && r.to <= scope.to) : []; + return (inside.length ? inside : all).map((r) => unfoldEffect.of(r)); +} + +/** + * IDEA "Collapse Recursively" (⌘⌥-): the region at the caret line (else the + * innermost one around the caret) and every region inside it. + */ +export function collapseRecursivelyEffects(state: EditorState, head: number): StateEffect[] { + const line = state.doc.lineAt(head); + const own = foldable(state, line.from, line.to); + const root = own && !isFolded(state, own) ? own : enclosingFoldRange(state, head); + if (!root) return []; + const inner = foldableRegions(state, root.from, root.to).filter((r) => !isFolded(state, r)); + if (!isFolded(state, root) && !inner.some((r) => r.from === root.from && r.to === root.to)) inner.push(root); + return inner.map((r) => foldEffect.of(r)); +} + +/** IDEA "Expand Recursively" (⌘⌥=): the region at the caret and every folded region inside it. */ +export function expandRecursivelyEffects(state: EditorState, head: number): StateEffect[] { + const line = state.doc.lineAt(head); + const root = foldToExpand(state, head) ?? foldable(state, line.from, line.to); + if (!root) return []; + const out: StateEffect[] = []; + foldedRanges(state).between(root.from, root.to, (from, to) => { + if (from >= root.from && to <= root.to) out.push(unfoldEffect.of({ from, to })); + }); + return out; +} + +function dispatchEffects(view: EditorView, effects: StateEffect[]): boolean { + if (effects.length) view.dispatch({ effects }); + return effects.length > 0; +} + +export const collapseAll = (view: EditorView) => dispatchEffects(view, collapseAllEffects(view.state)); +export const expandAll = (view: EditorView) => dispatchEffects(view, expandAllEffects(view.state)); +export const collapseRecursively = (view: EditorView) => + dispatchEffects(view, view.state.selection.ranges.flatMap((r) => collapseRecursivelyEffects(view.state, r.head))); +export const expandRecursively = (view: EditorView) => + dispatchEffects(view, view.state.selection.ranges.flatMap((r) => expandRecursivelyEffects(view.state, r.head))); + function chevron(open: boolean): HTMLElement { const span = document.createElement("span"); span.className = open ? "cm-openidea-foldOpen" : "cm-openidea-foldClosed"; diff --git a/src/editor/core/cm/gutterDrag.ts b/src/editor/core/cm/gutterDrag.ts new file mode 100644 index 00000000..906e034c --- /dev/null +++ b/src/editor/core/cm/gutterDrag.ts @@ -0,0 +1,72 @@ +/** + * Dragging over the line numbers (or the empty space of the other gutters) + * selects whole lines, as in IDEA. A press without a drag is left to the + * gutter's own click handling (e.g. toggling a breakpoint). + */ +import { EditorSelection, type EditorState } from "@codemirror/state"; +import { EditorView, ViewPlugin } from "@codemirror/view"; + +/** The lines from `start` to `end` (1-based, either order), the head at the far line boundary. */ +export function lineRangeSelection(state: EditorState, start: number, end: number): EditorSelection { + const doc = state.doc; + const after = (n: number) => (n < doc.lines ? doc.line(n + 1).from : doc.length); + return end >= start + ? EditorSelection.single(doc.line(start).from, after(end)) + : EditorSelection.single(after(start), doc.line(end).from); +} + +/** Line-number column or empty gutter space; not the fold chevrons, VCS annotations or a gutter's own markers. */ +export function inLineSelectArea(target: EventTarget | null): boolean { + const t = target as HTMLElement | null; + if (!t?.closest?.(".cm-gutters") || t.closest(".cm-foldGutter, .cm-vcs-blame")) return false; + if (t.closest(".cm-lineNumbers")) return true; + if (t.classList.contains("cm-gutter")) return true; + return t.classList.contains("cm-gutterElement") && t.childElementCount === 0; +} + +export const gutterLineDrag = ViewPlugin.fromClass( + class { + private stop: (() => void) | null = null; + + constructor(readonly view: EditorView) { + view.dom.addEventListener("mousedown", this.onMouseDown); + } + + private lineAt(clientY: number): number { + const block = this.view.lineBlockAtHeight(clientY - this.view.documentTop); + return this.view.state.doc.lineAt(block.from).number; + } + + onMouseDown = (e: MouseEvent) => { + if (e.button !== 0 || e.altKey || e.metaKey || e.ctrlKey || e.shiftKey || !inLineSelectArea(e.target)) return; + this.stop?.(); + const start = this.lineAt(e.clientY); + let dragging = false; + const move = (m: MouseEvent) => { + if (!(m.buttons & 1)) return finish(); + const line = this.lineAt(m.clientY); + if (!dragging && line === start) return; + dragging = true; + this.view.dispatch({ + selection: lineRangeSelection(this.view.state, start, line), + scrollIntoView: true, + userEvent: "select.pointer", + }); + }; + const finish = () => { + window.removeEventListener("mousemove", move, true); + window.removeEventListener("mouseup", finish, true); + this.stop = null; + if (dragging) this.view.focus(); + }; + window.addEventListener("mousemove", move, true); + window.addEventListener("mouseup", finish, true); + this.stop = finish; + }; + + destroy() { + this.stop?.(); + this.view.dom.removeEventListener("mousedown", this.onMouseDown); + } + }, +); diff --git a/src/editor/core/cm/ideaParity.test.ts b/src/editor/core/cm/ideaParity.test.ts new file mode 100644 index 00000000..52fca803 --- /dev/null +++ b/src/editor/core/cm/ideaParity.test.ts @@ -0,0 +1,331 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { EditorSelection, EditorState, type Extension } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { codeFolding, foldEffect, foldService, foldedRanges, indentService, indentUnit } from "@codemirror/language"; +import { languages } from "@codemirror/language-data"; +import { + deleteLine, + deleteWordBackward, + duplicateLineOrSelection, + moveStatementDown, + moveStatementUp, + nextOccurrence, + occurrenceSearch, + selectAllOccurrences, + unselectOccurrence, +} from "./editing"; +import { pasteReindentFilter } from "./prefs"; +import { closesStringAt, skipClosingQuote } from "./smartKeys"; +import { + collapseAllEffects, + collapseRecursivelyEffects, + expandRecursivelyEffects, + foldSelectionEffects, +} from "./folding"; +import { columnModeExtension, extendBlock, toggleColumnMode } from "./columnMode"; +import { nearestMark, unfoldAround } from "./lanes"; +import { lineRangeSelection } from "./gutterDrag"; +import { matchBraceTarget } from "./caretCommands"; +import { overwriteField, toggleOverwrite } from "./overwrite"; + +const views: EditorView[] = []; +afterEach(() => views.splice(0).forEach((v) => v.destroy())); + +/** `|` marks carets, `[`/`]` a selection. */ +function make(text: string, extensions: Extension = []) { + const ranges: ReturnType[] = []; + let doc = ""; + let anchor = -1; + for (const ch of text) { + if (ch === "|") ranges.push(EditorSelection.cursor(doc.length)); + else if (ch === "[") anchor = doc.length; + else if (ch === "]") ranges.push(EditorSelection.range(anchor, doc.length)); + else doc += ch; + } + const view = new EditorView({ + state: EditorState.create({ + doc, + selection: EditorSelection.create(ranges.length ? ranges : [EditorSelection.cursor(0)]), + extensions: [EditorState.allowMultipleSelections.of(true), indentUnit.of(" "), occurrenceSearch, extensions], + }), + parent: document.body, + }); + views.push(view); + return view; +} + +function show(view: EditorView): string { + const { doc, selection } = view.state; + let out = doc.toString(); + for (const r of [...selection.ranges].sort((a, b) => b.from - a.from)) + out = r.empty + ? out.slice(0, r.head) + "|" + out.slice(r.head) + : out.slice(0, r.from) + "[" + out.slice(r.from, r.to) + "]" + out.slice(r.to); + return out; +} + +const javascript = await languages.find((l) => l.name === "JavaScript")!.load(); + +describe("Move Statement", () => { + it("moves a multi-line statement as a whole, never one of its lines", () => { + const v = make("function f() {\n a();\n |foo(\n 1,\n 2);\n}", javascript); + moveStatementUp(v); + expect(v.state.doc.toString()).toBe("function f() {\n foo(\n 1,\n 2);\n a();\n}"); + moveStatementDown(v); + expect(v.state.doc.toString()).toBe("function f() {\n a();\n foo(\n 1,\n 2);\n}"); + }); + + it("moves into a block past its header and out of it past its closing brace, re-indented", () => { + const v = make("function f() {\n |a();\n if (x) {\n b();\n }\n}", javascript); + moveStatementDown(v); + expect(show(v)).toBe("function f() {\n if (x) {\n |a();\n b();\n }\n}"); + moveStatementDown(v); + moveStatementDown(v); + expect(show(v)).toBe("function f() {\n if (x) {\n b();\n }\n |a();\n}"); + }); + + it("never carries a statement out of its function", () => { + const v = make("function f() {\n |a();\n}\nb();", javascript); + moveStatementUp(v); + expect(v.state.doc.toString()).toBe("function f() {\n a();\n}\nb();"); + moveStatementDown(v); + expect(v.state.doc.toString()).toBe("function f() {\n a();\n}\nb();"); + }); + + it("passes a neighbouring function as a whole", () => { + const v = make("|a();\nfunction f() {\n b();\n}", javascript); + moveStatementDown(v); + expect(v.state.doc.toString()).toBe("function f() {\n b();\n}\na();"); + }); +}); + +describe("occurrences", () => { + it("⌃G from a caret selects the word, then whole-word matches after the last added one", () => { + const v = make("fo|o foobar foo foo"); + expect(nextOccurrence(v)).toBe("selected"); + expect(show(v)).toBe("[foo] foobar foo foo"); + expect(nextOccurrence(v)).toBe("added"); + expect(show(v)).toBe("[foo] foobar [foo] foo"); + }); + + it("matches inside words for a selection made by hand", () => { + const v = make("[foo] foobar foo"); + nextOccurrence(v); + expect(show(v)).toBe("[foo] [foo]bar foo"); + }); + + it("reports nothing found once, then searches from the top", () => { + const v = make("foo x [foo]"); + expect(nextOccurrence(v)).toBe("notFound"); + expect(nextOccurrence(v)).toBe("added"); + expect(show(v)).toBe("[foo] x [foo]"); + }); + + it("⌃⌘G from a caret selects whole-word occurrences only", () => { + const v = make("a|b abc ab"); + selectAllOccurrences(v); + expect(show(v)).toBe("[ab] abc [ab]"); + }); + + it("⌃⇧G keeps the whole-word run going", () => { + const v = make("|ab abc ab ab"); + nextOccurrence(v); + nextOccurrence(v); + nextOccurrence(v); + unselectOccurrence(v); + nextOccurrence(v); + expect(show(v)).toBe("[ab] abc [ab] [ab]"); + }); +}); + +describe("folded lines", () => { + const braces = foldService.of((state, from, to) => { + const line = state.doc.lineAt(from); + if (!line.text.endsWith("{")) return null; + const close = state.doc.toString().indexOf("}", to); + return close > to ? { from: to, to: close } : null; + }); + const folded = () => { + const v = make("x\nf() {\n a;\n}\ny", [braces, codeFolding()]); + v.dispatch({ effects: foldEffect.of({ from: 7, to: 13 }), selection: { anchor: 3 } }); + return v; + }; + + it("⌘D on a folded region's line duplicates the whole folded block", () => { + const v = folded(); + duplicateLineOrSelection(v); + expect(v.state.doc.toString()).toBe("x\nf() {\n a;\n}\nf() {\n a;\n}\ny"); + expect(v.state.selection.main.head).toBe(16); + }); + + it("⌘⌫ on a folded region's line deletes the whole folded block", () => { + const v = folded(); + deleteLine(v); + expect(v.state.doc.toString()).toBe("x\ny"); + }); +}); + +describe("Delete to Word Start", () => { + it("deletes the whitespace before the caret and the word before it", () => { + const v = make("foo bar |"); + deleteWordBackward(v); + expect(show(v)).toBe("foo |"); + const w = make("a.b(|"); + deleteWordBackward(w); + expect(show(w)).toBe("a.b|"); + }); +}); + +describe("paste reformatting", () => { + /** One unit deeper after a line ending in `{`. */ + const braceIndent = indentService.of((cx, pos) => { + const line = cx.state.doc.lineAt(pos); + if (line.number === 1) return 0; + const prev = cx.state.doc.line(line.number - 1); + const base = cx.lineIndent(prev.from); + return prev.text.trimEnd().endsWith("{") ? base + 2 : base; + }); + const paste = (v: EditorView, text: string) => + v.dispatch({ + changes: { from: v.state.selection.main.from, to: v.state.selection.main.to, insert: text }, + userEvent: "input.paste", + }); + + it("indents each pasted line (IDEA's default)", () => { + const v = make("if {\n|\n}", [braceIndent, pasteReindentFilter("indentEachLine")]); + paste(v, "a();\n b();\n c();"); + expect(v.state.doc.toString()).toBe("if {\n a();\n b();\n c();\n}"); + }); + + it("starts a block pasted at a line's indentation at the proper indent", () => { + const v = make("if {\n|\n}", [braceIndent, pasteReindentFilter("indentBlock")]); + paste(v, "a();\n b();\n c();"); + expect(v.state.doc.toString()).toBe("if {\n a();\n b();\n c();\n}"); + }); +}); + +describe("closing quotes", () => { + it("steps over the closing quote of an existing string literal", () => { + const v = make('f("ab|");', [javascript, skipClosingQuote]); + expect(closesStringAt(v.state, v.state.selection.main.head, '"')).toBe(true); + expect(closesStringAt(v.state, 2, '"')).toBe(false); + const handled = v.state.facet(EditorView.inputHandler).some((h) => h(v, 5, 5, '"', () => v.state.update({}))); + expect(handled).toBe(true); + expect(show(v)).toBe('f("ab"|);'); + }); +}); + +describe("folding actions", () => { + const braces = foldService.of((state, from, to) => { + const line = state.doc.lineAt(from); + if (!line.text.trimEnd().endsWith("{")) return null; + let depth = 0; + for (let pos = to - 1; pos < state.doc.length; pos++) { + const ch = state.doc.sliceString(pos, pos + 1); + if (ch === "{") depth++; + else if (ch === "}" && --depth === 0) return { from: to, to: pos }; + } + return null; + }); + const text = "a {\n b {\n c;\n }\n}\nd {\n}\n"; + const state = (sel?: { anchor: number; head?: number }) => + EditorState.create({ doc: text, selection: sel, extensions: [braces, codeFolding()] }); + const foldedOf = (s: EditorState) => { + const out: number[] = []; + foldedRanges(s).between(0, s.doc.length, (a) => void out.push(s.doc.lineAt(a).number)); + return out.sort((a, b) => a - b); + }; + + it("Collapse All folds nested regions too; with a selection only those inside it", () => { + let s = state(); + s = s.update({ effects: collapseAllEffects(s) }).state; + expect(foldedOf(s)).toEqual([1, 2, 6]); + s = state({ anchor: 4, head: 21 }); + s = s.update({ effects: collapseAllEffects(s) }).state; + expect(foldedOf(s)).toEqual([2]); + }); + + it("collapses and expands recursively", () => { + let s = state({ anchor: 1 }); + s = s.update({ effects: collapseRecursivelyEffects(s, 1) }).state; + expect(foldedOf(s)).toEqual([1, 2]); + s = s.update({ effects: expandRecursivelyEffects(s, 1) }).state; + expect(foldedOf(s)).toEqual([]); + }); + + it("⌘. folds a selection without its trailing line break and toggles the region at a caret", () => { + let s = state({ anchor: 4, head: 23 }); + const fold = foldSelectionEffects(s)[0]!; + expect(fold.is(foldEffect) && fold.value).toEqual({ from: 4, to: 22 }); + s = state({ anchor: 12 }); + s = s.update({ effects: foldSelectionEffects(s) }).state; + expect(foldedOf(s)).toEqual([2]); + s = s.update({ effects: foldSelectionEffects(s) }).state; + expect(foldedOf(s)).toEqual([]); + }); +}); + +describe("column selection", () => { + it("keeps the block in visual columns across tabs", () => { + const v = make("\tab\n cd", [columnModeExtension, EditorState.tabSize.of(4)]); + v.dispatch({ selection: { anchor: 1 } }); + toggleColumnMode(v); + extendBlock(v, 1, 0); + extendBlock(v, 0, 1); + expect(v.state.selection.ranges.map((r) => [r.anchor, r.head])).toEqual([ + [1, 2], + [8, 9], + ]); + }); +}); + +describe("error stripe", () => { + it("picks the nearest mark within the tolerance", () => { + const marks = [ + { top: 10, bottom: 12, pos: 1 }, + { top: 30, bottom: 32, pos: 2 }, + ]; + expect(nearestMark(marks, 15)?.pos).toBe(1); + expect(nearestMark(marks, 27)?.pos).toBe(2); + expect(nearestMark(marks, 21)).toBeNull(); + }); + + it("unfolds what hides the target line", () => { + const s = EditorState.create({ doc: "f {\n x\n}", extensions: [codeFolding()] }).update({ + effects: foldEffect.of({ from: 3, to: 8 }), + }).state; + expect(unfoldAround(s, 0)).toHaveLength(1); + }); +}); + +describe("gutter drag", () => { + it("selects whole lines in either direction", () => { + const s = EditorState.create({ doc: "a\nb\nc\nd" }); + expect(lineRangeSelection(s, 2, 3).main).toMatchObject({ anchor: 2, head: 6 }); + expect(lineRangeSelection(s, 3, 1).main).toMatchObject({ anchor: 6, head: 0 }); + expect(lineRangeSelection(s, 4, 4).main).toMatchObject({ anchor: 6, head: 7 }); + }); +}); + +describe("Move Caret to Matching Brace", () => { + it("jumps between braces, else to the unclosed opening brace", () => { + const s = EditorState.create({ doc: "f(a, [b], c)" }); + expect(matchBraceTarget(s, 1)).toBe(12); + expect(matchBraceTarget(s, 12)).toBe(1); + expect(matchBraceTarget(s, 3)).toBe(1); + }); +}); + +describe("overwrite mode", () => { + it("replaces the characters after the caret, never the line break", () => { + const v = make("ab|cd\nx", overwriteField); + toggleOverwrite(v); + expect(v.dom.classList.contains("cm-openidea-blockCaret")).toBe(true); + const type = (text: string) => + v.state + .facet(EditorView.inputHandler) + .some((h) => h(v, v.state.selection.main.from, v.state.selection.main.to, text, () => v.state.update({}))); + type("XYZ"); + expect(show(v)).toBe("abXYZ|\nx"); + }); +}); diff --git a/src/editor/core/cm/lanes.ts b/src/editor/core/cm/lanes.ts index 33c47a33..d02530c9 100644 --- a/src/editor/core/cm/lanes.ts +++ b/src/editor/core/cm/lanes.ts @@ -2,7 +2,8 @@ * The 3px change lane next to the line numbers (`gutterLaneMarkers`) and the * right-hand overview lane over the scrollbar (`scrollbarMarks`). */ -import { type Extension } from "@codemirror/state"; +import { EditorSelection, type EditorState, type Extension, type StateEffect } from "@codemirror/state"; +import { foldedRanges, unfoldEffect } from "@codemirror/language"; import { EditorView, ViewPlugin, gutter, type ViewUpdate } from "@codemirror/view"; import { gutterLaneMarkers, scrollbarMarks, type ScrollbarMark } from "@/editor/api"; @@ -13,6 +14,29 @@ export const changeLane: Extension = gutter({ const MAX_RENDERED = 4000; +/** How far (px) a click may miss a mark on the overview lane. */ +const MARK_TOLERANCE = 6; + +/** The mark nearest to `y` (by its top) among those within the tolerance. */ +export function nearestMark(marks: readonly M[], y: number): M | null { + let best: M | null = null; + for (const m of marks) { + if (y < m.top - MARK_TOLERANCE || y > m.bottom + MARK_TOLERANCE) continue; + if (!best || Math.abs(m.top - y) < Math.abs(best.top - y)) best = m; + } + return best; +} + +/** Unfolds the folds hiding `pos` or the rest of its line (navigating to a mark shows it). */ +export function unfoldAround(state: EditorState, pos: number): StateEffect[] { + const lineEnd = state.doc.lineAt(pos).to; + const out: StateEffect[] = []; + foldedRanges(state).between(pos, lineEnd, (from, to) => { + if (from <= lineEnd && to > pos) out.push(unfoldEffect.of({ from, to })); + }); + return out; +} + class OverviewLane { dom: HTMLElement; private scheduled = false; @@ -101,13 +125,19 @@ class OverviewLane { } private onMouseDown = (e: MouseEvent) => { - const el = (e.target as HTMLElement).closest(".cm-openidea-overviewMark"); - if (!el?.dataset.pos) return; + if (e.button !== 0) return; + const marks = [...this.dom.querySelectorAll(".cm-openidea-overviewMark")].map((el) => { + const r = el.getBoundingClientRect(); + return { top: r.top, bottom: r.bottom, pos: Number(el.dataset.pos) }; + }); + const hit = nearestMark(marks, e.clientY); + if (!hit) return; e.preventDefault(); - const pos = Math.min(Number(el.dataset.pos), this.view.state.doc.length); + const state = this.view.state; + const pos = Math.min(hit.pos, state.doc.length); this.view.dispatch({ - selection: { anchor: pos }, - effects: EditorView.scrollIntoView(pos, { y: "center" }), + selection: EditorSelection.cursor(pos), + effects: [...unfoldAround(state, pos), EditorView.scrollIntoView(pos, { y: "center" })], userEvent: "select.overview", }); this.view.focus(); diff --git a/src/editor/core/cm/languages.ts b/src/editor/core/cm/languages.ts index 38b85220..68f7750c 100644 --- a/src/editor/core/cm/languages.ts +++ b/src/editor/core/cm/languages.ts @@ -35,6 +35,21 @@ export function _resetLanguages() { infosPromise = null; } +/** + * `uiMode` of the backend language for a file name (longest extension first, + * like the backend's detection); `undefined` when the languages are not loaded + * or none matches. + */ +export function uiModeForPath(path: string): string | null | undefined { + if (!infos) return undefined; + let rest = path.slice(path.lastIndexOf("/") + 1).toLowerCase(); + for (let dot = rest.indexOf("."); dot >= 0; dot = rest.indexOf(".")) { + rest = rest.slice(dot + 1); + for (const info of infos.values()) if (info.extensions.includes(rest)) return info.uiMode; + } + return undefined; +} + const supports = new Map>(); export function findLanguageDescription(uiMode: string | null, path: string): LanguageDescription | null { diff --git a/src/editor/core/cm/overwrite.ts b/src/editor/core/cm/overwrite.ts new file mode 100644 index 00000000..f4f6e25f --- /dev/null +++ b/src/editor/core/cm/overwrite.ts @@ -0,0 +1,43 @@ +/** + * Overwrite mode (Insert key, as in IDEA): typed characters replace the ones + * after the caret (never the line break) and the caret is drawn as a block. + */ +import { EditorSelection, Prec, StateEffect, StateField, type EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; + +export const setOverwrite = StateEffect.define(); + +export function isOverwrite(state: EditorState): boolean { + return state.field(overwriteField, false) ?? false; +} + +export function toggleOverwrite(view: EditorView): boolean { + view.dispatch({ effects: setOverwrite.of(!isOverwrite(view.state)) }); + return true; +} + +const overwriteInput = EditorView.inputHandler.of((view, _from, _to, text) => { + const { state } = view; + if (!isOverwrite(state) || state.readOnly || /[\r\n]/.test(text)) return false; + const tr = state.changeByRange((r) => { + let to = r.to; + if (r.empty) { + const line = state.doc.lineAt(r.head); + to = Math.min(line.to, r.head + text.length); + } + return { changes: { from: r.from, to, insert: text }, range: EditorSelection.cursor(r.from + text.length) }; + }); + view.dispatch(state.update(tr, { scrollIntoView: true, userEvent: "input.type" })); + return true; +}); + +export const overwriteField = StateField.define({ + create: () => false, + update: (on, tr) => tr.effects.reduce((acc, e) => (e.is(setOverwrite) ? e.value : acc), on), + provide: (f) => [ + Prec.highest(overwriteInput), + EditorView.editorAttributes.compute([f], (s): Record => + s.field(f) ? { class: "cm-openidea-blockCaret" } : {}, + ), + ], +}); diff --git a/src/editor/core/cm/prefs.ts b/src/editor/core/cm/prefs.ts index 4da4372c..d39d133c 100644 --- a/src/editor/core/cm/prefs.ts +++ b/src/editor/core/cm/prefs.ts @@ -3,7 +3,7 @@ * Keys, Code Editing, Code Style). Everything here is rebuilt into the * `prefs` compartment when settings change. */ -import { EditorSelection, EditorState, Prec, RangeSetBuilder, StateEffect, StateField, type Extension, type Transaction } from "@codemirror/state"; +import { EditorSelection, EditorState, Prec, RangeSetBuilder, StateEffect, StateField, type Extension, type Transaction, type TransactionSpec } from "@codemirror/state"; import { Decoration, EditorView, @@ -16,7 +16,7 @@ import { type ViewUpdate, } from "@codemirror/view"; import { highlightTree } from "@lezer/highlight"; -import { getIndentUnit, indentUnit, syntaxTree } from "@codemirror/language"; +import { getIndentUnit, getIndentation, indentRange, indentString, indentUnit, syntaxTree } from "@codemirror/language"; import { cursorLineStart, deleteCharBackwardStrict, @@ -25,9 +25,17 @@ import { } from "@codemirror/commands"; import type { EditorExtras } from "@/platform/editorSettings"; import { openideaHighlightStyle } from "./highlight"; -import { cursorHumpLeft, cursorHumpRight, deleteHumpBackward, deleteHumpForward, selectHumpLeft, selectHumpRight } from "./editing"; +import { + cursorHumpLeft, + cursorHumpRight, + deleteHumpBackward, + deleteHumpForward, + deleteWordBackward, + selectHumpLeft, + selectHumpRight, +} from "./editing"; import { enterInLineComment, lineCommentStyle, type LineCommentStyle } from "./comments"; -import { smartBackspace, smartBackspaceApplies, tabOut } from "./smartKeys"; +import { skipClosingQuote, smartBackspace, smartBackspaceApplies, tabOut } from "./smartKeys"; // --- soft wraps --------------------------------------------------------------- @@ -207,25 +215,60 @@ export function indentPastedText(text: string, targetCol: number, unit: string, .join("\n"); } -const pasteFilter = EditorState.transactionFilter.of((tr: Transaction) => { - if (!tr.isUserEvent("input.paste") || !tr.docChanged) return tr; - const start = tr.startState; - const changes: Array<{ from: number; to: number; text: string }> = []; - tr.changes.iterChanges((from, to, _fb, _tb, inserted) => changes.push({ from, to, text: inserted.toString() })); - if (changes.length !== 1) return tr; - const { from, to, text } = changes[0]!; - const line = start.doc.lineAt(from); - const before = start.doc.sliceString(line.from, from); - const targetCol = leadingColumns(/^\s*$/.test(before) ? before : line.text, start.tabSize); - const out = indentPastedText(text, targetCol, start.facet(indentUnit)[0] === "\t" ? "\t" : " ", start.tabSize, getIndentUnit(start) || start.tabSize); - if (out === text) return tr; - return { - changes: { from, to, insert: out }, - selection: EditorSelection.cursor(from + out.length), - userEvent: "input.paste", - scrollIntoView: true, - }; -}); +/** + * Reformat on paste. `indentBlock` shifts the pasted block to the paste + * column; `indentEachLine` (IDEA's default) then gives each pasted line the + * language's indentation, where the language can compute one. A paste + * after nothing but whitespace starts at the line's proper indentation. + */ +export function pasteReindentFilter(mode: "indentBlock" | "indentEachLine") { + return EditorState.transactionFilter.of((tr: Transaction) => { + if (!tr.isUserEvent("input.paste") || !tr.docChanged) return tr; + const start = tr.startState; + const changes: Array<{ from: number; to: number; text: string }> = []; + tr.changes.iterChanges((from, to, _fb, _tb, inserted) => changes.push({ from, to, text: inserted.toString() })); + if (changes.length !== 1) return tr; + let { from } = changes[0]!; + const { to, text } = changes[0]!; + if (!text.trim()) return tr; + const line = start.doc.lineAt(from); + const before = start.doc.sliceString(line.from, from); + const atIndent = /^\s*$/.test(before); + const tabSize = start.tabSize; + const unitCols = getIndentUnit(start) || tabSize; + const useTabs = start.facet(indentUnit)[0] === "\t"; + let targetCol = leadingColumns(atIndent ? before : line.text, tabSize); + let prefix = ""; + if (atIndent) { + const proper = getIndentation(start, line.from); + if (proper !== null && proper !== targetCol) { + targetCol = proper; + prefix = indentString(start, proper); + from = line.from; + } + } + const block = indentPastedText(text, targetCol, useTabs ? "\t" : " ", tabSize, unitCols); + const out = prefix + (prefix ? block.trimStart() : block); + const first: TransactionSpec = { + changes: { from, to, insert: out }, + selection: EditorSelection.cursor(from + out.length), + userEvent: "input.paste", + scrollIntoView: true, + }; + if (mode !== "indentEachLine") return out === text && from === changes[0]!.from ? tr : first; + // Lines whose start lies in the pasted text, plus the paste line when it was pasted at its indentation. + const pasted = start.update({ ...first, filter: false }).state; + const end = from + out.length; + const firstLine = pasted.doc.lineAt(from); + const lineStart = atIndent ? firstLine.from : firstLine.to + 1; + const lastLine = pasted.doc.lineAt(end); + const lineEnd = lastLine.from === end && end > from ? end - 1 : end; + if (lineStart > lineEnd) return first; + const reindent = indentRange(pasted, lineStart, lineEnd); + if (reindent.empty) return first; + return [first, { changes: reindent, selection: EditorSelection.cursor(reindent.mapPos(end, 1)), sequential: true }]; + }); +} // --- rich-text copy ----------------------------------------------------------- @@ -397,12 +440,13 @@ export function prefsExtension(o: PrefsOptions): Extension { if (!g.dragAndDrop) out.push(noTextDrag); if (g.caretScroll === "center") out.push(centerCaret); if (g.richTextCopy) out.push(richCopy); - if (!o.readOnly && k.reformatOnPaste === "indentBlock") out.push(pasteFilter); + if (!o.readOnly && (k.reformatOnPaste === "indentBlock" || k.reformatOnPaste === "indentEachLine")) out.push(pasteReindentFilter(k.reformatOnPaste)); // Pairing: closeBrackets() reads the brackets list from language data; ours comes first. const brackets = [...(k.pairBrackets ? ["(", "[", "{"] : []), ...(k.pairQuotes ? ["'", '"'] : [])]; out.push(Prec.highest(EditorState.languageData.of(() => [{ closeBrackets: { brackets } }]))); if (!k.surroundSelection) out.push(Prec.highest(noSurround)); + if (k.pairQuotes) out.push(Prec.highest(skipClosingQuote)); if (k.tabOutOfPair) out.push(tabOut); if (o.commentStyle) out.push(lineCommentStyle.of(o.commentStyle)); @@ -411,9 +455,10 @@ export function prefsExtension(o: PrefsOptions): Extension { keys.push( { key: "Alt-ArrowLeft", run: cursorHumpLeft, shift: selectHumpLeft, preventDefault: true }, { key: "Alt-ArrowRight", run: cursorHumpRight, shift: selectHumpRight, preventDefault: true }, - { key: "Alt-Backspace", run: deleteHumpBackward }, { key: "Alt-Delete", run: deleteHumpForward }, ); + // Delete to Word Start: the whitespace before the caret and the word before it. + keys.push({ key: "Mod-Backspace", mac: "Alt-Backspace", run: k.camelHumps ? deleteHumpBackward : deleteWordBackward }); if (!k.smartHome) keys.push( { key: "Home", run: cursorLineStart, shift: selectLineStart, preventDefault: true }, diff --git a/src/editor/core/cm/preview.ts b/src/editor/core/cm/preview.ts new file mode 100644 index 00000000..0d20fd5b --- /dev/null +++ b/src/editor/core/cm/preview.ts @@ -0,0 +1,31 @@ +/** + * Read-only code previews outside editor tabs (Find in Files, bookmarks): the + * editor's font, colours, line numbers and syntax highlighting, without + * editing, folding or contributions. + */ +import { EditorState, type Extension } from "@codemirror/state"; +import { EditorView, highlightSpecialChars, lineNumbers } from "@codemirror/view"; +import { syntaxHighlighting } from "@codemirror/language"; +import { editorsFor } from "@/editor/api"; +import type { EditorSettings } from "@/platform/ipc"; +import { openideaHighlightStyle } from "./highlight"; +import { openideaTheme } from "./theme"; +import { findLanguageDescription, loadSupport, loadedSupport, uiModeForPath } from "./languages"; +import { fontExtension } from "./setup"; + +export async function previewExtensions(path: string, settings: EditorSettings | undefined): Promise { + const open = editorsFor(path)[0]?.ctx; + const desc = findLanguageDescription(open ? open.uiMode : (uiModeForPath(path) ?? null), path); + const support = desc ? (loadedSupport(desc) ?? (await loadSupport(desc))) : null; + return [ + fontExtension(settings), + openideaTheme, + lineNumbers(), + highlightSpecialChars(), + syntaxHighlighting(openideaHighlightStyle), + EditorState.readOnly.of(true), + EditorView.editable.of(false), + EditorView.contentAttributes.of({ "aria-label": `Preview of ${path}` }), + support ?? [], + ]; +} diff --git a/src/editor/core/cm/setup.ts b/src/editor/core/cm/setup.ts index 3f60ebac..ed2c43fc 100644 --- a/src/editor/core/cm/setup.ts +++ b/src/editor/core/cm/setup.ts @@ -44,6 +44,9 @@ import { multiCaretMouse } from "./multiCaret"; import { columnModeExtension } from "./columnMode"; import { tabKeymap } from "./smartKeys"; import { editorBaseKeymap } from "./baseKeymap"; +import { occurrenceSearch } from "./editing"; +import { gutterLineDrag } from "./gutterDrag"; +import { overwriteField } from "./overwrite"; import { clipboardHistoryExtension } from "../clipboardHistory"; import type { LanguageInfo } from "../backend"; @@ -290,6 +293,9 @@ export function baseExtensions(o: SetupOptions): Extension { EditorState.allowMultipleSelections.of(true), multiCaretMouse, columnModeExtension, + occurrenceSearch, + overwriteField, + gutterLineDrag, clipboardHistoryExtension, indentOnInput(), syntaxHighlighting(openideaHighlightStyle), diff --git a/src/editor/core/cm/smartKeys.ts b/src/editor/core/cm/smartKeys.ts index 870865c9..fd0caaac 100644 --- a/src/editor/core/cm/smartKeys.ts +++ b/src/editor/core/cm/smartKeys.ts @@ -3,8 +3,8 @@ * quotes. */ import { EditorSelection, StateEffect, countColumn, StateField, type EditorState, type Extension, type Transaction } from "@codemirror/state"; -import { keymap, type Command } from "@codemirror/view"; -import { getIndentUnit, getIndentation, indentString, indentUnit } from "@codemirror/language"; +import { EditorView, keymap, type Command } from "@codemirror/view"; +import { getIndentUnit, getIndentation, indentString, indentUnit, syntaxTree } from "@codemirror/language"; import { indentMore } from "@codemirror/commands"; // --- Tab ------------------------------------------------------------------------------- @@ -142,3 +142,32 @@ export const tabOutOfScope: Command = (view) => { /** Settings › Smart Keys › "Jump outside closing bracket/quote with Tab" (Tab itself is `insertTab`). */ export const tabOut: Extension = tabOutScopes; + +// --- closing quote --------------------------------------------------------------------------- + +/** `quote` at `pos` closes the string literal the caret is in (typed or not, the pair's end). */ +export function closesStringAt(state: EditorState, pos: number, quote: string): boolean { + if (state.sliceDoc(pos, pos + 1) !== quote) return false; + for (let n: ReturnType["resolveInner"]> | null = syntaxTree(state).resolveInner(pos, 1); n; n = n.parent) { + if (n.to > pos + 1) break; + if (n.to === pos + 1 && n.from < pos && /String|Char|Template|Rune/.test(n.type.name)) return true; + } + return false; +} + +/** Typing a quote right before the closing quote of a string literal steps over it, as in IDEA. */ +export const skipClosingQuote = EditorView.inputHandler.of((view, _from, _to, text) => { + if (!/^["'`]$/.test(text) || view.state.readOnly) return false; + const { state } = view; + const sel = state.selection; + if (!sel.ranges.every((r) => r.empty && closesStringAt(state, r.head, text))) return false; + view.dispatch({ + selection: EditorSelection.create( + sel.ranges.map((r) => EditorSelection.cursor(r.head + 1)), + sel.mainIndex, + ), + scrollIntoView: true, + userEvent: "input.type", + }); + return true; +}); diff --git a/src/editor/core/cm/stickyScroll.ts b/src/editor/core/cm/stickyScroll.ts index b1a46bc4..332f736b 100644 --- a/src/editor/core/cm/stickyScroll.ts +++ b/src/editor/core/cm/stickyScroll.ts @@ -2,9 +2,9 @@ * Sticky scroll: the header lines of the scopes enclosing the first visible * line stay pinned at the top (IDEA 2023+ / VS Code). Scopes come from the * backend folding ranges, or from the syntax tree when there are none. - * Clicking a sticky line scrolls to it. + * Clicking a sticky line scrolls to it, the caret where it was clicked. */ -import { Compartment, Facet, type EditorState, type Extension } from "@codemirror/state"; +import { Compartment, EditorSelection, Facet, type EditorState, type Extension } from "@codemirror/state"; import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view"; import { foldNodeProp, syntaxTree } from "@codemirror/language"; import { highlightTree } from "@lezer/highlight"; @@ -80,6 +80,23 @@ function renderLineContent(state: EditorState, lineNo: number, target: HTMLEleme push(end, ""); } +/** Character offset in a rendered sticky line closest to `clientX`. */ +export function offsetAtX(inner: HTMLElement, clientX: number): number { + const range = document.createRange(); + let offset = 0; + const walker = document.createTreeWalker(inner, NodeFilter.SHOW_TEXT); + for (let node = walker.nextNode() as Text | null; node; node = walker.nextNode() as Text | null) { + for (let i = 0; i < node.length; i++) { + range.setStart(node, i); + range.setEnd(node, i + 1); + const r = range.getBoundingClientRect(); + if (clientX < r.left + r.width / 2) return offset + i; + } + offset += node.length; + } + return offset; +} + class StickyScroll { dom: HTMLElement; private lines: number[] = []; @@ -203,14 +220,21 @@ class StickyScroll { } private onMouseDown = (e: MouseEvent) => { + if (e.button !== 0) return; const row = (e.target as HTMLElement).closest(".cm-openidea-stickyLine"); if (!row?.dataset.line) return; e.preventDefault(); - const line = this.view.state.doc.line(Math.min(Number(row.dataset.line), this.view.state.doc.lines)); - const indent = /^\s*/.exec(line.text)![0].length; + // The line number of a sticky line is not a link (IDEA). + const inner = (e.target as HTMLElement).closest(".cm-openidea-stickyText")?.querySelector(".cm-openidea-stickyInner"); + if (!inner) return; + const state = this.view.state; + const line = state.doc.line(Math.min(Number(row.dataset.line), state.doc.lines)); + const offset = offsetAtX(inner, e.clientX); + const rowIndex = [...this.dom.children].indexOf(row); + // The line takes the place of its sticky row, so the text stays under the pointer. this.view.dispatch({ - selection: { anchor: line.from + indent }, - effects: EditorView.scrollIntoView(line.from, { y: "start" }), + selection: EditorSelection.cursor(line.from + Math.min(offset, line.length)), + effects: EditorView.scrollIntoView(line.from, { y: "start", yMargin: Math.max(0, rowIndex) * this.view.defaultLineHeight }), userEvent: "select.sticky", }); this.view.focus(); diff --git a/src/editor/core/controller.test.ts b/src/editor/core/controller.test.ts index 7e2e6799..33e7caae 100644 --- a/src/editor/core/controller.test.ts +++ b/src/editor/core/controller.test.ts @@ -65,6 +65,18 @@ describe("EditorController", () => { expect(openFilePaths(useTabs.getState())).toEqual(["/p/a.ts", "/p/b.ts"]); }); + it("closes a tab by its input key", async () => { + ctrl = new EditorController(createMemoryBackend()); + ctrl.start(); + await ctrl.openFile("/p/a.ts", { focus: false }); + await ctrl.openFile("/p/b.ts", { focus: false }); + const key = Object.values(useTabs.getState().panes) + .flatMap((p) => p.tabs) + .find((t) => t.input.path === "/p/b.ts")!.input.key; + ctrl.closeInput(key); + expect(openFilePaths(useTabs.getState())).toEqual(["/p/a.ts"]); + }); + it("builds IDEA's editor tab menu", async () => { useProject.setState({ project: { name: "p", root: "/p", isGit: true, settings: {} } as never, settings: {} as never }); const ids = ["navigate.selectIn", "project.revealInFinder", "vcs.localHistory", "vcs.commit", "project.copyAbsolutePath", "project.rename", "file.settings"]; diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index 60b1a1f6..ea49d27b 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -1003,6 +1003,10 @@ export class EditorController implements SlotHost { }); } + closeInput(key: string) { + this.apply((s) => closeKey(s, key)); + } + activePath(): string | null { return this.activeSlot?.model.path ?? this.activeTabOf()?.input.path ?? null; } diff --git a/src/editor/core/index.ts b/src/editor/core/index.ts index 9796bcd6..85dc3479 100644 --- a/src/editor/core/index.ts +++ b/src/editor/core/index.ts @@ -9,9 +9,10 @@ import { registerOverlay, registerStatusItem, } from "@/workbench/contributions"; -import { _provideDocumentAttacher } from "@/editor/api"; +import { _provideCodePreview, _provideDocumentAttacher } from "@/editor/api"; import { editorController } from "./controller"; import { attachDocument } from "./model/attachment"; +import { previewExtensions } from "./cm/preview"; import { registerEditorActions } from "./actions"; import { installColorScheme } from "./colorSchemeRuntime"; import { EditorAreaView } from "./view/EditorArea"; @@ -39,8 +40,10 @@ export function activate(): void | (() => void) { focus: () => ctrl.focus(), openPaths: () => ctrl.openPaths(), closePath: (path) => ctrl.closePath(path), + closeInput: (key) => ctrl.closeInput(key), }); _provideDocumentAttacher((path) => attachDocument(ctrl.manager, path)); + _provideCodePreview((path) => previewExtensions(path, ctrl.manager.settings()?.editor)); const disposers = [ installColorScheme(), provideEditorArea(EditorAreaView), @@ -94,6 +97,7 @@ export function activate(): void | (() => void) { return () => { disposers.reverse().forEach((d) => d()); _provideDocumentAttacher(null); + _provideCodePreview(null); ctrl.stop(); }; } diff --git a/src/editor/core/model/camelHumps.ts b/src/editor/core/model/camelHumps.ts index d38349a0..a1b4e0c4 100644 --- a/src/editor/core/model/camelHumps.ts +++ b/src/editor/core/model/camelHumps.ts @@ -53,3 +53,16 @@ export function humpLeft(text: string, pos: number): number { while (i > 0 && !isHumpBoundary(text, i)) i--; return i; } + +/** + * Word start to the left of `pos` within a line without CamelHumps: the + * whitespace before `pos`, then a run of word characters or of punctuation. + */ +export function wordStartLeft(text: string, pos: number): number { + let i = pos; + while (i > 0 && kindOf(text[i - 1]) === "space") i--; + if (i <= 0) return 0; + const punct = kindOf(text[i - 1]) === "punct"; + while (i > 0 && kindOf(text[i - 1]) !== "space" && (kindOf(text[i - 1]) === "punct") === punct) i--; + return i; +} diff --git a/src/editor/lsp/Popups.module.css b/src/editor/lsp/Popups.module.css index 433e9b40..ca8cf7c3 100644 --- a/src/editor/lsp/Popups.module.css +++ b/src/editor/lsp/Popups.module.css @@ -28,6 +28,60 @@ font-family: var(--font-mono); color: var(--accent); } +.withPreview { + flex-direction: row; + width: auto; + max-width: min(1160px, 94vw); +} +.listMain { + display: flex; + flex-direction: column; + min-width: 0; + flex: none; +} +.mnemonic { + width: 10px; + flex: none; + font-family: var(--font-keys); + font-size: var(--fs-small); + color: var(--fg-subtle); + text-align: right; +} +.previewPanel { + width: 420px; + flex: none; + min-height: 60px; + max-height: 340px; + overflow: auto; + border-left: 1px solid var(--border-popup); + background: var(--bg-popup-inline, var(--bg-elev)); +} +.previewText { + padding: 8px 10px; + font-size: var(--fs-small); + color: var(--fg-subtle); +} +.diff { + margin: 0; + padding: 6px 0; + font-family: var(--openidea-editor-font, var(--font-mono)); + font-size: var(--fs-small); + color: var(--fg-editor); +} +.diffLine { + padding: 0 10px; + white-space: pre; + min-height: 1.4em; +} +.diff-added { + background: color-mix(in srgb, var(--vcs-added) 20%, transparent); +} +.diff-removed { + background: color-mix(in srgb, var(--danger) 18%, transparent); +} +.diff-gap { + color: var(--fg-faint); +} .listBody { display: flex; flex-direction: column; @@ -54,11 +108,15 @@ color: var(--fg-faint); } .label { - flex: none; - max-width: 60%; + flex: 0 1 auto; + min-width: 0; overflow: hidden; text-overflow: ellipsis; } +.label:has(+ .preview) { + flex: none; + max-width: 60%; +} .preview { min-width: 80px; flex: 0 1 auto; @@ -163,6 +221,34 @@ .docScope { display: contents; } +.docFrame { + outline: none; +} +.docWindowFrame { + height: 100%; + display: flex; + flex-direction: column; + overflow: auto; + outline: none; +} +.docWindowFrame .docBody { + flex: 1; +} +.docWindowEmpty { + padding: 16px; + font-size: var(--fs-ui); + color: var(--fg-faint); +} +.docToolbar { + position: sticky; + bottom: 0; + display: flex; + align-items: center; + gap: 2px; + padding: 2px 6px; + border-top: 1px solid var(--border-popup); + background: var(--bg-popup); +} .docBody { padding: 10px 14px; } @@ -195,6 +281,17 @@ white-space: nowrap; color: var(--fg-subtle); } +.defFrame { + display: flex; + flex-direction: column; + min-height: 0; + outline: none; +} +.defCount { + flex: none; + font-size: var(--fs-small); + color: var(--fg-subtle); +} .defBody { overflow: auto; padding: 4px 12px; @@ -283,3 +380,24 @@ overflow: hidden; text-overflow: ellipsis; } + +.reformatGroup { + margin: 0 0 10px; + padding: 0; + border: 0; + display: flex; + flex-direction: column; + gap: 6px; +} +.reformatLegend { + margin-bottom: 6px; + padding: 0; + font-size: var(--fs-small); + color: var(--fg-subtle); +} +.reformatOption { + display: flex; + align-items: center; + gap: 6px; + font-size: var(--fs-ui); +} diff --git a/src/editor/lsp/Popups.test.tsx b/src/editor/lsp/Popups.test.tsx new file mode 100644 index 00000000..98c3aebf --- /dev/null +++ b/src/editor/lsp/Popups.test.tsx @@ -0,0 +1,187 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import type { Location } from "@/bindings"; + +const openFile = vi.fn(async () => undefined); +const show = vi.fn(); +vi.mock("@/workbench/contributions", async (orig) => { + const actual = await orig(); + return { + ...actual, + editorService: () => ({ openFile }), + maybeEditorService: () => ({ openFile }), + toolWindowService: () => ({ show, hide: vi.fn(), toggle: vi.fn(), isVisible: () => false }), + }; +}); + +const { LspPopups, DocumentationWindow } = await import("./Popups"); +const { closePopup, openPopup, useLspPopup } = await import("./popupStore"); +const { useDocWindow, pushHistory, stepHistory, hoverCandidates } = await import("./docs"); + +const loc = (path: string, line: number): Location => ({ + path, + range: { start: { line, col: 4 }, end: { line, col: 7 } }, +}); + +afterEach(() => { + act(() => closePopup()); + vi.clearAllMocks(); +}); + +describe("list popup", () => { + it("numbers rows 1-9 and runs a row by its number", () => { + const run = vi.fn(); + openPopup({ + kind: "list", + anchor: { x: 0, y: 0 }, + title: "Refactor This", + numbered: true, + items: [ + { id: "a", label: "Rename…", run: vi.fn() }, + { id: "b", label: "Inline…", run }, + ], + }); + render(); + expect(screen.getAllByRole("option").map((o) => o.textContent)).toEqual(["1Rename…", "2Inline…"]); + fireEvent.keyDown(screen.getByRole("listbox"), { key: "2" }); + expect(run).toHaveBeenCalledOnce(); + expect(useLspPopup.getState().popup).toBeNull(); + }); + + it("reports the selected row and cleans up on close", () => { + const onActive = [vi.fn(), vi.fn()]; + const onClose = vi.fn(); + openPopup({ + kind: "list", + anchor: { x: 0, y: 0 }, + title: "Expressions", + items: onActive.map((fn, i) => ({ id: `${i}`, label: `e${i}`, run: vi.fn(), onActive: fn })), + onClose, + }); + render(); + expect(onActive[0]).toHaveBeenCalledOnce(); + fireEvent.keyDown(screen.getByRole("listbox"), { key: "ArrowDown" }); + expect(onActive[1]).toHaveBeenCalledOnce(); + act(() => closePopup()); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("pins the results elsewhere from the header button", () => { + const pin = vi.fn(); + openPopup({ + kind: "list", + anchor: { x: 0, y: 0 }, + title: "Choose Declaration of foo (2 found)", + items: [{ id: "a", label: "foo", run: vi.fn() }], + pin: { label: "Open in Find Tool Window", run: pin }, + }); + render(); + fireEvent.click(screen.getByRole("button", { name: "Open in Find Tool Window" })); + expect(pin).toHaveBeenCalledOnce(); + expect(useLspPopup.getState().popup).toBeNull(); + }); + + it("shows the selected row's preview, fetched lazily", async () => { + vi.useFakeTimers(); + try { + const preview = vi.fn(async () => ({ kind: "diff" as const, lines: [{ kind: "added" as const, text: "let x = 1;" }] })); + openPopup({ + kind: "list", + anchor: { x: 0, y: 0 }, + title: "Intentions", + items: [{ id: "a", label: "Introduce local variable", run: vi.fn() }], + preview, + }); + render(); + expect(preview).not.toHaveBeenCalled(); + await act(() => vi.advanceTimersByTimeAsync(200)); + expect(preview).toHaveBeenCalledOnce(); + expect(screen.getByLabelText("Preview").textContent).toBe("let x = 1;"); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("documentation", () => { + it("history helpers drop forward entries and clamp steps", () => { + const e = (v: string) => ({ markup: { kind: "plaintext" as const, value: v }, language: null }); + const h = pushHistory({ entries: [e("a"), e("b"), e("c")], index: 0 }, e("d")); + expect(h.entries.map((x) => x.markup.value)).toEqual(["a", "d"]); + expect(h.index).toBe(1); + expect(stepHistory(h, 1)).toBe(1); + expect(stepHistory(h, -1)).toBe(0); + }); + + it("asks about the declared name when a link points at the start of a declaration", () => { + expect(hoverCandidates("export class Foo extends Bar {", 0)).toEqual([0, 13, 25]); + }); + + it("walks the popup history with ← / → and opens the source with F4", async () => { + const source = vi.fn(async () => loc("/p/b.ts", 3)); + openPopup({ + kind: "doc", + anchor: { x: 0, y: 0 }, + entries: [ + { markup: { kind: "plaintext", value: "first" }, language: null }, + { markup: { kind: "plaintext", value: "second" }, language: null, source }, + ], + index: 1, + themeClasses: "", + }); + const { container } = render(); + const frame = () => document.querySelector("[data-lsp-doc]")!; + expect(frame().textContent).toContain("second"); + fireEvent.keyDown(frame(), { key: "ArrowLeft" }); + expect(frame().textContent).toContain("first"); + expect(screen.queryByRole("button", { name: "Edit Source" })).toBeNull(); + fireEvent.keyDown(frame(), { key: "ArrowRight" }); + expect(frame().textContent).toContain("second"); + fireEvent.keyDown(frame(), { key: "F4" }); + await vi.waitFor(() => expect(openFile).toHaveBeenCalledWith("/p/b.ts", { selection: { line: 3, col: 4 }, focus: true })); + expect(container).toBeTruthy(); + }); + + it("moves the popup to the Documentation tool window", () => { + openPopup({ + kind: "doc", + anchor: { x: 0, y: 0 }, + entries: [{ markup: { kind: "plaintext", value: "docs" }, language: null }], + index: 0, + themeClasses: "", + }); + render( + <> + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "Show in Documentation Tool Window" })); + expect(useLspPopup.getState().popup).toBeNull(); + expect(show).toHaveBeenCalledWith("documentation", true); + expect(useDocWindow.getState().entries).toHaveLength(1); + }); +}); + +describe("quick definition popup", () => { + it("switches between several definitions with ← / → and edits the source with F4", () => { + openPopup({ + kind: "definition", + anchor: { x: 0, y: 0 }, + targets: [ + { location: loc("/p/a.ts", 1), name: "run", firstLine: 0, text: "function run() {}", language: null }, + { location: loc("/p/b.ts", 5), name: "run", firstLine: 4, text: "run(): void;", language: null }, + ], + index: 0, + themeClasses: "", + }); + render(); + const frame = () => screen.getByRole("dialog", { name: "Definition" }).firstElementChild as HTMLElement; + expect(screen.getByText("1/2")).toBeTruthy(); + fireEvent.keyDown(frame(), { key: "ArrowRight" }); + expect(screen.getByText("2/2")).toBeTruthy(); + expect(frame().textContent).toContain("run(): void;"); + fireEvent.keyDown(frame(), { key: "F4" }); + expect(openFile).toHaveBeenCalledWith("/p/b.ts", { selection: { line: 5, col: 4 }, focus: true }); + }); +}); diff --git a/src/editor/lsp/Popups.tsx b/src/editor/lsp/Popups.tsx index 4381de67..bc6aa24a 100644 --- a/src/editor/lsp/Popups.tsx +++ b/src/editor/lsp/Popups.tsx @@ -2,13 +2,15 @@ import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; import clsx from "clsx"; import { - Button, + Checkbox, Dialog, HighlightedText, Icon, + IconButton, Input, List, Popup, + Select, SymbolBadge, fileIconName, fuzzyMatch, @@ -16,7 +18,24 @@ import { } from "@/ui"; import { basename, relativePath, useProject } from "@/platform/project"; import { editorService } from "@/workbench/contributions"; -import { closePopup, useLspPopup, type ListPopupItem, type LspPopup } from "./popupStore"; +import { + closePopup, + updatePopup, + useLspPopup, + type ListPopupItem, + type ListPreview, + type LspPopup, +} from "./popupStore"; +import { shortcutLabel } from "@/platform/keymap"; +import { + docLinkHandler, + docStep, + editDocSource, + moveDocPopupToWindow, + useDocWindow, + type DocHistory, + type DocHost, +} from "./docs"; import { renderCode, renderMarkup } from "./markdown"; import { describeSummary } from "./workspaceEdit"; import { useUsagesStatus } from "./navigation"; @@ -33,6 +52,27 @@ function ItemIcon({ item }: { item: ListPopupItem }) { return ; } +function ListPreviewPanel({ preview }: { preview: ListPreview | null | "loading" }) { + if (preview === "loading") return
; + if (!preview) return null; + return ( +
+ {preview.kind === "text" ? ( +
{preview.text}
+ ) : ( +
+          {preview.lines.map((l, i) => (
+            
+ {l.kind === "gap" ? "⋯" : l.text || " "} +
+ ))} + {preview.note &&
{preview.note}
} +
+ )} +
+ ); +} + function ListPopup({ popup }: { popup: Extract }) { const [query, setQuery] = useState(""); const filtered = useMemo(() => { @@ -45,6 +85,30 @@ function ListPopup({ popup }: { popup: Extract }) { ); const [active, setActive] = useState(firstEnabled); const activeIndex = Math.min(active, Math.max(0, filtered.length - 1)); + const activeItem = filtered[activeIndex]?.item; + + // Choosers highlight what the selected row would act on. + useEffect(() => { + activeItem?.onActive?.(); + }, [activeItem]); + + const [preview, setPreview] = useState<{ id: string; data: ListPreview | null } | null>(null); + const fetchPreview = popup.preview; + useEffect(() => { + if (!fetchPreview || !activeItem || activeItem.disabled) return; + const ctrl = new AbortController(); + // Moving quickly through the list must not queue a resolve per row. + const timer = setTimeout(() => { + fetchPreview(activeItem, ctrl.signal).then( + (data) => !ctrl.signal.aborted && setPreview({ id: activeItem.id, data }), + () => !ctrl.signal.aborted && setPreview({ id: activeItem.id, data: null }), + ); + }, 120); + return () => { + clearTimeout(timer); + ctrl.abort(); + }; + }, [activeItem, fetchPreview]); const activate = (item: ListPopupItem) => { if (item.disabled) return; @@ -54,7 +118,11 @@ function ListPopup({ popup }: { popup: Extract }) { const onKeyDown = (e: KeyboardEvent) => { if (e.metaKey || e.ctrlKey || e.altKey) return; - if (e.key === "Backspace" && query) { + if (popup.numbered && !query && /^[1-9]$/.test(e.key)) { + const item = popup.items[Number(e.key) - 1]; + e.preventDefault(); + if (item) activate(item); + } else if (e.key === "Backspace" && query) { setQuery(query.slice(0, -1)); setActive(0); e.preventDefault(); @@ -69,55 +137,82 @@ function ListPopup({ popup }: { popup: Extract }) { // Rows are virtualized (absolutely positioned), so size the popup from the content. const width = useMemo(() => { const chars = Math.max( - popup.title.length, + popup.title.length + (popup.pin ? 4 : 0), ...popup.items.map( (i) => i.label.length + (i.preview?.length ?? 0) + (i.detail?.length ?? 0) + (i.shortcut?.length ?? 0), ), ); - return Math.min(720, Math.max(320, chars * 7 + 90)); + return Math.min(720, Math.max(320, chars * 7 + 90 + (popup.numbered ? 16 : 0))); }, [popup]); + const shownPreview = popup.preview + ? preview && preview.id === activeItem?.id + ? preview.data + : activeItem && !activeItem.disabled + ? ("loading" as const) + : null + : null; + const indexOf = (item: ListPopupItem) => popup.items.indexOf(item); return ( -
- {popup.title} - {query && {query}} -
-
- r.item.id} - activeIndex={activeIndex} - onActiveChange={setActive} - hoverSelects - aria-label={popup.title} - onActivate={(r) => activate(r.item)} - empty={ - - Nothing matches - - } - renderItem={({ item, match }) => ( -
- - - {item.preview && {item.preview}} - - {item.detail && {item.detail}} - {item.shortcut && {item.shortcut}} -
+
+
+ {popup.title} + {query && {query}} + {popup.pin && ( + { + const run = popup.pin!.run; + closePopup(); + run(); + }} + /> )} - /> +
+
+ r.item.id} + activeIndex={activeIndex} + onActiveChange={setActive} + hoverSelects + aria-label={popup.title} + onActivate={(r) => activate(r.item)} + empty={ + + Nothing matches + + } + renderItem={({ item, match }) => { + const n = popup.numbered && !query ? indexOf(item) + 1 : 0; + return ( +
+ {popup.numbered && {n > 0 && n <= 9 ? n : ""}} + + + {item.preview && {item.preview}} + + {item.detail && {item.detail}} + {item.shortcut && {item.shortcut}} +
+ ); + }} + /> +
+ {popup.preview && } ); } @@ -209,25 +304,112 @@ function DomContent({ build, className }: { build: () => HTMLElement; className? return
; } +/** Documentation with its history toolbar; shared by the popup and the Documentation tool window. */ +function DocView({ host, history, themeClasses }: { host: DocHost; history: DocHistory; themeClasses: string }) { + const entry = history.entries[history.index]; + const onKeyDown = (e: KeyboardEvent) => { + if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return; + if (e.key === "ArrowLeft" || e.key === "ArrowRight") { + e.preventDefault(); + docStep(host, e.key === "ArrowLeft" ? -1 : 1); + } else if (e.key === "F4" && entry?.source) { + e.preventDefault(); + void editDocSource(host); + } + }; + const ref = useRef(null); + return ( +
+ {entry && ( + renderMarkup(entry.markup, entry.language, docLinkHandler(host))} + /> + )} +
+ docStep(host, -1)} + /> + = history.entries.length - 1} + onClick={() => docStep(host, 1)} + /> + + {entry?.source && ( + void editDocSource(host)} /> + )} + {host === "popup" && ( + moveDocPopupToWindow()} + /> + )} +
+
+ ); +} + function DocPopup({ popup }: { popup: Extract }) { const focusSeq = useLspPopup((s) => s.focusSeq); const scope = useRef(null); const opened = useRef(focusSeq); useEffect(() => { - if (focusSeq !== opened.current) scope.current?.focus({ preventScroll: true }); + if (focusSeq !== opened.current) scope.current?.querySelector("[data-lsp-doc]")?.focus({ preventScroll: true }); }, [focusSeq]); return ( - -
- renderMarkup(popup.markup, popup.language)} /> + +
+
); } +/** The Documentation tool window (F1 on a focused documentation popup). */ +export function DocumentationWindow() { + const history = useDocWindow(); + if (!history.entries.length) { + return ( +
+ No documentation +
+ ); + } + return ; +} + function DefinitionPopup({ popup }: { popup: Extract }) { const root = useProject((s) => s.project?.root ?? ""); - const { location } = popup; + const target = popup.targets[popup.index]!; + const { location } = target; + const many = popup.targets.length > 1; const open = () => { closePopup(); void editorService().openFile(location.path, { @@ -235,21 +417,56 @@ function DefinitionPopup({ popup }: { popup: Extract + updatePopup("definition", { index: (index + popup.targets.length) % popup.targets.length }); + const onKeyDown = (e: KeyboardEvent) => { + if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return; + if (many && (e.key === "ArrowLeft" || e.key === "ArrowRight")) { + e.preventDefault(); + select(popup.index + (e.key === "ArrowLeft" ? -1 : 1)); + } else if (e.key === "F4") { + e.preventDefault(); + open(); + } + }; return ( -
- - {basename(location.path)} - - {relativePath(root, location.path)}:{location.range.start.line + 1} - - - -
-
- renderCode(popup.text, popup.language, "cm-lsp-code")} /> +
+
+ + {many ? ( + setSelectionOnly(false)} /> + Whole file + + + +
+ + Options + + +
+ + ); +} + /** Status bar text while ⌘⇧F7 usages are highlighted ("3 usages of 'x' found…"). */ export function UsagesStatus() { const text = useUsagesStatus((s) => s.text); @@ -353,5 +627,7 @@ export function LspPopups() { return ; case "preview": return ; + case "reformat": + return ; } } diff --git a/src/editor/lsp/actions.ts b/src/editor/lsp/actions.ts index dff9b73c..38cbadc4 100644 --- a/src/editor/lsp/actions.ts +++ b/src/editor/lsp/actions.ts @@ -13,7 +13,7 @@ import { gotoDiagnostic } from "./diagnostics"; import { applyKindSilently, runKindAction, showIntentions } from "./intentions"; import { workflowSettings, type ActionsOnSaveValid } from "@/platform/workflowSettings"; import { startRename } from "./rename"; -import { formatDocument } from "./format"; +import { formatDocument, reformatDialog } from "./format"; import { showHint } from "./hint"; import { refactorThis, runRefactoring, safeDelete } from "@/editor/refactor"; @@ -114,6 +114,9 @@ export function registerLspActions(): () => void { editorAction("code.reformat", "Reformat Code", "Code", reformat, { keywords: ["format", "prettier"], }), + editorAction("code.reformatDialog", "Reformat File…", "Code", (v) => + reformatDialog(v, (view) => applyKindSilently(view, "source.organizeImports")), + ), editorAction("code.optimizeImports", "Optimize Imports", "Code", optimizeImports, { keywords: ["organize imports"] }), // Project view / editor tab variants: `{ path }` of a file, opened first. { id: "code.reformatFile", title: "Reformat File", category: "Code", hidden: true, when: "projectOpen", run: inFile(reformat) }, diff --git a/src/editor/lsp/codeLens.test.ts b/src/editor/lsp/codeLens.test.ts new file mode 100644 index 00000000..d9f45043 --- /dev/null +++ b/src/editor/lsp/codeLens.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import type { OutlineItem } from "@/platform/ipc"; +import { lensTargets } from "./codeLens"; + +const item = (name: string, kind: OutlineItem["kind"], line: number, children: OutlineItem[] = []): OutlineItem => { + const r = { start: { line, col: 0 }, end: { line, col: 1 } }; + return { name, kind, detail: null, range: r, selection_range: r, children }; +}; + +describe("usages lens targets", () => { + it("skips callbacks and anonymous functions", () => { + const items = [ + item("Cart", "class", 0, [ + item("count", "method", 1, [item("reduce() callback", "function", 2)]), + item("", "function", 3), + ]), + ]; + expect(lensTargets(items).map((t) => t.name)).toEqual(["Cart", "count"]); + }); +}); diff --git a/src/editor/lsp/codeLens.ts b/src/editor/lsp/codeLens.ts index abcfb84f..70a33266 100644 --- a/src/editor/lsp/codeLens.ts +++ b/src/editor/lsp/codeLens.ts @@ -40,12 +40,15 @@ export interface LensTarget { col: number; } +/** Servers name callbacks and anonymous functions (``, `map() callback`); they have no usages. */ +const anonymous = (name: string) => /^<.*>$/.test(name) || / callback$/.test(name); + /** Declarations worth a usages lens, in document order. Exported for tests. */ export function lensTargets(items: readonly OutlineItem[]): LensTarget[] { const out: LensTarget[] = []; const walk = (list: readonly OutlineItem[]) => { for (const it of list) { - if (LENS_KINDS.has(it.kind)) out.push({ name: it.name, line: it.selection_range.start.line, col: it.selection_range.start.col }); + if (LENS_KINDS.has(it.kind) && !anonymous(it.name)) out.push({ name: it.name, line: it.selection_range.start.line, col: it.selection_range.start.col }); if (it.children.length) walk(it.children); } }; diff --git a/src/editor/lsp/completion.ts b/src/editor/lsp/completion.ts index 964e4dca..f557e100 100644 --- a/src/editor/lsp/completion.ts +++ b/src/editor/lsp/completion.ts @@ -351,10 +351,27 @@ export function matchesCase(label: string, typed: string, mode: string): boolean return i >= typed.length; } +/** + * A source another feature restricts the lookup to, via the + * `openideaExclusiveCompletion` language-data key (`(state) => source | null`; + * ⌘J's live templates). + */ +function exclusiveSource(ctx: CompletionContext): CompletionSource | null { + type Provider = (state: CompletionContext["state"]) => CompletionSource | null; + for (const f of ctx.state.languageDataAt("openideaExclusiveCompletion", ctx.pos)) { + const source = f(ctx.state); + if (source) return source; + } + return null; +} + /** Show suggestions as you type: sources only answer explicit requests when it is off. */ function gated(source: CompletionSource): CompletionSource { - return (ctx) => - !ctx.explicit && (!editorPrefs().completion.autoPopup || inplaceRenameActive(ctx.state)) ? null : source(ctx); + return (ctx) => { + const only = exclusiveSource(ctx); + if (only) return source === contributedSource ? only(ctx) : null; + return !ctx.explicit && (!editorPrefs().completion.autoPopup || inplaceRenameActive(ctx.state)) ? null : source(ctx); + }; } // --------------------------------------------------------------------------- diff --git a/src/editor/lsp/docs.ts b/src/editor/lsp/docs.ts new file mode 100644 index 00000000..eb0b04aa --- /dev/null +++ b/src/editor/lsp/docs.ts @@ -0,0 +1,163 @@ +/** + * Quick Documentation navigation, as in IDEA: links to other symbols open + * their documentation in place (⌘-click opens the source instead), ← / → + * walk the history, Edit Source (F4) jumps to the declaration, and F1 on a + * focused popup moves it to the Documentation tool window, which then shows + * F1's documentation instead of a popup while it is open. + */ +import { create } from "zustand"; +import { commands, nextRequestId, type Location } from "@/platform/ipc"; +import { hasPrimaryModifier } from "@/platform/keymap"; +import { editorService, maybeEditorService, toolWindowService } from "@/workbench/contributions"; +import { closePopup, updatePopup, useLspPopup, type DocEntry } from "./popupStore"; +import { isEmptyMarkup, languageForPath, sourceLink, type DocLinkHandler } from "./markdown"; +import { lspRequest } from "./lsp"; +import { readFileLines, withDocument } from "./files"; + +export const DOCUMENTATION_WINDOW = "documentation"; + +export interface DocHistory { + entries: DocEntry[]; + index: number; +} + +/** History after following a link from the shown entry: forward entries are dropped. */ +export function pushHistory(h: DocHistory, entry: DocEntry): DocHistory { + return { entries: [...h.entries.slice(0, h.index + 1), entry], index: h.index + 1 }; +} + +/** History index after ← (-1) / → (+1); unchanged at the ends. */ +export function stepHistory(h: DocHistory, delta: -1 | 1): number { + return Math.max(0, Math.min(h.entries.length - 1, h.index + delta)); +} + +// --------------------------------------------------------------------------- +// Documentation tool window + +interface DocWindowState extends DocHistory { + themeClasses: string; +} + +export const useDocWindow = create(() => ({ entries: [], index: 0, themeClasses: "" })); + +export function docWindowVisible(): boolean { + try { + return toolWindowService().isVisible(DOCUMENTATION_WINDOW); + } catch { + return false; + } +} + +/** Shows `entry` in the Documentation tool window (a new history step). */ +export function showInDocWindow(entry: DocEntry, themeClasses: string) { + useDocWindow.setState((s) => ({ ...pushHistory(s, entry), themeClasses: themeClasses || s.themeClasses })); + toolWindowService().show(DOCUMENTATION_WINDOW, false); +} + +/** F1 on a focused documentation popup: the popup's history moves to the tool window. */ +export function moveDocPopupToWindow(): boolean { + const popup = useLspPopup.getState().popup; + if (popup?.kind !== "doc") return false; + useDocWindow.setState({ entries: popup.entries, index: popup.index, themeClasses: popup.themeClasses }); + closePopup(); + toolWindowService().show(DOCUMENTATION_WINDOW, true); + return true; +} + +// --------------------------------------------------------------------------- +// Shared by the popup and the tool window + +/** Where a doc view keeps its history: the popup or the tool window. */ +export type DocHost = "popup" | "window"; + +function history(host: DocHost): DocHistory | null { + if (host === "window") return useDocWindow.getState(); + const p = useLspPopup.getState().popup; + return p?.kind === "doc" ? p : null; +} + +function setHistory(host: DocHost, h: DocHistory) { + if (host === "window") useDocWindow.setState(h); + else updatePopup("doc", h); +} + +export function docStep(host: DocHost, delta: -1 | 1) { + const h = history(host); + if (h) setHistory(host, { entries: h.entries, index: stepHistory(h, delta) }); +} + +/** F4 / Edit Source: the documented element's declaration. */ +export async function editDocSource(host: DocHost): Promise { + const h = history(host); + const entry = h?.entries[h.index]; + const loc = (await entry?.source?.().catch(() => null)) ?? null; + if (!loc) return false; + if (host === "popup") closePopup(); + await editorService().openFile(loc.path, { selection: { line: loc.range.start.line, col: loc.range.start.col }, focus: true }); + return true; +} + +const DECLARATION_KEYWORDS = + /^(export|default|declare|abstract|public|private|protected|internal|static|final|async|const|let|var|val|function|fun|fn|func|def|class|interface|struct|enum|trait|type|impl|pub|mod|object|record|override|readonly|open|data|sealed|extends|implements)$/; + +/** + * Positions on the linked line worth asking the server about: the link's own + * column, then the identifiers after declaration keywords (links often point + * at the start of `export class Foo`). + */ +export function hoverCandidates(lineText: string, col: number): number[] { + const out = [col]; + const re = /[A-Za-z_$][\w$]*/g; + for (let m = re.exec(lineText); m && out.length < 5; m = re.exec(lineText)) { + if (m.index < col || DECLARATION_KEYWORDS.test(m[0])) continue; + if (!out.includes(m.index)) out.push(m.index); + } + return out; +} + +async function docAt(target: { path: string; line: number; col: number }): Promise { + const line = (await readFileLines(target.path))?.[target.line] ?? ""; + return withDocument(target.path, async (doc) => { + for (const col of hoverCandidates(line, target.col)) { + const hover = await lspRequest(() => commands.lspHover(nextRequestId(), doc, { line: target.line, col })).catch( + () => null, + ); + if (hover && !isEmptyMarkup(hover.contents)) { + const location: Location = { + path: target.path, + range: { start: { line: target.line, col }, end: { line: target.line, col } }, + }; + return { markup: hover.contents, language: languageForPath(target.path), source: async () => location }; + } + } + return null; + }); +} + +/** + * Link clicks in a documentation view: a source link shows that symbol's + * documentation in the same view (⌘-click opens the source instead). + */ +export function docLinkHandler(host: DocHost): DocLinkHandler { + return (href, e) => { + const target = sourceLink(href); + if (!target || hasPrimaryModifier(e)) { + if (host === "popup" && target) closePopup(); + return false; + } + void (async () => { + const entry = await docAt(target).catch(() => null); + const h = history(host); + if (!entry || !h) { + if (host === "popup") closePopup(); + void maybeEditorService()?.openFile(target.path, { + selection: { line: target.line, col: target.col }, + focus: true, + }); + return; + } + setHistory(host, pushHistory(h, entry)); + })(); + return true; + }; +} diff --git a/src/editor/lsp/features.test.ts b/src/editor/lsp/features.test.ts index acd5c67a..1f40be53 100644 --- a/src/editor/lsp/features.test.ts +++ b/src/editor/lsp/features.test.ts @@ -7,6 +7,7 @@ import { splitSignature } from "./signature"; import { dedupeLocations, usagesStatusText } from "./navigation"; import { declarationSummary } from "./markdown"; import { linkedSpans } from "./rename"; +import { definitionSpan } from "./hover"; describe("semanticRanges", () => { const doc = Text.of(["let mut count = Foo::new();", "const MAX: u32 = 1;"]); @@ -120,3 +121,17 @@ describe("linkedSpans", () => { ]); }); }); + +describe("definitionSpan", () => { + const loc = { path: "/a.ts", range: { start: { line: 40, col: 9 }, end: { line: 40, col: 12 } } }; + const range = (a: number, b: number) => ({ start: { line: a, col: 0 }, end: { line: b, col: 1 } }); + + it("shows the whole enclosing declaration when the syntax tree knows it", () => { + expect(definitionSpan(loc, 500, range(38, 120))).toEqual({ first: 38, last: 120 }); + }); + + it("falls back to a window around the name otherwise", () => { + expect(definitionSpan(loc, 500, null)).toEqual({ first: 39, last: 52 }); + expect(definitionSpan(loc, 500, range(0, 10))).toEqual({ first: 39, last: 52 }); + }); +}); diff --git a/src/editor/lsp/files.ts b/src/editor/lsp/files.ts index 4eb574fa..692ccfc0 100644 --- a/src/editor/lsp/files.ts +++ b/src/editor/lsp/files.ts @@ -1,4 +1,4 @@ -/** Reading other files' text for previews (definitions, choosers). */ +/** Reading other files' text for previews (definitions, choosers), and backend documents for them. */ import { editorsFor } from "@/editor/api"; import { call, commands, isTauri } from "@/platform/ipc"; import { isVirtualUri, virtualDocument } from "@/platform/virtualDocs"; @@ -34,3 +34,30 @@ export async function readManyFileLines(paths: readonly string[], concurrency = await Promise.all(Array.from({ length: Math.min(concurrency, queue.length) }, worker)); return out; } + +/** + * Runs `fn` with a backend document id for `path` (an open editor's when there + * is one; otherwise opened for the call only, the backend ref-counts it). + */ +export async function withDocument(path: string, fn: (doc: number) => Promise): Promise { + const open = editorsFor(path)[0]; + if (open) return fn(open.ctx.doc); + const info = await call(isVirtualUri(path) ? commands.docOpenVirtual(path) : commands.docOpen(path)); + try { + return await fn(info.id); + } finally { + void commands.docClose(info.id); + } +} + +/** The identifier at (or starting right after) `col` of `line`; null when there is none. */ +export function identifierAt(line: string, col: number): string | null { + const re = /[\p{L}\p{N}_$]+/gu; + for (let m = re.exec(line); m; m = re.exec(line)) { + const end = m.index + m[0].length; + if (end < col) continue; + if (m.index > col + 1) return null; + return /^\p{N}/u.test(m[0]) ? null : m[0]; + } + return null; +} diff --git a/src/editor/lsp/format.test.ts b/src/editor/lsp/format.test.ts new file mode 100644 index 00000000..8f604b79 --- /dev/null +++ b/src/editor/lsp/format.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { Text } from "@codemirror/state"; +import { formattedLines } from "./format"; + +describe("formattedLines", () => { + const doc = Text.of(["fn main(){", "let x=1;", "}", "", "fn other() {}"]); + const lineStart = (n: number) => doc.line(n).from; + + it("counts the lines of the result the changes touched", () => { + expect( + formattedLines(doc, [ + { from: lineStart(1) + 2, to: lineStart(1) + 4, insert: " " }, + { from: lineStart(1) + 10, to: lineStart(1) + 10, insert: " " }, + { from: lineStart(2), to: lineStart(2), insert: " " }, + ]), + ).toBe(2); + }); + + it("numbers lines as in the new text when a change adds lines", () => { + // `{}` split over two lines, then a change on (old) line 5 = new line 6. + expect( + formattedLines(doc, [ + { from: lineStart(5) + 11, to: lineStart(5) + 12, insert: "{\n" }, + { from: lineStart(1), to: lineStart(1) + 1, insert: "f" }, + ]), + ).toBe(3); + }); +}); diff --git a/src/editor/lsp/format.ts b/src/editor/lsp/format.ts index 3399a583..01276154 100644 --- a/src/editor/lsp/format.ts +++ b/src/editor/lsp/format.ts @@ -1,28 +1,79 @@ /** * Reformat Code (⌥⌘L): `lsp_format` for the selection (or the whole file), * applied locally as one CodeMirror transaction and one undo step; editor/core - * syncs it to the backend like any user edit. + * syncs it to the backend like any user edit. As in IDEA an explicit reformat + * reports "Formatted N lines" with a link to the Reformat File dialog + * (⌥⇧⌘L: scope and Optimize Imports). */ import { indentUnit } from "@codemirror/language"; import { isolateHistory } from "@codemirror/commands"; +import type { Text } from "@codemirror/state"; import type { EditorView } from "@codemirror/view"; import { commands } from "@/platform/ipc"; +import { shortcutLabel } from "@/platform/keymap"; +import { executeAction } from "@/platform/actions"; import { CONTRIBUTED_FORMAT, contributedAt, whenSynced, type ContributedFormatter } from "@/editor/api"; import { docOf, lspRequest, reportLspError } from "./lsp"; import { offsetToPos, textEditsToChanges } from "./positions"; import { showHint } from "./hint"; +import { openPopup, type ReformatOptions } from "./popupStore"; export interface FormatResult { applied: boolean; /** Number of changed regions. */ changes: number; + /** Lines of the result that the changes touched. */ + lines: number; } -export async function formatDocument(view: EditorView, opts: { explicit: boolean; selectionOnly?: boolean } = { explicit: true }): Promise { +const NOTHING: FormatResult = { applied: false, changes: 0, lines: 0 }; + +/** Lines of the new document touched by `changes` (given against `doc`), as IDEA counts "Formatted N lines". */ +export function formattedLines(doc: Text, changes: readonly { from: number; to: number; insert: string }[]): number { + const touched = new Set(); + // Line shift of the changes so far, to number lines as in the new text. + let shift = 0; + for (const c of [...changes].sort((a, b) => a.from - b.from)) { + const startLine = doc.lineAt(c.from).number + shift; + const insertedLines = c.insert.split("\n").length - 1; + for (let n = startLine; n <= startLine + insertedLines; n++) touched.add(n); + shift += insertedLines - (doc.lineAt(c.to).number - doc.lineAt(c.from).number); + } + return touched.size; +} + +function formattedHint(view: EditorView, lines: number) { + const keys = shortcutLabel("code.reformatDialog"); + showHint(view, () => { + const box = document.createElement("div"); + box.textContent = `Formatted ${lines} ${lines === 1 ? "line" : "lines"}`; + const more = document.createElement("div"); + const link = document.createElement("button"); + link.type = "button"; + link.className = "cm-lsp-link-button"; + link.textContent = "Show"; + link.addEventListener("mousedown", (e) => e.preventDefault()); + link.addEventListener("click", () => void executeAction("code.reformatDialog")); + more.append(link, ` the Reformat dialog${keys ? `: ${keys}` : ""}`); + box.appendChild(more); + return box; + }); +} + +export async function formatDocument( + view: EditorView, + opts: { explicit: boolean; selectionOnly?: boolean } = { explicit: true }, +): Promise { + const result = await format(view, opts); + if (opts.explicit && result.applied) formattedHint(view, result.lines); + return result; +} + +async function format(view: EditorView, opts: { explicit: boolean; selectionOnly?: boolean }): Promise { const own = contributedAt(view.state, CONTRIBUTED_FORMAT, 0); if (own) return applyContributedFormat(view, own, opts); const doc = docOf(view); - if (!doc || doc.readOnly) return { applied: false, changes: 0 }; + if (!doc || doc.readOnly) return NOTHING; // The server formats the backend's copy: let pending keystrokes reach it first. await whenSynced(view); const state = view.state; @@ -35,16 +86,16 @@ export async function formatDocument(view: EditorView, opts: { explicit: boolean edits = await lspRequest(() => commands.lspFormat(doc.doc, range, options)); } catch (e) { reportLspError(view, e, opts.explicit); - return { applied: false, changes: 0 }; + return NOTHING; } if (view.state.doc !== state.doc) { if (opts.explicit) showHint(view, "The file changed while formatting; try again"); - return { applied: false, changes: 0 }; + return NOTHING; } const changes = textEditsToChanges(state.doc, edits); if (!changes.length) { - if (opts.explicit) showHint(view, useSelection ? "The selection is already formatted" : "The code is already formatted"); - return { applied: false, changes: 0 }; + if (opts.explicit) showHint(view, "No lines changed: content is already properly formatted"); + return NOTHING; } view.dispatch({ changes, @@ -52,7 +103,7 @@ export async function formatDocument(view: EditorView, opts: { explicit: boolean annotations: isolateHistory.of("full"), scrollIntoView: false, }); - return { applied: true, changes: changes.length }; + return { applied: true, changes: changes.length, lines: formattedLines(state.doc, changes) }; } function applyContributedFormat( @@ -61,16 +112,50 @@ function applyContributedFormat( opts: { explicit: boolean; selectionOnly?: boolean }, ): FormatResult { const state = view.state; - if (state.readOnly) return { applied: false, changes: 0 }; + if (state.readOnly) return NOTHING; const sel = state.selection.main; const useSelection = (opts.selectionOnly ?? true) && !sel.empty; const changes = format(state, useSelection ? { from: sel.from, to: sel.to } : null).filter( (c) => state.sliceDoc(c.from, c.to) !== c.insert, ); if (!changes.length) { - if (opts.explicit) showHint(view, useSelection ? "The selection is already formatted" : "The code is already formatted"); - return { applied: false, changes: 0 }; + if (opts.explicit) showHint(view, "No lines changed: content is already properly formatted"); + return NOTHING; } view.dispatch({ changes, userEvent: "format", annotations: isolateHistory.of("full"), scrollIntoView: false }); - return { applied: true, changes: changes.length }; + return { applied: true, changes: changes.length, lines: formattedLines(state.doc, changes) }; +} + +/** The dialog's last choices, as IDEA remembers them. */ +let lastOptions: ReformatOptions = { selectionOnly: true, optimizeImports: false }; + +/** + * Reformat File… (⌥⇧⌘L): asks for the scope and options, then optimizes + * imports (when chosen) and reformats. + */ +export async function reformatDialog( + view: EditorView, + optimizeImports: (view: EditorView) => Promise = async () => {}, +): Promise { + const ctx = docOf(view); + if (!ctx || ctx.readOnly) { + if (ctx?.readOnly) showHint(view, "The file is read-only"); + return; + } + const sel = view.state.selection.main; + const name = ctx.path.slice(ctx.path.lastIndexOf("/") + 1); + openPopup({ + kind: "reformat", + title: `Reformat File: ${name}`, + hasSelection: !sel.empty, + canOptimizeImports: !contributedAt(view.state, CONTRIBUTED_FORMAT, 0), + initial: lastOptions, + onRun: (options) => + void (async () => { + lastOptions = { ...lastOptions, ...options, selectionOnly: sel.empty ? lastOptions.selectionOnly : options.selectionOnly }; + view.focus(); + if (options.optimizeImports) await optimizeImports(view); + await formatDocument(view, { explicit: true, selectionOnly: options.selectionOnly }); + })(), + }); } diff --git a/src/editor/lsp/hover.ts b/src/editor/lsp/hover.ts index 30f25db8..11ed5a94 100644 --- a/src/editor/lsp/hover.ts +++ b/src/editor/lsp/hover.ts @@ -1,11 +1,11 @@ /** * Hover tooltip (500ms): diagnostics under the pointer + quick documentation, - * plus the explicit actions code.quickDocumentation (F1, sticky popup) and - * code.quickDefinition (⌥Space, definition source popup). + * plus the explicit actions code.quickDocumentation (F1, sticky popup with + * history, `docs.ts`) and code.quickDefinition (⌥Space, definition source popup). */ import { EditorSelection } from "@codemirror/state"; import { hoverTooltip, type EditorView } from "@codemirror/view"; -import { commands, isTauri, nextRequestId, type Hover, type RequestId } from "@/platform/ipc"; +import { commands, isTauri, nextRequestId, type Hover, type Location, type Pos, type Range, type RequestId } from "@/platform/ipc"; import { executeAction } from "@/platform/actions"; import { shortcutLabel } from "@/platform/keymap"; import { diagnosticsAt, type PlacedDiagnostic } from "./diagnostics"; @@ -13,9 +13,12 @@ import { isEmptyMarkup, languageForPath, renderMarkup } from "./markdown"; import { docOf, lspRequest, reportLspError } from "./lsp"; import { offsetToPos, rangeToOffsets } from "./positions"; import { showHint } from "./hint"; -import { caretAnchor, focusPopup, openPopup, useLspPopup } from "./popupStore"; +import { caretAnchor, focusPopup, openPopup, useLspPopup, type DefinitionTarget, type DocEntry } from "./popupStore"; import { selectedLookupDoc, showLookupDocs } from "./completion"; -import { readFileLines } from "./files"; +import { identifierAt, readFileLines, withDocument } from "./files"; +import { docWindowVisible, moveDocPopupToWindow, showInDocWindow } from "./docs"; +import { structure } from "@/editor/structure"; +import { basename } from "@/platform/project"; import { iconNode } from "./iconMarkup"; import { editorPrefs } from "@/platform/editorSettings"; import { CONTRIBUTED_HOVER, contributedAt, type ContributedHover } from "@/editor/api"; @@ -118,13 +121,39 @@ export function hoverExtension() { ); } +/** The documentation popup has the keyboard (F1 pressed twice, or clicked into). */ +function docPopupFocused(): boolean { + return !!(document.activeElement as HTMLElement | null)?.closest?.("[data-lsp-doc]"); +} + +/** Lazily resolved declaration of the element at `pos` (Edit Source). */ +function definitionOf(doc: number, pos: Pos): () => Promise { + let found: Promise | null = null; + return () => + (found ??= lspRequest(() => commands.lspGoto(doc, pos, "definition")).then( + (locs) => locs[0] ?? null, + () => null, + )); +} + +function showDoc(view: EditorView, entry: DocEntry, at: number) { + if (docWindowVisible()) { + showInDocWindow(entry, view.themeClasses); + return; + } + openPopup({ kind: "doc", anchor: caretAnchor(view, at), entries: [entry], index: 0, themeClasses: view.themeClasses }); +} + /** - * F1: documentation for the symbol at the caret in a sticky popup; in an open - * lookup, for its selected item. Again on an open popup: focuses it. + * F1: documentation for the symbol at the caret in a popup (or the + * Documentation tool window while it is open); in an open lookup, for its + * selected item. Again on an open popup: focuses it; once more: moves it to + * the tool window. */ export async function quickDocumentation(view: EditorView) { if (useLspPopup.getState().popup?.kind === "doc") { - focusPopup(); + if (docPopupFocused()) moveDocPopupToWindow(); + else focusPopup(); return; } const pos = view.state.selection.main.head; @@ -133,14 +162,7 @@ export async function quickDocumentation(view: EditorView) { if (lookup === "showing") { const doc = await selectedLookupDoc(view).catch(() => null); if (!doc) showHint(view, "No documentation found"); - else - openPopup({ - kind: "doc", - anchor: caretAnchor(view, pos), - markup: doc.markup, - language: doc.language, - themeClasses: view.themeClasses, - }); + else showDoc(view, { markup: doc.markup, language: doc.language }, pos); return; } try { @@ -149,13 +171,16 @@ export async function quickDocumentation(view: EditorView) { showHint(view, "No documentation found"); return; } - openPopup({ - kind: "doc", - anchor: caretAnchor(view, hover.range ? rangeToOffsets(view.state.doc, hover.range).from : pos), - markup: hover.contents, - language: docOf(view)?.uiMode ?? null, - themeClasses: view.themeClasses, - }); + const ctx = docOf(view); + showDoc( + view, + { + markup: hover.contents, + language: ctx?.uiMode ?? null, + source: ctx ? definitionOf(ctx.doc, offsetToPos(view.state.doc, pos)) : undefined, + }, + hover.range ? rangeToOffsets(view.state.doc, hover.range).from : pos, + ); } catch (e) { reportLspError(view, e, true); } @@ -163,36 +188,72 @@ export async function quickDocumentation(view: EditorView) { const DEFINITION_CONTEXT_BEFORE = 1; const DEFINITION_MAX_LINES = 30; +/** A declaration taller than this is cut (IDEA scrolls; we keep the popup light). */ +const DECLARATION_MAX_LINES = 400; +const MAX_TARGETS = 20; -/** ⌥Space: the definition's source in a popup, without leaving the editor. */ +/** Line span to show for a definition: its whole declaration when the syntax tree knows it. */ +export function definitionSpan( + loc: Location, + lineCount: number, + declaration: Range | null, +): { first: number; last: number } { + const start = loc.range.start.line; + if (declaration && declaration.start.line <= start && start <= declaration.end.line) { + const first = Math.max(0, declaration.start.line); + return { first, last: Math.min(lineCount - 1, declaration.end.line, first + DECLARATION_MAX_LINES - 1) }; + } + const first = Math.max(0, start - DEFINITION_CONTEXT_BEFORE); + const spanEnd = Math.max(loc.range.end.line, start + 12); + return { first, last: Math.min(lineCount - 1, Math.min(spanEnd, first + DEFINITION_MAX_LINES - 1)) }; +} + +async function declarationRange(loc: Location): Promise { + try { + return await withDocument(loc.path, async (doc) => (await structure.structuralRanges(doc, loc.range.start)).declaration?.range ?? null); + } catch { + return null; + } +} + +async function definitionTarget(loc: Location, fallbackLanguage: string | null): Promise { + const [lines, declaration] = await Promise.all([readFileLines(loc.path), declarationRange(loc)]); + if (!lines) return null; + const { first, last } = definitionSpan(loc, lines.length, declaration); + const name = identifierAt(lines[loc.range.start.line] ?? "", loc.range.start.col) ?? basename(loc.path); + return { + location: loc, + name, + firstLine: first, + text: lines.slice(first, last + 1).join("\n"), + language: languageForPath(loc.path) ?? fallbackLanguage, + }; +} + +/** ⌥Space: the definition's source in a popup, without leaving the editor; ← / → switch between several. */ export async function quickDefinition(view: EditorView) { const doc = docOf(view); if (!doc) return; const pos = view.state.selection.main.head; try { - const locations = await lspRequest(() => commands.lspGoto(doc.doc, offsetToPos(view.state.doc, pos), "definition")); - const loc = locations[0]; - if (!loc) { + const found = await lspRequest(() => commands.lspGoto(doc.doc, offsetToPos(view.state.doc, pos), "definition")); + const seen = new Set(); + const locations = found.filter((l) => { + const key = `${l.path}:${l.range.start.line}:${l.range.start.col}`; + return !seen.has(key) && !!seen.add(key); + }); + if (!locations.length) { showHint(view, "No definition found"); return; } - const lines = await readFileLines(loc.path); - if (!lines) { + const targets = (await Promise.all(locations.slice(0, MAX_TARGETS).map((l) => definitionTarget(l, doc.uiMode)))).filter( + (t): t is DefinitionTarget => !!t, + ); + if (!targets.length) { showHint(view, "Cannot read the definition's file"); return; } - const first = Math.max(0, loc.range.start.line - DEFINITION_CONTEXT_BEFORE); - const spanEnd = Math.max(loc.range.end.line, loc.range.start.line + 12); - const last = Math.min(lines.length - 1, Math.min(spanEnd, first + DEFINITION_MAX_LINES - 1)); - openPopup({ - kind: "definition", - anchor: caretAnchor(view, pos), - location: loc, - firstLine: first, - text: lines.slice(first, last + 1).join("\n"), - language: languageForPath(loc.path) ?? doc.uiMode, - themeClasses: view.themeClasses, - }); + openPopup({ kind: "definition", anchor: caretAnchor(view, pos), targets, index: 0, themeClasses: view.themeClasses }); } catch (e) { reportLspError(view, e, true); } diff --git a/src/editor/lsp/index.ts b/src/editor/lsp/index.ts index 76b567ed..ae2a22ad 100644 --- a/src/editor/lsp/index.ts +++ b/src/editor/lsp/index.ts @@ -7,14 +7,14 @@ */ import type { Extension } from "@codemirror/state"; import { registerEditorContribution, type EditorDocContext } from "@/editor/api"; -import { registerOverlay, registerStatusItem } from "@/workbench/contributions"; +import { registerOverlay, registerStatusItem, registerToolWindow } from "@/workbench/contributions"; import { lspDoc } from "./lsp"; import { hintExtension } from "./hint"; import { completionExtension } from "./completion"; import { inplaceRenameExtension } from "./inplaceRename"; import { diagnosticsExtension, installDiagnosticsEvents } from "./diagnostics"; import { hoverExtension } from "./hover"; -import { signatureExtension } from "./signature"; +import { parameterTabKeys, signatureExtension } from "./signature"; import { navigationExtension } from "./navigation"; import { intentionsExtension } from "./intentions"; import { inlayExtension } from "./inlay"; @@ -26,7 +26,8 @@ import { registerActionsOnSave, registerFormatOnSave, registerLspActions } from import { lspTheme } from "./theme"; import { inspectionWidgetExtension } from "./inspectionWidget"; import { unresolvedReferences } from "./fileReferences"; -import { LspPopups, UsagesStatus } from "./Popups"; +import { DocumentationWindow, LspPopups, UsagesStatus } from "./Popups"; +import { DOCUMENTATION_WINDOW } from "./docs"; import { prefetchMarkdown } from "./markdown"; import { registerNavigationActions } from "@/editor/navigation"; @@ -56,8 +57,21 @@ export function activate(): () => void { installDiagnosticsEvents(), installAvailabilityEvents(), registerEditorContribution({ id: "lsp", order: 50, extensions: lspExtensions }), + // After the templates (order 90): live template / postfix expansion keeps ⇥ first. + registerEditorContribution({ id: "lsp.parameterTab", order: 95, extensions: () => parameterTabKeys }), registerOverlay({ id: "editor-lsp.popups", component: LspPopups }), registerStatusItem({ id: "editor-lsp.usagesStatus", align: "left", priority: 10, component: UsagesStatus }), + // IDEA's Documentation tool window: right side, listed once F1 moved documentation there. + registerToolWindow({ + id: DOCUMENTATION_WINDOW, + title: "Documentation", + icon: "info", + anchor: "right", + secondary: true, + stripe: "onUse", + order: 90, + component: DocumentationWindow, + }), registerLspActions(), registerNavigationActions(), registerFormatOnSave(), diff --git a/src/editor/lsp/inlay.test.ts b/src/editor/lsp/inlay.test.ts new file mode 100644 index 00000000..1b0deb77 --- /dev/null +++ b/src/editor/lsp/inlay.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EditorState, StateEffect } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import type { InlayHint } from "@/bindings"; + +const openFile = vi.fn(async () => undefined); +vi.mock("@/workbench/contributions", async (orig) => { + const actual = await orig(); + return { ...actual, maybeEditorService: () => ({ openFile }) }; +}); + +const { inlayDecorations, inlayMenuItems } = await import("./inlay"); +const { useContextMenuStore } = await import("@/ui"); + +const hint: InlayHint = { + position: { line: 0, col: 5 }, + label: ": Foo", + kind: "type", + paddingLeft: false, + paddingRight: false, + tooltip: null, + parts: [ + { value: ": ", tooltip: null, location: null }, + { + value: "Foo", + tooltip: "struct Foo", + location: { path: "/p/foo.rs", range: { start: { line: 4, col: 7 }, end: { line: 4, col: 10 } } }, + }, + ], +}; + +const views: EditorView[] = []; +afterEach(() => { + views.splice(0).forEach((v) => v.destroy()); + vi.clearAllMocks(); +}); + +function editorWith(hints: InlayHint[]) { + const view = new EditorView({ state: EditorState.create({ doc: "let x = foo();" }), parent: document.body }); + views.push(view); + view.dispatch({ effects: StateEffect.appendConfig.of(EditorView.decorations.of(inlayDecorations(view, hints))) }); + return view; +} + +describe("inlay hints", () => { + it("renders label parts; ⌘-click on a part with a location navigates there", () => { + const view = editorWith([hint]); + const part = view.dom.querySelector(".cm-lsp-inlay-part")!; + expect(part.textContent).toBe("Foo"); + expect(part.title).toBe("struct Foo"); + expect(view.dom.querySelector(".cm-lsp-inlay")!.textContent).toBe(": Foo"); + part.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 })); + expect(openFile).not.toHaveBeenCalled(); + part.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, metaKey: true, ctrlKey: true })); + expect(openFile).toHaveBeenCalledWith("/p/foo.rs", { selection: { line: 4, col: 7 }, focus: true }); + }); + + it("offers to disable hints of the clicked kind", () => { + const view = editorWith([hint]); + view.dom.querySelector(".cm-lsp-inlay")!.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true })); + const items = useContextMenuStore.getState().menu?.items ?? []; + expect(items.map((i) => ("label" in i ? i.label : i.kind))).toEqual([ + "Disable Type hints", + "separator", + "Inlay Hints Settings…", + ]); + expect((inlayMenuItems("parameter")[0] as { label: string }).label).toBe("Disable Parameter name hints"); + }); +}); diff --git a/src/editor/lsp/inlay.ts b/src/editor/lsp/inlay.ts index d93f51b0..f97ee238 100644 --- a/src/editor/lsp/inlay.ts +++ b/src/editor/lsp/inlay.ts @@ -1,16 +1,42 @@ /** * Inlay hints (settings.editor.inlayHints) for the visible range, refreshed * after edits / scrolling (debounced) and rendered as subtle inline widgets. + * Label parts that name a declaration navigate there on ⌘-click; right-click + * offers to disable hints of that kind. */ import { StateEffect, StateField, type Extension } from "@codemirror/state"; import { Decoration, EditorView, ViewPlugin, WidgetType, type DecorationSet, type ViewUpdate } from "@codemirror/view"; -import { commands, type InlayHint } from "@/platform/ipc"; +import { call, commands, isTauri, type InlayHint } from "@/platform/ipc"; +import { executeAction } from "@/platform/actions"; +import { hasPrimaryModifier } from "@/platform/keymap"; +import { maybeEditorService } from "@/workbench/contributions"; +import { showContextMenu, type MenuItem } from "@/ui"; import { useProject } from "@/platform/project"; import { editorPrefs } from "@/platform/editorSettings"; import { debounce, docOf, isUnavailable, lspRequest } from "./lsp"; import { offsetsToRange, posToOffset } from "./positions"; import { onLspRefresh, serverBackoff } from "./availability"; +const KIND_LABEL: Record = { + type: "Type hints", + parameter: "Parameter name hints", + other: "Other hints", +}; + +async function setEditorSetting(key: string, value: unknown) { + if (!isTauri()) return; + useProject.setState({ settings: await call(commands.settingsSet("global", `editor.${key}`, JSON.stringify(value))) }); +} + +/** Right-click on a hint: turn its kind off (IDEA's "Disable '…' hints"), or open the settings. */ +export function inlayMenuItems(kind: InlayHint["kind"]): MenuItem[] { + return [ + { label: `Disable ${KIND_LABEL[kind]}`, run: () => void setEditorSetting(`inlay.${kind}`, false) }, + { kind: "separator" }, + { label: "Inlay Hints Settings…", run: () => void executeAction("file.settings", "editor.inlayHints") }, + ]; +} + class InlayWidget extends WidgetType { constructor(readonly hint: InlayHint) { super(); @@ -18,16 +44,51 @@ class InlayWidget extends WidgetType { eq(other: InlayWidget) { const a = this.hint, b = other.hint; - return a.label === b.label && a.kind === b.kind && a.paddingLeft === b.paddingLeft && a.paddingRight === b.paddingRight; + return ( + a.label === b.label && + a.kind === b.kind && + a.paddingLeft === b.paddingLeft && + a.paddingRight === b.paddingRight && + (a.parts?.length ?? 0) === (b.parts?.length ?? 0) && + (a.parts ?? []).every((p, i) => p.location?.path === b.parts[i]?.location?.path && p.value === b.parts[i]?.value) + ); } - toDOM() { + toDOM(view: EditorView) { const el = document.createElement("span"); el.className = `cm-lsp-inlay cm-lsp-inlay-${this.hint.kind}`; if (this.hint.paddingLeft) el.classList.add("cm-lsp-inlay-pl"); if (this.hint.paddingRight) el.classList.add("cm-lsp-inlay-pr"); - el.textContent = this.hint.label; + const parts = this.hint.parts ?? []; + if (parts.some((p) => p.location || p.tooltip)) { + for (const part of parts) { + const span = document.createElement("span"); + span.textContent = part.value; + if (part.tooltip) span.title = part.tooltip; + if (part.location) { + span.className = "cm-lsp-inlay-part"; + const loc = part.location; + // ⌘-click (Ctrl elsewhere) navigates to the declaration the part names, as in IDEA. + span.addEventListener("mousedown", (e) => { + if (e.button !== 0 || !hasPrimaryModifier(e)) return; + e.preventDefault(); + e.stopPropagation(); + void maybeEditorService()?.openFile(loc.path, { + selection: { line: loc.range.start.line, col: loc.range.start.col }, + focus: true, + }); + }); + } + el.appendChild(span); + } + } else el.textContent = this.hint.label; if (this.hint.tooltip) el.title = this.hint.tooltip; el.setAttribute("aria-hidden", "true"); + el.addEventListener("contextmenu", (e) => { + e.preventDefault(); + e.stopPropagation(); + view.focus(); + showContextMenu(e, inlayMenuItems(this.hint.kind)); + }); return el; } ignoreEvent() { diff --git a/src/editor/lsp/inplaceRename.test.ts b/src/editor/lsp/inplaceRename.test.ts index 338f41bd..f444cd99 100644 --- a/src/editor/lsp/inplaceRename.test.ts +++ b/src/editor/lsp/inplaceRename.test.ts @@ -2,7 +2,13 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { EditorSelection, EditorState, type Extension } from "@codemirror/state"; import { history, undoDepth } from "@codemirror/commands"; import { EditorView } from "@codemirror/view"; -import { endInplaceRename, inplaceRenameActive, inplaceRenameExtension, startInplaceRename } from "./inplaceRename"; +import { + cycleSuggestion, + endInplaceRename, + inplaceRenameActive, + inplaceRenameExtension, + startInplaceRename, +} from "./inplaceRename"; const views: EditorView[] = []; afterEach(() => views.splice(0).forEach((v) => v.destroy())); @@ -84,4 +90,35 @@ describe("in-place rename edits elsewhere", () => { await Promise.resolve(); expect(commit).toHaveBeenCalledWith("bar"); }); + + it("cycles name suggestions with ↑ / ↓, mirrored and committed like typing", () => { + const view = new EditorView({ + state: EditorState.create({ doc: "let foo = 1; foo + foo;", extensions: [inplaceRenameExtension()] }), + }); + views.push(view); + const commit = vi.fn(); + startInplaceRename(view, { + primary: { from: 4, to: 7 }, + others: [{ from: 13, to: 16 }], + hint: "", + suggestions: ["count", "total"], + commit, + dialog: vi.fn(), + }); + expect(cycleSuggestion(view, 1)).toBe(true); + expect(view.state.doc.toString()).toBe("let count = 1; count + foo;"); + expect(view.state.selection.main).toMatchObject({ from: 4, to: 9 }); + cycleSuggestion(view, 1); + expect(view.state.doc.toString()).toBe("let total = 1; total + foo;"); + cycleSuggestion(view, 1); + expect(view.state.doc.toString()).toBe("let count = 1; count + foo;"); + endInplaceRename(view, "commit"); + expect(commit).toHaveBeenCalledWith("count"); + expect(view.state.doc.toString()).toBe("let foo = 1; foo + foo;"); + }); + + it("leaves the arrows alone without suggestions", () => { + const { view } = session(); + expect(cycleSuggestion(view, 1)).toBe(false); + }); }); diff --git a/src/editor/lsp/inplaceRename.ts b/src/editor/lsp/inplaceRename.ts index 7c1fab39..87f2a16c 100644 --- a/src/editor/lsp/inplaceRename.ts +++ b/src/editor/lsp/inplaceRename.ts @@ -11,6 +11,7 @@ */ import { Annotation, EditorSelection, EditorState, Prec, StateEffect, StateField, Transaction, type ChangeSet, type Extension } from "@codemirror/state"; import { Decoration, EditorView, ViewPlugin, keymap, showTooltip, type Tooltip, type ViewUpdate } from "@codemirror/view"; +import { completionStatus } from "@codemirror/autocomplete"; import { isExternalChange } from "@/editor/api"; export interface Span { @@ -30,6 +31,8 @@ export interface InplaceSession { commit: (newName: string) => void; /** ⇧F6 again: the rename dialog, prefilled with the typed name. */ dialog: (typed: string) => void; + /** Name suggestions listed under the name; ↑ / ↓ put the previous / next one in (Introduce Variable). */ + suggestions?: readonly string[]; } const startSession = StateEffect.define(); @@ -63,11 +66,30 @@ const sessionField = StateField.define({ ? { pos: s.primary.from, above: false, - create: () => { + create: (view) => { const dom = document.createElement("div"); dom.className = "cm-lsp-hint cm-lsp-inplace-hint"; dom.setAttribute("role", "status"); - dom.textContent = s.hint; + if (s.suggestions?.length) { + const current = view.state.sliceDoc(s.primary.from, s.primary.to); + const list = document.createElement("ul"); + list.className = "cm-lsp-inplace-suggestions"; + list.setAttribute("aria-label", "Suggested names"); + for (const name of s.suggestions) { + const li = document.createElement("li"); + li.textContent = name; + if (name === current) li.className = "cm-lsp-inplace-suggestion-active"; + li.addEventListener("mousedown", (e) => { + e.preventDefault(); + putSuggestion(view, name); + }); + list.appendChild(li); + } + dom.appendChild(list); + } + const hint = document.createElement("div"); + hint.textContent = s.hint; + dom.appendChild(hint); return { dom }; }, } @@ -117,6 +139,31 @@ export function startInplaceRename(view: EditorView, session: Omit 0 ? 0 : list.length - 1) : (i + delta + list.length) % list.length]!; + return putSuggestion(view, next); +} + /** * Ends the session, putting the old name back everywhere. Returns the name * that was typed (null without a session). `commit` then runs the rename. @@ -167,6 +214,8 @@ const keys = Prec.highest( { key: "Escape", run: (v) => endInplaceRename(v, "cancel") !== null }, // Undo while renaming restores the old name instead of undoing earlier edits. { key: "Mod-z", run: (v) => endInplaceRename(v, "cancel") !== null }, + { key: "ArrowDown", run: (v) => cycleSuggestion(v, 1) }, + { key: "ArrowUp", run: (v) => cycleSuggestion(v, -1) }, ]), ); diff --git a/src/editor/lsp/intentions.test.ts b/src/editor/lsp/intentions.test.ts new file mode 100644 index 00000000..7f0ae87f --- /dev/null +++ b/src/editor/lsp/intentions.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import type { CodeAction } from "@/bindings"; + +const resolved: CodeAction = { + title: "Introduce local variable", + kind: "refactor.extract", + isPreferred: false, + disabledReason: null, + edit: { + files: [ + { + kind: "edit", + path: "/p/a.ts", + edits: [{ range: { start: { line: 0, col: 0 }, end: { line: 0, col: 0 } }, new_text: "const x = 1;\n" }], + }, + { kind: "create", path: "/p/b.ts", overwrite: false }, + ], + }, + command: null, + data: null, + server: 1, +}; +const lspResolveCodeAction = vi.fn(async () => ({ status: "ok" as const, data: resolved })); +vi.mock("@/platform/ipc", async (orig) => { + const actual = await orig(); + return { ...actual, isTauri: () => true, commands: { ...actual.commands, lspResolveCodeAction } }; +}); + +const { intentionPreview } = await import("./intentions"); +const { lspDoc } = await import("./lsp"); + +const views: EditorView[] = []; +afterEach(() => { + views.splice(0).forEach((v) => v.destroy()); + vi.clearAllMocks(); +}); + +describe("intention preview", () => { + it("resolves the action once and shows its diff for this file", async () => { + const view = new EditorView({ + state: EditorState.create({ + doc: "f(1);", + extensions: [lspDoc.of({ doc: 1, path: "/p/a.ts", language: "typescript", uiMode: null, readOnly: false })], + }), + }); + views.push(view); + const action = { ...resolved, edit: null }; + const cache = new Map>(); + const signal = new AbortController().signal; + const preview = await intentionPreview(view, action, cache, signal); + expect(preview).toEqual({ + kind: "diff", + lines: [ + { kind: "added", text: "const x = 1;" }, + { kind: "context", text: "f(1);" }, + ], + note: "Also changes 1 other file", + }); + await intentionPreview(view, action, cache, signal); + expect(lspResolveCodeAction).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/editor/lsp/intentions.ts b/src/editor/lsp/intentions.ts index 14451360..82c5118c 100644 --- a/src/editor/lsp/intentions.ts +++ b/src/editor/lsp/intentions.ts @@ -12,7 +12,8 @@ import { shortcutLabel } from "@/platform/keymap"; import { debounce, docOf, isUnavailable, lspRequest, reportLspError } from "./lsp"; import { offsetsToRange } from "./positions"; import { showHint } from "./hint"; -import { caretAnchor, openPopup, type ListPopupItem } from "./popupStore"; +import { caretAnchor, openPopup, type ListPopupItem, type ListPreview } from "./popupStore"; +import { editPreviewLines } from "./workspaceEdit"; import { serverBackoff } from "./availability"; import { iconNode } from "./iconMarkup"; import { editorPrefs } from "@/platform/editorSettings"; @@ -121,17 +122,53 @@ export async function applyKindSilently(view: EditorView, kind: string): Promise return true; } -function actionItems(view: EditorView, actions: CodeAction[]): ListPopupItem[] { +function actionItems(view: EditorView, actions: CodeAction[], resolved?: Map>): ListPopupItem[] { return sortCodeActions(actions).map((a, i) => ({ id: `${i}`, label: a.title, icon: isQuickFix(a) ? "bulb-fix" : "bulb", emphasis: a.isPreferred, disabled: a.disabledReason, - run: () => applyCodeAction(view, a), + // The edit the preview already resolved is applied as is. + run: async () => applyCodeAction(view, (await resolved?.get(a)?.catch(() => a)) ?? a), })); } +/** The action with its edit (resolved once per popup; servers may compute edits lazily). */ +function resolveOnce(doc: number, action: CodeAction, cache: Map>): Promise { + let p = cache.get(action); + if (!p) { + p = action.edit || action.command ? Promise.resolve(action) : lspRequest(() => commands.lspResolveCodeAction(doc, action)); + cache.set(action, p); + p.catch(() => cache.delete(action)); + } + return p; +} + +/** Intention preview (IDEA's side panel): the diff the action would make to this file. */ +export async function intentionPreview( + view: EditorView, + action: CodeAction, + cache: Map>, + signal: AbortSignal, +): Promise { + const ctx = docOf(view); + if (!ctx) return null; + const doc = view.state.doc; + const full = await resolveOnce(ctx.doc, action, cache); + if (signal.aborted || view.state.doc !== doc) return null; + const files = full.edit?.files ?? []; + const here = files.find((f) => f.kind === "edit" && f.path === ctx.path); + const others = new Set(files.map((f) => (f.kind === "rename" ? f.from : f.path)).filter((p) => p !== ctx.path)).size; + const note = others ? `Also changes ${others} other ${others === 1 ? "file" : "files"}` : undefined; + if (here?.kind === "edit") { + const lines = editPreviewLines(doc, here.edits); + if (lines.length) return { kind: "diff", lines, note }; + } + if (note) return { kind: "text", text: note }; + return { kind: "text", text: full.command && !full.edit ? "Preview isn't available for this action" : "No changes in this file" }; +} + /** ⌥↵ */ export async function showIntentions(view: EditorView) { try { @@ -141,11 +178,18 @@ export async function showIntentions(view: EditorView) { showHint(view, "No intentions available"); return; } + const sorted = sortCodeActions(actions); + const resolved = new Map>(); + const items = actionItems(view, sorted, resolved); openPopup({ kind: "list", anchor: caretAnchor(view, view.state.selection.main.head), title: "Intentions", - items: actionItems(view, actions), + items, + preview: (item, signal) => { + const action = sorted[items.indexOf(item)]; + return action ? intentionPreview(view, action, resolved, signal) : Promise.resolve(null); + }, }); } catch (e) { reportLspError(view, e, true); diff --git a/src/editor/lsp/markdown.ts b/src/editor/lsp/markdown.ts index b07cf978..fbae9e53 100644 --- a/src/editor/lsp/markdown.ts +++ b/src/editor/lsp/markdown.ts @@ -158,29 +158,57 @@ function highlightBlocks(root: HTMLElement, fallback: string | null) { }); } -function onLinkClick(e: MouseEvent) { - const a = (e.target as Element | null)?.closest?.("a"); - if (!a) return; - e.preventDefault(); - const href = a.getAttribute("href") ?? ""; +/** A documentation link to a source position (`file://…#L12,5`, `jdt://…#L42`); null for web links. */ +export function sourceLink(href: string): { path: string; line: number; col: number } | null { if (href.startsWith("file://")) { - const url = new URL(href); + let url: URL; + try { + url = new URL(href); + } catch { + return null; + } const m = /^#?L?(\d+)(?:[,:](\d+))?/.exec(url.hash.slice(1)); - const selection = m ? { line: Math.max(0, Number(m[1]) - 1), col: m[2] ? Math.max(0, Number(m[2]) - 1) : 0 } : undefined; - void maybeEditorService()?.openFile(decodeURIComponent(url.pathname), { selection, focus: true }); - } else if (isVirtualUri(href) && !/^https?:/i.test(href) && !/^mailto:/i.test(href)) { + return { + path: decodeURIComponent(url.pathname), + line: m ? Math.max(0, Number(m[1]) - 1) : 0, + col: m?.[2] ? Math.max(0, Number(m[2]) - 1) : 0, + }; + } + if (isVirtualUri(href) && !/^https?:/i.test(href) && !/^mailto:/i.test(href)) { // Library class links (jdtls hover: `jdt://contents/…#L42`). const hash = href.indexOf("#"); const m = hash >= 0 ? /^L?(\d+)/.exec(href.slice(hash + 1)) : null; - const selection = m ? { line: Math.max(0, Number(m[1]) - 1), col: 0 } : undefined; - void maybeEditorService()?.openFile(hash >= 0 ? href.slice(0, hash) : href, { selection, focus: true }); + return { path: hash >= 0 ? href.slice(0, hash) : href, line: m ? Math.max(0, Number(m[1]) - 1) : 0, col: 0 }; + } + return null; +} + +/** Opens a documentation link: source links in the editor, web links in the browser. */ +export function openDocLink(href: string) { + const target = sourceLink(href); + if (target) { + void maybeEditorService()?.openFile(target.path, { selection: { line: target.line, col: target.col }, focus: true }); } else if (/^https?:\/\//.test(href) && isTauri()) { void import("@tauri-apps/plugin-opener").then((m) => m.openUrl(href)).catch(() => {}); } } +/** Handles a link click itself (true) or leaves it to `openDocLink` (false). */ +export type DocLinkHandler = (href: string, e: MouseEvent) => boolean; + +function linkClickHandler(onLink?: DocLinkHandler) { + return (e: MouseEvent) => { + const a = (e.target as Element | null)?.closest?.("a"); + if (!a) return; + e.preventDefault(); + const href = a.getAttribute("href") ?? ""; + if (onLink?.(href, e)) return; + openDocLink(href); + }; +} + /** Renders markup into a `.cm-lsp-markdown` element. */ -export function renderMarkup(m: Markup, fallbackLanguage: string | null = null): HTMLElement { +export function renderMarkup(m: Markup, fallbackLanguage: string | null = null, onLink?: DocLinkHandler): HTMLElement { const dom = document.createElement("div"); dom.className = "cm-lsp-markdown"; dom.innerHTML = markupToHtml(m); @@ -193,7 +221,7 @@ export function renderMarkup(m: Markup, fallbackLanguage: string | null = null): }) .catch(() => {}); } - dom.addEventListener("click", onLinkClick); + dom.addEventListener("click", linkClickHandler(onLink)); return dom; } diff --git a/src/editor/lsp/navigation.ts b/src/editor/lsp/navigation.ts index 4b648e22..8d1705a2 100644 --- a/src/editor/lsp/navigation.ts +++ b/src/editor/lsp/navigation.ts @@ -42,7 +42,7 @@ import { offsetToPos, rangeToOffsets } from "./positions"; import { SYNTAX_NOTE, declarationFallback, isSelfTarget, localHighlights, type DeclarationTarget } from "./syntaxFallback"; import { showHint } from "./hint"; import { caretAnchor, openPopup, type ListPopupItem } from "./popupStore"; -import { readManyFileLines } from "./files"; +import { identifierAt, readManyFileLines } from "./files"; import { documentLinks, followReference, knownLinks, linkReference, referenceAt, textReference } from "./links"; import { serverBackoff } from "./availability"; import { requestHover } from "./hover"; @@ -100,10 +100,12 @@ export async function locationItems(locs: readonly DeclarationTarget[]): Promise symbol: l.kind, run: () => navigateTo(l), }; + // Server targets: the declared name (speed search matches it), file and line after it. + const name = identifierAt(files.get(l.path)?.[l.range.start.line] ?? "", l.range.start.col); return { id: `${i}`, - label: basename(l.path), - detail: where, + label: name ?? basename(l.path), + detail: name ? `${basename(l.path)} · ${where}` : where, preview, icon: fileIconName(basename(l.path)), run: () => navigateTo(l), @@ -111,6 +113,19 @@ export async function locationItems(locs: readonly DeclarationTarget[]): Promise }); } +/** "Choose Implementation of foo (3 found)". */ +export function chooserTitle(kind: GotoKind, name: string | null, count: number, fromSyntax = false): string { + return `${TITLES[kind]}${name ? ` of ${name}` : ""} (${count} found${fromSyntax ? ", based on syntax" : ""})`; +} + +/** Pin button of the targets chooser: the targets as a Find tool window tab (code-insight). */ +function pinToFind(symbol: string, locations: readonly Location[]) { + return { + label: "Open in Find Tool Window", + run: () => void executeAction("find.showUsageResults", { symbol, locations }), + }; +} + export async function gotoTargets(view: EditorView, kind: GotoKind, at?: number) { const offset = at ?? view.state.selection.main.head; if (kind === "definition" || kind === "declaration") { @@ -169,11 +184,14 @@ export async function gotoTargets(view: EditorView, kind: GotoKind, at?: number) }, 150); return; } + const word = view.state.wordAt(offset); + const name = word ? view.state.sliceDoc(word.from, word.to) : null; openPopup({ kind: "list", anchor: caretAnchor(view, offset), - title: `${TITLES[kind]} (${locs.length} found${fromSyntax ? ", based on syntax" : ""})`, + title: chooserTitle(kind, name, locs.length, fromSyntax), items: await locationItems(locs), + pin: pinToFind(name ?? "", locs), }); } @@ -207,6 +225,7 @@ async function gotoWebReference(view: EditorView, doc: EditorDocContext, pos: Po anchor: caretAnchor(view, offset), title: `Choose Declaration of '${label}' (${targets.length} found)`, items: await locationItems(targets), + pin: pinToFind(label, targets), }); return true; } diff --git a/src/editor/lsp/popupStore.ts b/src/editor/lsp/popupStore.ts index 3f827a31..f098dab2 100644 --- a/src/editor/lsp/popupStore.ts +++ b/src/editor/lsp/popupStore.ts @@ -24,20 +24,81 @@ export interface ListPopupItem { disabled?: string | null; shortcut?: string; run: () => void | Promise; + /** Called when the row becomes the selected one (e.g. highlight what it would act on). */ + onActive?: () => void; +} + +/** Side panel content for the selected row of a list popup (intention preview). */ +export type ListPreview = + | { kind: "diff"; lines: DiffLine[]; /** Other files the change touches. */ note?: string } + | { kind: "text"; text: string }; + +export interface DiffLine { + kind: "context" | "added" | "removed" | "gap"; + text: string; +} + +export interface DocEntry { + markup: Markup; + language: string | null; + /** Declaration of the documented element (Edit Source), resolved on demand. */ + source?: () => Promise; +} + +export interface DefinitionTarget { + location: Location; + /** Name shown in the switcher (symbol or file name). */ + name: string; + firstLine: number; + text: string; + language: string | null; +} + +export interface ReformatOptions { + selectionOnly: boolean; + optimizeImports: boolean; } export type LspPopup = - | { kind: "doc"; anchor: PopupPoint; markup: Markup; language: string | null; themeClasses: string } + | { + kind: "doc"; + anchor: PopupPoint; + /** Navigation history (links followed inside the popup); `index` is shown. */ + entries: DocEntry[]; + index: number; + themeClasses: string; + } | { kind: "definition"; anchor: PopupPoint; - location: Location; - firstLine: number; - text: string; - language: string | null; + /** Every target (⌥Space on an overloaded / multiply defined symbol); `index` is shown. */ + targets: DefinitionTarget[]; + index: number; themeClasses: string; } - | { kind: "list"; anchor: PopupPoint; title: string; items: ListPopupItem[] } + | { + kind: "list"; + anchor: PopupPoint; + title: string; + items: ListPopupItem[]; + /** Rows 1-9 get a number; typing it runs the row (IDEA's numbered popups). */ + numbered?: boolean; + /** Header pin button (e.g. "Open in Find Tool Window"). */ + pin?: { label: string; run: () => void }; + /** Side panel for the selected row, fetched lazily; `signal` aborts when the selection moves. */ + preview?: (item: ListPopupItem, signal: AbortSignal) => Promise; + /** Runs when the popup goes away (closed, replaced or a row ran). */ + onClose?: () => void; + } + | { + kind: "reformat"; + title: string; + hasSelection: boolean; + /** Whether the server offers Optimize Imports for the file. */ + canOptimizeImports: boolean; + initial: ReformatOptions; + onRun: (options: ReformatOptions) => void; + } | { kind: "rename"; anchor: PopupPoint; @@ -85,12 +146,27 @@ export function focusPopup() { useLspPopup.setState((s) => ({ focusSeq: s.focusSeq + 1 })); } +function disposed(popup: LspPopup | null) { + if (popup?.kind === "list") popup.onClose?.(); +} + export function openPopup(popup: LspPopup) { + const previous = useLspPopup.getState().popup; useLspPopup.setState((s) => ({ popup, seq: s.seq + 1 })); + if (previous !== popup) disposed(previous); } export function closePopup() { + const previous = useLspPopup.getState().popup; useLspPopup.setState({ popup: null }); + disposed(previous); +} + +/** Replaces the open popup's state without remounting it (doc history, definition switching). */ +export function updatePopup(kind: K, patch: Partial>): void { + const current = useLspPopup.getState().popup; + if (current?.kind !== kind) return; + useLspPopup.setState({ popup: { ...current, ...patch } as LspPopup }); } /** Screen point just below `pos` (falls back to the editor's top-left). */ diff --git a/src/editor/lsp/signature.test.ts b/src/editor/lsp/signature.test.ts index 5b3cd65f..51cd1d97 100644 --- a/src/editor/lsp/signature.test.ts +++ b/src/editor/lsp/signature.test.ts @@ -11,7 +11,9 @@ vi.mock("@/platform/ipc", async (orig) => { return { ...actual, isTauri: () => true, commands: { ...actual.commands, lspSignatureHelp } }; }); -const { signatureExtension, acceptsParameter } = await import("./signature"); +const { signatureExtension, acceptsParameter, argumentSpans, argumentStop, parameterTabKeys, triggerSignatureHelp } = await import( + "./signature" +); const { lspDoc } = await import("./lsp"); // jsdom has no layout; CodeMirror measures text when the tooltip is positioned. @@ -62,3 +64,53 @@ describe("acceptsParameter", () => { expect(acceptsParameter({ label: "format(String f, Object... a)", parameters: [[7, 15], [17, 28]] }, 4)).toBe(true); }); }); + +describe("argument navigation", () => { + const at = (src: string) => ({ text: src.replace("|", ""), pos: src.indexOf("|") }); + const stop = (src: string, dir: 1 | -1) => { + const { text, pos } = at(src); + const to = argumentStop(text, pos, dir); + return to === null ? null : `${text.slice(0, to)}|${text.slice(to)}`; + }; + + it("splits arguments at top-level commas, outside strings and nested brackets", () => { + const text = 'f(a, g(b, c), "x, y", [1, 2])'; + expect(argumentSpans(text, 1).map((s) => text.slice(s.from, s.to))).toEqual(["a", "g(b, c)", '"x, y"', "[1, 2]"]); + expect(argumentSpans("f()", 1)).toEqual([]); + }); + + it("moves to the start of the next / previous argument, not past the ends", () => { + expect(stop("f(a|b, c)", 1)).toBe("f(ab, |c)"); + expect(stop("f(ab, c|)", -1)).toBe("f(|ab, c)"); + expect(stop("f(ab,| c)", 1)).toBeNull(); + expect(stop("f(ab, c|)", 1)).toBeNull(); + expect(stop("f(a, |)", -1)).toBe("f(|a, )"); + expect(stop("f(a, g(|x, y), z)", 1)).toBe("f(a, g(x, |y), z)"); + expect(stop('f("(", |b)', -1)).toBe('f(|"(", b)'); + }); + + it("⇥ moves while parameter info is shown and is left alone otherwise", async () => { + const text = "f(a, b)"; + const view = new EditorView({ + state: EditorState.create({ + doc: text, + selection: { anchor: 3 }, + extensions: [ + lspDoc.of({ doc: 1, path: "/a.ts", language: "typescript", uiMode: null, readOnly: false }), + signatureExtension(), + parameterTabKeys, + ], + }), + parent: document.body, + }); + views.push(view); + const tab = () => + view.contentDOM.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", keyCode: 9, bubbles: true, cancelable: true })); + expect(tab()).toBe(true); // not handled: no parameter info + expect(view.state.selection.main.head).toBe(3); + triggerSignatureHelp(view, true); + await vi.waitFor(() => expect(view.dom.querySelector(".cm-lsp-signature")).not.toBeNull()); + expect(tab()).toBe(false); + expect(view.state.selection.main.head).toBe(5); + }); +}); diff --git a/src/editor/lsp/signature.ts b/src/editor/lsp/signature.ts index bd865003..e4313328 100644 --- a/src/editor/lsp/signature.ts +++ b/src/editor/lsp/signature.ts @@ -1,9 +1,11 @@ /** * Parameter info (⌘P, and automatically after `(` / `,`): a tooltip above the * caret listing the signatures with the active parameter in bold. While open - * it follows the caret and closes when the server reports no signature. + * it follows the caret and closes when the server reports no signature; + * ⇥ / ⇧⇥ move to the next / previous argument. */ -import { Prec, StateEffect, StateField, type Extension } from "@codemirror/state"; +import { EditorSelection, Prec, StateEffect, StateField, type Extension } from "@codemirror/state"; +import { completionStatus, hasNextSnippetField, hasPrevSnippetField } from "@codemirror/autocomplete"; import { EditorView, ViewPlugin, keymap, showTooltip, type Tooltip, type ViewUpdate } from "@codemirror/view"; import { commands, type SignatureHelp } from "@/platform/ipc"; import { docOf, lspRequest, reportLspError, debounce } from "./lsp"; @@ -11,6 +13,7 @@ import { offsetToPos } from "./positions"; import { showHint } from "./hint"; import { renderMarkup, isEmptyMarkup } from "./markdown"; import { editorPrefs } from "@/platform/editorSettings"; +import { inplaceRenameActive } from "./inplaceRename"; interface SigState { pos: number; @@ -140,6 +143,112 @@ const signaturePlugin = ViewPlugin.fromClass( }, ); +// --------------------------------------------------------------------------- +// ⇥ / ⇧⇥: next / previous argument while parameter info is shown + +const OPEN = "([{"; +const CLOSE = ")]}"; + +/** Skips a string literal starting at `i` (its quote); returns the index after it. */ +function skipString(text: string, i: number): number { + const q = text[i]!; + let j = i + 1; + while (j < text.length && text[j] !== q) { + if (text[j] === "\\") j++; + else if (text[j] === "\n" && q !== "`") break; + j++; + } + return j + 1; +} + +const isQuote = (c: string) => c === '"' || c === "'" || c === "`"; + +/** The `(` of the innermost call around `pos`, scanning brackets outside strings and line comments. */ +export function enclosingParen(text: string, pos: number, window = 4000): number | null { + const start = Math.max(0, pos - window); + const stack: number[] = []; + for (let i = start; i < pos; i++) { + const c = text[i]!; + if (isQuote(c) && !(c === "'" && /\w/.test(text[i - 1] ?? ""))) { + i = skipString(text, i) - 1; + } else if (c === "/" && text[i + 1] === "/") { + while (i < pos && text[i] !== "\n") i++; + } else if (OPEN.includes(c)) stack.push(i); + else if (CLOSE.includes(c)) stack.pop(); + } + for (let k = stack.length - 1; k >= 0; k--) if (text[stack[k]!] === "(") return stack[k]!; + return null; +} + +/** Argument spans (trimmed; empty ones collapse to where they would start) of the call opened at `open`. */ +export function argumentSpans(text: string, open: number): { from: number; to: number }[] { + const out: { from: number; to: number }[] = []; + let depth = 0; + let argStart = open + 1; + const push = (end: number) => { + let from = argStart; + let to = end; + while (from < to && /\s/.test(text[from]!)) from++; + while (to > from && /\s/.test(text[to - 1]!)) to--; + out.push({ from, to }); + }; + for (let i = open + 1; i < text.length; i++) { + const c = text[i]!; + if (isQuote(c) && !(c === "'" && /\w/.test(text[i - 1] ?? ""))) i = skipString(text, i) - 1; + else if (OPEN.includes(c)) depth++; + else if (CLOSE.includes(c)) { + if (depth === 0) { + push(i); + return out.length === 1 && out[0]!.from === out[0]!.to ? [] : out; + } + depth--; + } else if (c === "," && depth === 0) { + push(i); + argStart = i + 1; + } + } + return out; +} + +/** Caret target of ⇥ (`dir` 1) / ⇧⇥ (-1) in the call around `pos`; null at the ends (the key then does its usual thing). */ +export function argumentStop(text: string, pos: number, dir: 1 | -1): number | null { + const open = enclosingParen(text, pos); + if (open === null) return null; + const args = argumentSpans(text, open); + if (!args.length) return null; + // The argument the caret is in: past an argument's separating comma it is that argument. + let current = 0; + for (let i = 1; i < args.length; i++) if (pos > text.lastIndexOf(",", args[i]!.from)) current = i; + const target = args[current + dir]; + return target ? target.from : null; +} + +function moveToArgument(view: EditorView, dir: 1 | -1): boolean { + const state = view.state; + if (!state.field(signatureField, false) || state.readOnly) return false; + if (state.selection.ranges.length > 1 || completionStatus(state) === "active") return false; + // Snippet / template fields and the in-place rename own ⇥. + if (dir > 0 ? hasNextSnippetField(state) : hasPrevSnippetField(state)) return false; + if (inplaceRenameActive(state)) return false; + const head = state.selection.main.head; + const to = argumentStop(state.doc.toString(), head, dir); + if (to === null || to === head) return false; + view.dispatch({ selection: EditorSelection.cursor(to), scrollIntoView: true, userEvent: "select" }); + return true; +} + +/** + * Next / Previous Parameter keys. Registered after the template contributions + * (see `index.ts`) so live template / postfix expansion keeps ⇥ first; the + * lookup, snippet fields and the in-place rename are checked explicitly. + */ +export const parameterTabKeys: Extension = Prec.high( + keymap.of([ + { key: "Tab", run: (v) => moveToArgument(v, 1) }, + { key: "Shift-Tab", run: (v) => moveToArgument(v, -1) }, + ]), +); + export function signatureExtension(): Extension { return [ signatureField, diff --git a/src/editor/lsp/syntaxFallback.test.ts b/src/editor/lsp/syntaxFallback.test.ts index 052deb2f..8170d893 100644 --- a/src/editor/lsp/syntaxFallback.test.ts +++ b/src/editor/lsp/syntaxFallback.test.ts @@ -274,7 +274,8 @@ describe("navigation wiring", () => { const popup = useLspPopup.getState().popup; expect(popup?.kind).toBe("list"); if (popup?.kind !== "list") return; - expect(popup.title).toBe("Choose Declaration (2 found, based on syntax)"); + expect(popup.title).toBe("Choose Declaration of run (2 found, based on syntax)"); + expect(popup.pin?.label).toBe("Open in Find Tool Window"); expect(popup.items.map((i) => [i.label, i.symbol, i.detail?.split(" · ")[0]])).toEqual([ ["run", "method", "A"], ["run", "method", "B"], diff --git a/src/editor/lsp/tempHighlight.ts b/src/editor/lsp/tempHighlight.ts new file mode 100644 index 00000000..9cba8aa9 --- /dev/null +++ b/src/editor/lsp/tempHighlight.ts @@ -0,0 +1,58 @@ +/** + * Temporary range highlight (styled in `theme.ts`) while a chooser moves its selection (the + * expression / occurrences an Introduce refactoring would take, a postfix + * template's target…), as in IDEA. Not part of the document state: any + * edit clears it, and callers clear it when their popup closes. + */ +import { StateEffect, StateField, type Extension } from "@codemirror/state"; +import { Decoration, EditorView } from "@codemirror/view"; + +export interface HighlightRange { + from: number; + to: number; +} + +const setTemporary = StateEffect.define(); + +const mark = Decoration.mark({ class: "cm-lsp-chooser-highlight" }); + +const temporaryField = StateField.define({ + create: () => [], + update(value, tr) { + for (const e of tr.effects) if (e.is(setTemporary)) return e.value; + return tr.docChanged && value.length ? [] : value; + }, + provide: (f) => + EditorView.decorations.from(f, (ranges) => + Decoration.set( + ranges.filter((r) => r.from < r.to).map((r) => mark.range(r.from, r.to)), + true, + ), + ), +}); + +/** Highlights `ranges` until the next call, `clearTemporaryHighlight` or an edit; scrolls the first into view. */ +export function highlightRanges(view: EditorView, ranges: readonly HighlightRange[]): void { + const len = view.state.doc.length; + const clamped = ranges + .map((r) => ({ from: Math.max(0, Math.min(r.from, len)), to: Math.max(0, Math.min(r.to, len)) })) + .sort((a, b) => a.from - b.from); + const effects: StateEffect[] = [setTemporary.of(clamped)]; + if (!view.state.field(temporaryField, false)) effects.push(StateEffect.appendConfig.of(temporaryHighlightExtension())); + const first = clamped[0]; + if (first) effects.push(EditorView.scrollIntoView(first.from, { y: "nearest" })); + view.dispatch({ effects }); +} + +export function clearTemporaryHighlight(view: EditorView): void { + const current = view.state.field(temporaryField, false); + if (current?.length) view.dispatch({ effects: setTemporary.of([]) }); +} + +export function temporaryHighlights(view: EditorView): readonly HighlightRange[] { + return view.state.field(temporaryField, false) ?? []; +} + +export function temporaryHighlightExtension(): Extension { + return [temporaryField]; +} diff --git a/src/editor/lsp/theme.ts b/src/editor/lsp/theme.ts index df8ba2b2..d14aa914 100644 --- a/src/editor/lsp/theme.ts +++ b/src/editor/lsp/theme.ts @@ -256,6 +256,8 @@ export const lspTheme = EditorView.theme({ backgroundColor: "var(--bg-identifier-under-caret-write, color-mix(in srgb, var(--danger) 12%, transparent))", }, ".cm-lsp-nav-info": { padding: "4px 8px" }, + // Chooser highlight (tempHighlight.ts): the expression / occurrences a choice would take. + ".cm-lsp-chooser-highlight": { backgroundColor: "var(--bg-text-search-result)" }, // --- in-place rename (inplaceRename.ts) ------------------------------------ ".cm-lsp-inplace": { @@ -267,6 +269,17 @@ export const lspTheme = EditorView.theme({ borderRadius: "2px", }, ".cm-lsp-inplace-hint": { color: "var(--fg-secondary)", fontSize: "var(--fs-small)", padding: "3px 8px" }, + ".cm-lsp-inplace-suggestions": { + listStyle: "none", + margin: "0 -8px 4px", + padding: "0", + fontFamily: "var(--openidea-editor-font, var(--font-mono))", + fontSize: "var(--fs-ui)", + color: "var(--fg)", + }, + ".cm-lsp-inplace-suggestions li": { padding: "1px 8px", cursor: "pointer" }, + ".cm-lsp-inplace-suggestions li:hover": { backgroundColor: "var(--bg-hover)" }, + ".cm-lsp-inplace-suggestion-active": { backgroundColor: "var(--bg-selected)" }, // --- floating lightbulb (intentions.ts) ----------------------------------- ".cm-lsp-bulb": { @@ -295,6 +308,8 @@ export const lspTheme = EditorView.theme({ WebkitUserSelect: "none", userSelect: "none", }, + // A part naming a declaration (⌘-click navigates there). + ".cm-lsp-inlay-part:hover": { textDecoration: "underline", textUnderlineOffset: "2px" }, ".cm-lsp-inlay-pl": { marginLeft: "4px" }, ".cm-lsp-inlay-pr": { marginRight: "4px" }, diff --git a/src/editor/lsp/typeInfo.ts b/src/editor/lsp/typeInfo.ts index 389c4b09..8b7d582e 100644 --- a/src/editor/lsp/typeInfo.ts +++ b/src/editor/lsp/typeInfo.ts @@ -67,8 +67,10 @@ export async function expressionTypeInfo(view: EditorView) { openPopup({ kind: "doc", anchor: caretAnchor(view, hover.range ? rangeToOffsets(view.state.doc, hover.range).from : pos), - markup: { kind: "markdown", value: `\`\`\`${language}\n${info.text}\n\`\`\`` }, - language: docOf(view)?.uiMode ?? null, + entries: [ + { markup: { kind: "markdown", value: `\`\`\`${language}\n${info.text}\n\`\`\`` }, language: docOf(view)?.uiMode ?? null }, + ], + index: 0, themeClasses: view.themeClasses, }); } catch (e) { diff --git a/src/editor/lsp/workspaceEdit.test.ts b/src/editor/lsp/workspaceEdit.test.ts index b5c537f3..448bc014 100644 --- a/src/editor/lsp/workspaceEdit.test.ts +++ b/src/editor/lsp/workspaceEdit.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { describeSummary, needsPreview, summarizeWorkspaceEdit } from "./workspaceEdit"; +import { Text } from "@codemirror/state"; +import { describeSummary, editPreviewLines, needsPreview, summarizeWorkspaceEdit } from "./workspaceEdit"; const edit = (line: number) => ({ range: { start: { line, col: 0 }, end: { line, col: 3 } }, new_text: "new" }); @@ -47,3 +48,31 @@ describe("summarizeWorkspaceEdit", () => { expect(s).toMatchObject({ files: 0, occurrences: 0 }); }); }); + +describe("editPreviewLines", () => { + const doc = Text.of(["function f() {", " return price * 2;", "}", "", "const a = 1;", "const b = 2;", "const c = 3;"]); + const at = (line: number, col: number) => ({ line, col }); + + it("shows changed lines with one line of context, hunks separated", () => { + const lines = editPreviewLines(doc, [ + { range: { start: at(1, 2), end: at(1, 2) }, new_text: "const x = price * 2;\n " }, + { range: { start: at(1, 9), end: at(1, 18) }, new_text: "x" }, + { range: { start: at(6, 10), end: at(6, 11) }, new_text: "4" }, + ]); + expect(lines).toEqual([ + { kind: "context", text: "function f() {" }, + { kind: "removed", text: " return price * 2;" }, + { kind: "added", text: " const x = price * 2;" }, + { kind: "added", text: " return x;" }, + { kind: "context", text: "}" }, + { kind: "gap", text: "" }, + { kind: "context", text: "const b = 2;" }, + { kind: "removed", text: "const c = 3;" }, + { kind: "added", text: "const c = 4;" }, + ]); + }); + + it("is empty when nothing changes", () => { + expect(editPreviewLines(doc, [{ range: { start: at(0, 0), end: at(0, 8) }, new_text: "function" }])).toEqual([]); + }); +}); diff --git a/src/editor/lsp/workspaceEdit.ts b/src/editor/lsp/workspaceEdit.ts index 6543dc9e..1ad882d6 100644 --- a/src/editor/lsp/workspaceEdit.ts +++ b/src/editor/lsp/workspaceEdit.ts @@ -1,5 +1,8 @@ -/** Summaries of workspace edits for rename / refactoring previews. */ -import type { WorkspaceEdit } from "@/bindings"; +/** Summaries of workspace edits for rename / refactoring previews, and the intention preview diff. */ +import { ChangeSet, type Text } from "@codemirror/state"; +import type { TextEdit, WorkspaceEdit } from "@/bindings"; +import type { DiffLine } from "./popupStore"; +import { textEditsToChanges } from "./positions"; export interface WorkspaceEditFileSummary { path: string; @@ -70,3 +73,68 @@ export function describeSummary(s: WorkspaceEditSummary): string { export function needsPreview(s: WorkspaceEditSummary): boolean { return s.files > 1 || s.hasFileOperations; } + +/** Line diff of `a` → `b` (LCS; both short: they are the changed lines of one hunk). */ +function lineDiff(a: readonly string[], b: readonly string[]): DiffLine[] { + const n = a.length; + const m = b.length; + if (n * m > 250_000) return [...a.map((text) => ({ kind: "removed" as const, text })), ...b.map((text) => ({ kind: "added" as const, text }))]; + const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0)); + for (let i = n - 1; i >= 0; i--) + for (let j = m - 1; j >= 0; j--) lcs[i]![j] = a[i] === b[j] ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!); + const out: DiffLine[] = []; + let i = 0; + let j = 0; + while (i < n || j < m) { + if (i < n && j < m && a[i] === b[j]) { + out.push({ kind: "context", text: a[i]! }); + i++; + j++; + } else if (i < n && (j >= m || lcs[i + 1]![j]! >= lcs[i]![j + 1]!)) out.push({ kind: "removed", text: a[i++]! }); + else out.push({ kind: "added", text: b[j++]! }); + } + return out; +} + +const lines = (doc: Text, first: number, last: number) => { + const out: string[] = []; + for (let n = first; n <= last; n++) out.push(doc.line(n).text); + return out; +}; + +/** + * The intention preview of `edits` to `doc`: changed lines with `context` + * unchanged lines around each hunk, hunks separated by a gap line, at most + * `maxLines` lines. + */ +export function editPreviewLines(doc: Text, edits: readonly TextEdit[], context = 1, maxLines = 60): DiffLine[] { + const changes = textEditsToChanges(doc, edits); + if (!changes.length) return []; + const set = ChangeSet.of(changes, doc.length); + const after = set.apply(doc); + // Hunks: changed old-line ranges, merged when their contexts touch. + const hunks: { a1: number; a2: number }[] = []; + for (const c of changes) { + const a1 = doc.lineAt(c.from).number; + const a2 = doc.lineAt(c.to).number; + const last = hunks[hunks.length - 1]; + if (last && a1 - last.a2 <= 2 * context + 1) last.a2 = Math.max(last.a2, a2); + else hunks.push({ a1, a2 }); + } + const out: DiffLine[] = []; + for (const h of hunks) { + if (out.length) out.push({ kind: "gap", text: "" }); + const from = doc.line(h.a1).from; + const to = doc.line(h.a2).to; + const b1 = after.lineAt(set.mapPos(from, -1)).number; + const b2 = after.lineAt(set.mapPos(to, 1)).number; + const c1 = Math.max(1, h.a1 - context); + const c2 = Math.min(doc.lines, h.a2 + context); + for (const text of lines(doc, c1, h.a1 - 1)) out.push({ kind: "context", text }); + out.push(...lineDiff(lines(doc, h.a1, h.a2), lines(after, b1, b2))); + for (const text of lines(doc, h.a2 + 1, c2)) out.push({ kind: "context", text }); + if (out.length >= maxLines) break; + } + if (out.length <= maxLines) return out; + return [...out.slice(0, maxLines), { kind: "gap", text: "" }]; +} diff --git a/src/editor/refactor/introduce.ts b/src/editor/refactor/introduce.ts index 3a45f616..5142bc17 100644 --- a/src/editor/refactor/introduce.ts +++ b/src/editor/refactor/introduce.ts @@ -16,6 +16,7 @@ import { docOf } from "@/editor/lsp/lsp"; import { offsetsToRange, rangeToOffsets } from "@/editor/lsp/positions"; import { showHint } from "@/editor/lsp/hint"; import { caretAnchor, openPopup } from "@/editor/lsp/popupStore"; +import { clearTemporaryHighlight, highlightRanges } from "@/editor/lsp/tempHighlight"; import { call, commands, isCancelled, nextRequestId } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { expressionWords, findTextOccurrences, normalizeExpression } from "./occurrences"; @@ -150,7 +151,8 @@ function isCallee(doc: Text, node: SyntaxNode, outer: SyntaxNode | undefined): b return /^\s*(\(|<|::<)/.test(doc.sliceString(end, end + 4)); } -function preview(text: string): string { +/** Chooser label of an expression: one line, shortened. */ +export function expressionPreview(text: string): string { const one = text.replace(/\s+/g, " ").trim(); return one.length > 80 ? `${one.slice(0, 77)}…` : one; } @@ -194,7 +196,7 @@ async function introduce(view: EditorView, kind: IntroduceKind, reason: Fallback const p: Prepared = { view, ctx, kind, reason, doc, at }; let candidates: SyntaxNode[]; if (sel.empty) { - candidates = at.candidates.filter((c, i) => !c.error && !c.missing && !isCallee(doc, c, at.candidates[i + 1])); + candidates = expressionCandidates(doc, at); if (!candidates.length) return refuse(p, "no expression at the caret"); } else { if (!at.expression || !at.exact) return refuse(p, "selected block should represent an expression"); @@ -208,11 +210,22 @@ async function introduce(view: EditorView, kind: IntroduceKind, reason: Fallback kind: "list", anchor: caretAnchor(view, sel.head), title: "Expressions", - items: candidates.map((c, i) => ({ id: `${i}`, label: preview(nodeText(doc, c)), run: () => perform(p, c) })), + items: candidates.map((c, i) => ({ + id: `${i}`, + label: expressionPreview(nodeText(doc, c)), + onActive: () => highlightRanges(view, [rangeToOffsets(doc, c.range)]), + run: () => perform(p, c), + })), + onClose: () => clearTemporaryHighlight(view), }); return true; } +/** The expressions at an empty selection, innermost first, that are worth extracting (no callee names). */ +export function expressionCandidates(doc: Text, at: ExpressionAt): SyntaxNode[] { + return at.candidates.filter((c, i) => !c.error && !c.missing && !isCallee(doc, c, at.candidates[i + 1])); +} + async function brokenAround( ctx: EditorDocContext, doc: Text, @@ -331,7 +344,7 @@ async function introduceVariable(p: Prepared, expr: SyntaxNode, chain: SyntaxNod } return { changes: state.changes(specs), decl, declAt: stmtFrom, refs }; }; - await chooseOccurrences(p, others.length, (all) => applyAndName(p, plan(all), names)); + await chooseOccurrences(p, { from: exprFrom, to: exprTo }, others, (all) => applyAndName(p, plan(all), names)); } async function variableContext( @@ -469,7 +482,7 @@ async function introduceConstant(p: Prepared, expr: SyntaxNode, chain: SyntaxNod } return { changes: state.changes(specs), decl, declAt: place.insertAt, declIndent: place.indent.length, refs }; }; - await chooseOccurrences(p, others.length, (all) => applyAndName(p, plan(all), names)); + await chooseOccurrences(p, { from: exprFrom, to: exprTo }, others, (all) => applyAndName(p, plan(all), names)); } /** Not an object key, type, import source…: a place where a named constant can stand. */ @@ -561,20 +574,32 @@ async function verifiedOccurrences( } /** IDEA's "Multiple occurrences found" choice; runs `apply` directly when there are none. */ -async function chooseOccurrences(p: Prepared, others: number, apply: (all: boolean) => void) { - if (!others) { +async function chooseOccurrences(p: Prepared, self: Occurrence, others: Occurrence[], apply: (all: boolean) => void) { + if (!others.length) { apply(false); return; } - const head = p.view.state.selection.main.head; + const { view } = p; + const head = view.state.selection.main.head; openPopup({ kind: "list", - anchor: caretAnchor(p.view, head), + anchor: caretAnchor(view, head), title: "Multiple occurrences found", items: [ - { id: "one", label: "Replace this occurrence only", run: () => apply(false) }, - { id: "all", label: `Replace all ${others + 1} occurrences`, run: () => apply(true) }, + { + id: "one", + label: "Replace this occurrence only", + onActive: () => highlightRanges(view, [self]), + run: () => apply(false), + }, + { + id: "all", + label: `Replace all ${others.length + 1} occurrences`, + onActive: () => highlightRanges(view, [self, ...others]), + run: () => apply(true), + }, ], + onClose: () => clearTemporaryHighlight(view), }); } diff --git a/src/editor/refactor/refactorThis.test.ts b/src/editor/refactor/refactorThis.test.ts index ecd3e6c2..bc0722fa 100644 --- a/src/editor/refactor/refactorThis.test.ts +++ b/src/editor/refactor/refactorThis.test.ts @@ -11,7 +11,11 @@ let query: (request: number) => Promise = async () => ok({ actions: [], let prepare: Result = ok(null); const lspCodeActionQuery = vi.fn(async (request: number, _doc: number, _range: unknown, _only: string[]) => query(request)); const lspPrepareRename = vi.fn(async () => prepare); -const lspApplyCodeAction = vi.fn(async () => ok(null)); +let onApply: (() => void) | null = null; +const lspApplyCodeAction = vi.fn(async () => { + onApply?.(); + return ok(null); +}); const lspCodeActions = vi.fn(async () => ok([])); const cancelRequest = vi.fn(async () => undefined); const languages = vi.fn(async () => [{ id: "rust", name: "Rust", lineComment: "//", blockComment: ["/*", "*/"] }]); @@ -25,16 +29,22 @@ vi.mock("@/platform/ipc", async (orig) => { }; }); let expression = false; +let candidates: unknown[] = []; vi.mock("@/editor/structure", () => ({ structure: { support: vi.fn(async () => ({ tree: true, locals: false })), - expressionAt: vi.fn(async () => ({ expression: expression ? { kind: "call_expression" } : null })), + expressionAt: vi.fn(async () => ({ expression: expression ? { kind: "call_expression" } : null, exact: expression, candidates })), + identifiers: vi.fn(async () => []), }, })); const showHint = vi.fn(); vi.mock("@/editor/lsp/hint", () => ({ showHint })); const introduceFallback = vi.fn(async () => true); -vi.mock("./introduce", () => ({ introduceFallback })); +vi.mock("./introduce", () => ({ + introduceFallback, + expressionCandidates: (_doc: unknown, at: { candidates?: unknown[] }) => at.candidates ?? [], + expressionPreview: (text: string) => text, +})); const safeDelete = vi.fn(); vi.mock("./safeDelete", () => ({ safeDelete })); @@ -42,6 +52,8 @@ const { classifyAction, kindAdvertised, cannotPerform } = await import("./catalo const { refactorThis, runRefactoring } = await import("./refactorThis"); const { useLspPopup, closePopup } = await import("@/editor/lsp/popupStore"); const { lspDoc } = await import("@/editor/lsp/lsp"); +const { inplaceRenameExtension, inplaceSession } = await import("@/editor/lsp/inplaceRename"); +const { temporaryHighlights } = await import("@/editor/lsp/tempHighlight"); // jsdom has no layout; popups anchor at the caret. Range.prototype.getClientRects ??= () => [] as unknown as DOMRectList; @@ -66,7 +78,10 @@ function editor(text = "fn main() { let total = price * 2; }") { state: EditorState.create({ doc: text, selection: { anchor: text.indexOf("price") + 1 }, - extensions: [lspDoc.of({ doc: 1, path: "/p/a.rs", language: "rust", uiMode: null, readOnly: false })], + extensions: [ + lspDoc.of({ doc: 1, path: "/p/a.rs", language: "rust", uiMode: null, readOnly: false }), + inplaceRenameExtension(), + ], }), }); return view; @@ -80,6 +95,8 @@ beforeEach(() => { query = answer([]); prepare = ok(null); expression = false; + candidates = []; + onApply = null; }); afterEach(() => { view?.destroy(); @@ -139,6 +156,8 @@ describe("Refactor This", () => { ]); await refactorThis(editor(), vi.fn()); expect(labels()).toEqual(["Rename…", "Safe Delete…", "Introduce Variable…", "Inline…", "Convert to raw string"]); + // IDEA's numbered list: 1-9 run a row directly. + expect((useLspPopup.getState().popup as { numbered?: boolean }).numbered).toBe(true); expect(lspCodeActionQuery).toHaveBeenCalledOnce(); expect(lspCodeActionQuery.mock.calls[0]![3]).toEqual(["refactor"]); }); @@ -248,4 +267,65 @@ describe("Refactor menu actions", () => { await runRefactoring(editor(), "extractField"); expect(lspCodeActions).not.toHaveBeenCalled(); }); + + it("with only a caret, choose the expression first (highlighted while choosing) and ask the server for it", async () => { + const text = "fn main() { let total = price * 2; }"; + const node = (t: string) => { + const from = text.indexOf(t); + return { kind: "e", range: { start: { line: 0, col: from }, end: { line: 0, col: from + t.length } }, error: false, missing: false }; + }; + candidates = [node("price"), node("price * 2")]; + const v = editor(text); + await runRefactoring(v, "extractVariable"); + const popup = useLspPopup.getState().popup; + expect(popup?.kind === "list" && popup.title).toBe("Expressions"); + expect(labels()).toEqual(["price", "price * 2"]); + expect(lspCodeActionQuery).not.toHaveBeenCalled(); + if (popup?.kind !== "list") return; + popup.items[1]!.onActive!(); + expect(temporaryHighlights(v)).toEqual([{ from: text.indexOf("price"), to: text.indexOf("2") + 1 }]); + + query = answer([action("Extract into variable", "refactor.extract")]); + closePopup(); + expect(temporaryHighlights(v)).toEqual([]); + popup.items[1]!.run(); + await vi.waitFor(() => expect(lspCodeActionQuery).toHaveBeenCalledOnce()); + expect(v.state.sliceDoc(v.state.selection.main.from, v.state.selection.main.to)).toBe("price * 2"); + expect(lspCodeActionQuery.mock.calls[0]![2]).toEqual((candidates[1] as { range: unknown }).range); + }); + + it("offer IDEA's occurrences choice when the server has both variants", async () => { + const text = "fn f() { g(price); h(price); }"; + const v = editor(text); + v.dispatch({ selection: { anchor: text.indexOf("price"), head: text.indexOf("price") + 5 } }); + query = answer([ + action("Extract to local variable", "refactor.extract.variable"), + action("Extract to local variable (replace all occurrences)", "refactor.extract.variable"), + ]); + await runRefactoring(v, "extractVariable"); + expect(labels()).toEqual(["Replace this occurrence only", "Replace all 2 occurrences"]); + const popup = useLspPopup.getState().popup; + if (popup?.kind !== "list") return; + popup.items[1]!.onActive!(); + expect(temporaryHighlights(v)).toHaveLength(2); + }); + + it("rename the introduced name in place after the server's edit", async () => { + const text = "fn main() { let total = price * 2; }"; + const v = editor(text); + v.dispatch({ selection: { anchor: text.indexOf("price"), head: text.indexOf("2") + 1 } }); + query = answer([action("Extract into variable", "refactor.extract")]); + onApply = () => + v.dispatch({ + changes: [ + { from: text.indexOf("let total"), insert: "let var_name = price * 2;\n " }, + { from: text.indexOf("price"), to: text.indexOf("2") + 1, insert: "var_name" }, + ], + }); + await runRefactoring(v, "extractVariable"); + await vi.waitFor(() => expect(inplaceSession(v.state)).not.toBeNull()); + const session = inplaceSession(v.state)!; + expect(session.oldName).toBe("var_name"); + expect(session.others).toHaveLength(1); + }); }); diff --git a/src/editor/refactor/refactorThis.ts b/src/editor/refactor/refactorThis.ts index 87e6d261..c421ae36 100644 --- a/src/editor/refactor/refactorThis.ts +++ b/src/editor/refactor/refactorThis.ts @@ -30,6 +30,13 @@ import { } from "./catalog"; import { introduceFallback, type IntroduceKind } from "./introduce"; import { languageName, queryRefactorActions } from "./query"; +import { + EXPRESSION_REFACTORINGS, + applyIntroduceAction, + chooseExpressionFirst, + chooseOccurrences, + introducesName, +} from "./serverIntroduce"; import { safeDelete } from "./safeDelete"; /** Outcome of the server query: its answer, or why there is none. */ @@ -66,9 +73,14 @@ export function groupActions(actions: readonly CodeAction[]): { return { mapped, other }; } -/** Applies the single action, or lets the user pick one. */ -function runActions(view: EditorView, title: string, actions: readonly CodeAction[]) { - if (actions.length === 1) return applyCodeAction(view, actions[0]!); +/** + * Applies the single action, or lets the user pick one. Introduce / Extract + * actions offer IDEA's occurrences choice and rename the new name in place. + */ +function runActions(view: EditorView, title: string, actions: readonly CodeAction[], id: RefactoringId | null = null) { + if (id && EXPRESSION_REFACTORINGS.has(id) && chooseOccurrences(view, actions, id)) return; + const apply = (a: CodeAction) => (id && introducesName(id) ? applyIntroduceAction(view, a, id) : applyCodeAction(view, a)); + if (actions.length === 1) return apply(actions[0]!); openPopup({ kind: "list", anchor: caretAnchor(view, view.state.selection.main.head), @@ -78,7 +90,7 @@ function runActions(view: EditorView, title: string, actions: readonly CodeActio label: a.title, icon: "bulb", emphasis: a.isPreferred, - run: () => applyCodeAction(view, a), + run: () => apply(a), })), }); } @@ -127,6 +139,10 @@ export async function runRefactoring(view: EditorView, id: RefactoringId): Promi showHint(view, cannotPerform("The file is read-only")); return; } + // IDEA picks the expression first (and the server then extracts exactly that one). + if (EXPRESSION_REFACTORINGS.has(id) && view.state.selection.main.empty) { + if (await chooseExpressionFirst(view, () => runRefactoring(view, id))) return; + } menuRun?.abort(); const run = (menuRun = new AbortController()); const state = view.state; @@ -143,7 +159,7 @@ export async function runRefactoring(view: EditorView, id: RefactoringId): Promi const all = answer.status === "ok" ? answer.query.actions : []; const actions = groupActions(all).mapped.get(id) ?? []; if (actions.length) { - await runActions(view, r.label.replace(/…$/, ""), actions); + await runActions(view, r.label.replace(/…$/, ""), actions, id); return; } const introduce = INTRODUCE[id]; @@ -195,10 +211,15 @@ export function refactorThisItems( const { mapped, other } = groupActions(answer.status === "ok" ? answer.query.actions : []); const serverUsable = answer.status === "ok"; const items: ListPopupItem[] = []; + const caretOnly = view.state.selection.main.empty; for (const r of REFACTORINGS) { const actions = mapped.get(r.id) ?? []; + const expression = EXPRESSION_REFACTORINGS.has(r.id); let go: (() => unknown) | null = null; - if (actions.length) go = () => runActions(view, r.label.replace(/…$/, ""), actions); + // With only a caret the expression is chosen first, then the server asked for it. + if (expression && caretOnly && serverUsable && (actions.length || facts.expression)) + go = () => runRefactoring(view, r.id); + else if (actions.length) go = () => runActions(view, r.label.replace(/…$/, ""), actions, r.id); else if (r.id === "rename" && facts.symbol) go = () => run.rename(view); else if (r.id === "safeDelete" && facts.symbol && !docOf(view)?.readOnly) go = () => run.safeDelete(view); else if (INTRODUCE[r.id] && facts.expression && !serverUsable) @@ -243,5 +264,5 @@ export async function refactorThis(view: EditorView, rename: (view: EditorView) showHint(view, "No refactorings available at the caret"); return; } - openPopup({ kind: "list", anchor: caretAnchor(view, state.selection.main.head), title: "Refactor This", items }); + openPopup({ kind: "list", anchor: caretAnchor(view, state.selection.main.head), title: "Refactor This", items, numbered: true }); } diff --git a/src/editor/refactor/safeDelete.test.ts b/src/editor/refactor/safeDelete.test.ts index 5ac7893c..d5e088c7 100644 --- a/src/editor/refactor/safeDelete.test.ts +++ b/src/editor/refactor/safeDelete.test.ts @@ -47,10 +47,12 @@ vi.mock("@/platform/ipc", async (orig) => { let locals: Locals = { status: "noIdentifier" }; let statement: SyntaxNode | null = null; +let identifiers: { range: Range; qualified: boolean }[] = []; vi.mock("@/editor/structure", () => ({ structure: { locals: vi.fn(async () => locals), structuralRanges: vi.fn(async (): Promise> => ({ statement })), + identifiers: vi.fn(async () => identifiers), }, })); const showHint = vi.fn(); @@ -59,11 +61,13 @@ const executeAction = vi.fn(); vi.mock("@/platform/actions", () => ({ executeAction })); let answer = "cancel"; const ask = vi.fn(async (_request: MessageRequest) => ({ button: answer, checked: false })); -vi.mock("@/workbench/messages", () => ({ ask })); +let confirmAnswer: { checked: boolean } | null = { checked: false }; +const confirmChecked = vi.fn(async (_opts: { title: string; message: string; checkbox?: string; checked?: boolean }) => confirmAnswer); +vi.mock("@/workbench/messages", () => ({ ask, confirmChecked })); -const { deletionRange, localDeclaration, outlineItemAt, safeDelete, fileUsages, usagesMessage } = +const { deletionRange, localDeclaration, outlineItemAt, safeDelete, fileUsages, usagesMessage, textOccurrences } = await import("./safeDelete"); -const { closePopup } = await import("@/editor/lsp/popupStore"); +const { closePopup, useLspPopup } = await import("@/editor/lsp/popupStore"); const { lspDoc } = await import("@/editor/lsp/lsp"); // --- helpers ---------------------------------------------------------------- @@ -112,6 +116,8 @@ beforeEach(() => { references = ok([]); locals = { status: "noIdentifier" }; statement = null; + identifiers = []; + confirmAnswer = { checked: false }; }); afterEach(() => { @@ -192,6 +198,16 @@ describe("localDeclaration", () => { }); }); +describe("textOccurrences", () => { + it("finds whole-word mentions that are not identifier tokens, outside the declaration", () => { + const text = 'foo(); // foo and food\nlet s = "foo";\nfn foo() {}'; + const ids = [0, text.lastIndexOf("foo")].map((from) => ({ from, to: from + 3 })); + const decl = text.indexOf("fn foo"); + const hits = textOccurrences(text, "foo", ids, { from: decl, to: text.length }); + expect(hits.map((h) => text.slice(h.from - 3, h.to))).toEqual(["// foo", '= "foo']); + }); +}); + describe("usagesMessage", () => { it("says how many usages and where", () => { const loc = (path: string): Location => ({ path, range: rangeOf("x", 0, 1) }); @@ -245,6 +261,37 @@ describe("safeDelete in the editor", () => { expect((await run("delete")).state.doc.toString()).toBe("fn unused() {\n unused();\n}\n\nfn main() {}\n"); }); + it("confirms first ('Delete function …?') and searches nothing when cancelled", async () => { + const v = editor(text, "unused() {"); + outline = ok([item(text, "unused", "fn unused() {\n unused();\n}")]); + confirmAnswer = null; + await safeDelete(v); + expect(confirmChecked).toHaveBeenCalledTimes(1); + const opts = confirmChecked.mock.calls[0]![0]; + expect(opts.title).toBe("Safe Delete"); + expect(opts.message).toBe("Delete function 'unused'?"); + expect(opts.checkbox).toBe("Search in comments and strings"); + expect(lspReferences).not.toHaveBeenCalled(); + expect(v.state.doc.toString()).toBe(text); + }); + + it("with 'Search in comments and strings' previews mentions before deleting", async () => { + const src = "// calls unused\nfn unused() {}\n\nfn main() {}\n"; + const v = editor(src, "unused() {"); + outline = ok([item(src, "unused", "fn unused() {}")]); + const decl = src.indexOf("unused() {"); + identifiers = [{ range: rangeOf(src, decl, decl + 6), qualified: false }]; + confirmAnswer = { checked: true }; + await safeDelete(v); + const popup = useLspPopup.getState().popup; + expect(popup?.kind).toBe("preview"); + if (popup?.kind !== "preview") return; + expect(popup.occurrences).toEqual([{ line: 0, text: "// calls unused", from: 9, to: 15 }]); + expect(v.state.doc.toString()).toBe(src); + popup.onConfirm(); + expect(v.state.doc.toString()).not.toContain("fn unused"); + }); + it("refuses a declaration in another file instead of doing something else", async () => { const v = editor(text, "main"); goto = ok([{ path: "/p/other.rs", range: rangeOf(text, 0, 1) }]); diff --git a/src/editor/refactor/safeDelete.ts b/src/editor/refactor/safeDelete.ts index c8aafd15..ee6f6609 100644 --- a/src/editor/refactor/safeDelete.ts +++ b/src/editor/refactor/safeDelete.ts @@ -2,7 +2,8 @@ * Safe Delete (⌘⌦): resolves the declaration at the caret (LSP definition + * document symbols; the tree-sitter structure service for local declarations * only), searches its usages (LSP references, the declaration's own body - * excluded), then deletes it — with attached doc comments / annotations and + * excluded) once the user confirmed ("Delete 'x'?", optionally searching + * comments and strings too), then deletes it — with attached doc comments / annotations and * the trailing blank line — as one undo step, or, when it is still used, * asks like IDEA: "Usages Detected" → View Usages / Delete Anyway / Cancel. * @@ -20,7 +21,8 @@ import { structure } from "@/editor/structure"; import { docOf, isUnavailable, lspRequest } from "@/editor/lsp/lsp"; import { comparePos, offsetToPos, rangeContains, rangeToOffsets } from "@/editor/lsp/positions"; import { showHint } from "@/editor/lsp/hint"; -import { ask } from "@/workbench/messages"; +import { ask, confirmChecked } from "@/workbench/messages"; +import { openPopup, type PreviewOccurrence } from "@/editor/lsp/popupStore"; import { cannotPerform } from "./catalog"; import { cancelled, languageName } from "./query"; @@ -333,6 +335,77 @@ function applyDeletion(view: EditorView, docBefore: Text, target: SafeDeleteTarg } let running: EditorView | null = null; +/** "Search in comments and strings", remembered like IDEA does (on by default). */ +let searchInComments = true; + +/** + * Mentions of the name in this file's comments and strings: whole-word text + * matches that are not identifier tokens, outside the declaration itself. + */ +export function textOccurrences( + text: string, + name: string, + identifiers: readonly { from: number; to: number }[], + exclude: { from: number; to: number }, +): { from: number; to: number }[] { + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const re = new RegExp(`(? o.from)); + const out: { from: number; to: number }[] = []; + for (let m = re.exec(text); m; m = re.exec(text)) { + const from = m.index; + if (ids.has(from) || (from >= exclude.from && from < exclude.to)) continue; + out.push({ from, to: from + name.length }); + } + return out; +} + +async function commentAndStringOccurrences( + ctx: EditorDocContext, + doc: Text, + target: SafeDeleteTarget, +): Promise<{ from: number; to: number }[]> { + let identifiers; + try { + identifiers = await structure.identifiers(ctx.doc, target.name); + } catch (e) { + if (isCancelled(e)) throw e; + // Without a syntax tree comments can't be told from code. + return []; + } + const ids = identifiers.map((o) => rangeToOffsets(doc, o.range)); + return textOccurrences(doc.toString(), target.name, ids, rangeToOffsets(doc, target.range)); +} + +/** No code usages, but mentions in comments / strings: show them before deleting (IDEA's preview). */ +function previewTextOccurrences( + ctx: EditorDocContext, + doc: Text, + target: SafeDeleteTarget, + hits: readonly { from: number; to: number }[], + apply: () => void, +) { + const occurrences: PreviewOccurrence[] = hits.map((h) => { + const line = doc.lineAt(h.from); + return { line: line.number - 1, text: line.text, from: h.from - line.from, to: Math.min(h.to, line.to) - line.from }; + }); + openPopup({ + kind: "preview", + title: `Safe Delete ${target.kind.toLowerCase()} '${target.name}'`, + summary: { + files: 1, + occurrences: occurrences.length, + hasFileOperations: false, + entries: [{ path: ctx.path, edits: occurrences.length }], + }, + warnings: [ + `'${target.name}' is mentioned in comments or strings. The declaration will be deleted; these mentions stay.`, + ], + occurrences, + confirmLabel: "Delete", + onConfirm: apply, + }); +} export async function safeDelete(view: EditorView): Promise { const ctx = docOf(view); @@ -353,22 +426,44 @@ export async function safeDelete(view: EditorView): Promise { return; } const { target } = resolved; - const [usages, comments] = await Promise.all([findUsages(ctx, target), commentSyntax(ctx.language)]); - if (view.state.doc !== docBefore || !view.state.selection.eq(selBefore)) return; + const comments = await commentSyntax(ctx.language); // Validate before asking: a declaration that can't be removed by lines is refused up front. const { from, to } = rangeToOffsets(docBefore, target.range); if (!deletionRange(docBefore, from, to, comments)) { showHint(view, cannotPerform(`'${target.name}' shares its line with other code`)); return; } - if (!usages.length) { - applyDeletion(view, docBefore, target, comments); + // IDEA always confirms first; usages are searched after OK. + const confirmed = await confirmChecked({ + title: "Safe Delete", + message: `Delete ${target.kind.toLowerCase()} '${target.name}'?`, + ok: "OK", + checkbox: "Search in comments and strings", + checked: searchInComments, + }); + if (!confirmed) { + view.focus(); return; } - const answer = await askUsagesDetected(`${target.kind} '${target.name}'`, usages); - if (answer === "view") viewUsages(target.name, usages); - else if (answer === "delete") applyDeletion(view, docBefore, target, comments); - else view.focus(); + searchInComments = confirmed.checked; + if (view.state.doc !== docBefore || !view.state.selection.eq(selBefore)) return; + const [usages, textHits] = await Promise.all([ + findUsages(ctx, target), + confirmed.checked ? commentAndStringOccurrences(ctx, docBefore, target) : Promise.resolve([]), + ]); + if (view.state.doc !== docBefore) return; + if (usages.length) { + const answer = await askUsagesDetected(`${target.kind} '${target.name}'`, usages); + if (answer === "view") viewUsages(target.name, usages); + else if (answer === "delete") applyDeletion(view, docBefore, target, comments); + else view.focus(); + return; + } + if (textHits.length) { + previewTextOccurrences(ctx, docBefore, target, textHits, () => applyDeletion(view, docBefore, target, comments)); + return; + } + applyDeletion(view, docBefore, target, comments); } catch (e) { if (isCancelled(e)) return; showHint(view, isUnavailable(e) ? cannotPerform("No language server is available") : cannotPerform(errorText(e))); diff --git a/src/editor/refactor/serverIntroduce.test.ts b/src/editor/refactor/serverIntroduce.test.ts new file mode 100644 index 00000000..acb40fcd --- /dev/null +++ b/src/editor/refactor/serverIntroduce.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import type { CodeAction } from "@/bindings"; +import { introducedName, occurrenceVariants } from "./serverIntroduce"; + +const spansText = (text: string, spans: { from: number; to: number }[]) => spans.map((s) => text.slice(s.from, s.to)); + +describe("introducedName", () => { + it("finds the server's variable: declaration and replaced occurrence", () => { + const before = "function f() {\n return price * 2 + 1;\n}\n"; + const after = "function f() {\n const newLocal = price * 2;\n return newLocal + 1;\n}\n"; + const found = introducedName(before, after)!; + expect(found.name).toBe("newLocal"); + expect(found.spans).toHaveLength(2); + expect(spansText(after, found.spans)).toEqual(["newLocal", "newLocal"]); + }); + + it("ignores names that were already there and keywords / types written once", () => { + const before = "void f() {\n g(a.b(), a.b());\n}\n"; + const after = "void f() {\n String b = a.b();\n g(b, b);\n}\n"; + const found = introducedName(before, after)!; + expect(found.name).toBe("b"); + // `b` already existed in `a.b()`; only the three new tokens count. + expect(found.spans.map((s) => after.slice(s.from - 1, s.to + 1))).toEqual([" b ", "(b,", " b)"]); + }); + + it("handles an extracted function placed after its call", () => { + const before = "fn main() {\n let x = 1 + 2;\n}\n"; + const after = "fn main() {\n let x = fun_name();\n}\n\nfn fun_name() -> i32 {\n 1 + 2\n}\n"; + expect(introducedName(before, after)?.name).toBe("fun_name"); + }); + + it("is null without a new name used twice", () => { + expect(introducedName("a + b", "(a + b)")).toBeNull(); + expect(introducedName("x", "x")).toBeNull(); + }); +}); + +describe("occurrenceVariants", () => { + const action = (title: string): CodeAction => ({ + title, + kind: "refactor.extract.variable", + isPreferred: false, + disabledReason: null, + edit: null, + command: null, + data: null, + server: 1, + }); + + it("pairs the single and the all-occurrences action", () => { + const one = action("Extract to local variable"); + const all = action("Extract to local variable (replace all occurrences)"); + expect(occurrenceVariants([one, all])).toEqual({ one, all }); + expect(occurrenceVariants([one])).toBeNull(); + expect(occurrenceVariants([one, action("Extract to field"), all])).toBeNull(); + }); +}); diff --git a/src/editor/refactor/serverIntroduce.ts b/src/editor/refactor/serverIntroduce.ts new file mode 100644 index 00000000..d495fc0a --- /dev/null +++ b/src/editor/refactor/serverIntroduce.ts @@ -0,0 +1,327 @@ +/** + * Introduce Variable / Constant / Field / Parameter and Extract Method through + * the language server, with IDEA's interaction around the server's edit: + * - with only a caret, the expression is chosen first (each candidate is + * highlighted while the chooser moves) and selected before the server is + * asked, so it extracts exactly that expression; + * - when the server offers both a "this occurrence" and an "all + * occurrences" variant, IDEA's "Multiple occurrences found" chooser picks; + * - after the edit the introduced name is renamed in place, with name + * suggestions from the expression (↑ / ↓). + */ +import { EditorSelection } from "@codemirror/state"; +import type { EditorView } from "@codemirror/view"; +import type { CodeAction, SyntaxNode } from "@/bindings"; +import { isCancelled } from "@/platform/ipc"; +import { shortcutLabel } from "@/platform/keymap"; +import { whenSynced } from "@/editor/api"; +import { structure, type ExpressionAt } from "@/editor/structure"; +import { docOf } from "@/editor/lsp/lsp"; +import { offsetsToRange, rangeToOffsets } from "@/editor/lsp/positions"; +import { showHint } from "@/editor/lsp/hint"; +import { caretAnchor, openPopup } from "@/editor/lsp/popupStore"; +import { applyCodeAction } from "@/editor/lsp/intentions"; +import { putSuggestion, startInplaceRename, type Span } from "@/editor/lsp/inplaceRename"; +import { clearTemporaryHighlight, highlightRanges } from "@/editor/lsp/tempHighlight"; +import type { RefactoringId } from "./catalog"; +import { expressionCandidates, expressionPreview } from "./introduce"; +import { findTextOccurrences } from "./occurrences"; +import { isIdentifier, isKeyword, suggestNames, uniqueNames } from "./names"; + +/** Refactorings that take an expression (chosen first when nothing is selected). */ +export const EXPRESSION_REFACTORINGS: ReadonlySet = new Set([ + "extractVariable", + "extractConstant", + "extractField", + "extractParameter", +]); + +/** Refactorings whose result is a new name renamed in place afterwards. */ +const NAMING_REFACTORINGS: ReadonlySet = new Set([...EXPRESSION_REFACTORINGS, "extractMethod"]); + +export function introducesName(id: RefactoringId | null): boolean { + return !!id && NAMING_REFACTORINGS.has(id); +} + +// --------------------------------------------------------------------------- +// Expression chooser + +/** + * With an empty selection: selects the expression at the caret (asking which + * one when there are several) and then runs `then`. False when there is no + * expression to choose (the caller goes on as before). + */ +export async function chooseExpressionFirst(view: EditorView, then: () => unknown): Promise { + const ctx = docOf(view); + const sel = view.state.selection.main; + if (!ctx || !sel.empty) return false; + const support = await structure.support(ctx.language).catch(() => null); + if (!support?.tree) return false; + await whenSynced(view); + const doc = view.state.doc; + let at: ExpressionAt; + try { + at = await structure.expressionAt(ctx.doc, offsetsToRange(doc, sel.from, sel.to)); + } catch (e) { + if (isCancelled(e)) return true; + return false; + } + if (view.state.doc !== doc || !view.state.selection.main.eq(sel)) return true; + const candidates = expressionCandidates(doc, at); + if (!candidates.length) return false; + const select = (node: SyntaxNode) => { + if (view.state.doc !== doc) { + showHint(view, "The document changed; invoke the refactoring again"); + return; + } + const { from, to } = rangeToOffsets(doc, node.range); + view.dispatch({ selection: EditorSelection.range(from, to), scrollIntoView: true }); + view.focus(); + void then(); + }; + if (candidates.length === 1) { + select(candidates[0]!); + return true; + } + openPopup({ + kind: "list", + anchor: caretAnchor(view, sel.head), + title: "Expressions", + items: candidates.map((c, i) => ({ + id: `${i}`, + label: expressionPreview(doc.sliceString(rangeToOffsets(doc, c.range).from, rangeToOffsets(doc, c.range).to)), + onActive: () => highlightRanges(view, [rangeToOffsets(doc, c.range)]), + run: () => select(c), + })), + onClose: () => clearTemporaryHighlight(view), + }); + return true; +} + +// --------------------------------------------------------------------------- +// Occurrences chooser + +const ALL_OCCURRENCES = /\b(all|every) (the )?occurrences?\b|\breplace all\b/i; + +/** The single-occurrence / all-occurrences pair among a refactoring's actions, if the server offers both. */ +export function occurrenceVariants(actions: readonly CodeAction[]): { one: CodeAction; all: CodeAction } | null { + const all = actions.filter((a) => ALL_OCCURRENCES.test(a.title)); + const one = actions.filter((a) => !ALL_OCCURRENCES.test(a.title)); + return all.length === 1 && one.length === 1 ? { one: one[0]!, all: all[0]! } : null; +} + +/** + * IDEA's "Multiple occurrences found" for a server that offers both variants; + * false when it does not (the caller applies / lists the actions as before). + */ +export function chooseOccurrences(view: EditorView, actions: readonly CodeAction[], id: RefactoringId): boolean { + const variants = occurrenceVariants(actions); + if (!variants) return false; + const state = view.state; + const sel = state.selection.main; + const text = state.sliceDoc(sel.from, sel.to); + const occurrences = text.trim() ? findTextOccurrences(state.doc.toString(), 0, state.doc.length, text, 200) : []; + const n = occurrences.length; + openPopup({ + kind: "list", + anchor: caretAnchor(view, sel.head), + title: "Multiple occurrences found", + items: [ + { + id: "one", + label: "Replace this occurrence only", + onActive: () => highlightRanges(view, [{ from: sel.from, to: sel.to }]), + run: () => applyIntroduceAction(view, variants.one, id), + }, + { + id: "all", + label: n > 1 ? `Replace all ${n} occurrences` : "Replace all occurrences", + onActive: () => highlightRanges(view, n ? occurrences : [{ from: sel.from, to: sel.to }]), + run: () => applyIntroduceAction(view, variants.all, id), + }, + ], + onClose: () => clearTemporaryHighlight(view), + }); + return true; +} + +// --------------------------------------------------------------------------- +// The introduced name + +const WORD = /[\p{L}\p{N}_$]/u; +const TOKEN = /[\p{L}_$][\p{L}\p{N}_$]*|\S/gu; + +interface Token { + text: string; + from: number; +} + +function tokens(text: string, offset: number): Token[] { + return [...text.matchAll(TOKEN)].map((m) => ({ text: m[0], from: offset + m.index! })); +} + +/** Tokens of `after` that are not part of the longest common token subsequence with `before`. */ +function insertedTokens(before: Token[], after: Token[]): Token[] { + const n = before.length; + const m = after.length; + if (n * m > 4_000_000) return after; + const lcs = new Uint32Array((n + 1) * (m + 1)); + const at = (i: number, j: number) => i * (m + 1) + j; + for (let i = n - 1; i >= 0; i--) + for (let j = m - 1; j >= 0; j--) + lcs[at(i, j)] = + before[i]!.text === after[j]!.text ? lcs[at(i + 1, j + 1)]! + 1 : Math.max(lcs[at(i + 1, j)]!, lcs[at(i, j + 1)]!); + const out: Token[] = []; + let i = 0; + let j = 0; + while (j < m) { + if (i < n && before[i]!.text === after[j]!.text) { + i++; + j++; + } else if (i < n && lcs[at(i + 1, j)]! >= lcs[at(i, j + 1)]!) i++; + else out.push(after[j++]!); + } + return out; +} + +/** Names servers give new declarations before the user names them. */ +const SERVER_DEFAULT = /^(new[A-Z_]\w*|newLocal\w*|var_name\d*|fun_name\d*|extracted\w*|new_\w+|value\d*)$/; + +/** + * The name a refactoring introduced, and every place the edit wrote it, from + * the text before and after (servers may apply the edit through a command, + * so the edit itself is not always known). Null when no new name appears at + * least twice (declaration and use). + */ +export function introducedName(before: string, after: string): { name: string; spans: Span[] } | null { + let start = 0; + const max = Math.min(before.length, after.length); + while (start < max && before[start] === after[start]) start++; + let end = 0; + while (end < max - start && before[before.length - 1 - end] === after[after.length - 1 - end]) end++; + // Whole words at both ends of the changed region. + while (start > 0 && WORD.test(after[start - 1]!)) start--; + let afterTo = after.length - end; + let beforeTo = before.length - end; + while (afterTo < after.length && WORD.test(after[afterTo]!)) { + afterTo++; + beforeTo++; + } + const inserted = insertedTokens(tokens(before.slice(start, beforeTo), start), tokens(after.slice(start, afterTo), start)); + const byName = new Map(); + for (const t of inserted) { + if (!WORD.test(t.text[0]!) || !isIdentifier(t.text) || isKeyword(t.text)) continue; + let list = byName.get(t.text); + if (!list) byName.set(t.text, (list = [])); + list.push({ from: t.from, to: t.from + t.text.length }); + } + let best: { name: string; spans: Span[] } | null = null; + const score = (name: string, spans: Span[]) => (SERVER_DEFAULT.test(name) ? 1000 : 0) + spans.length; + for (const [name, spans] of byName) { + if (spans.length < 2) continue; + if (!best || score(name, spans) > score(best.name, best.spans)) best = { name, spans }; + } + if (!best) return null; + // The server picked a name free in scope, so every plain (non-member) use in the changed + // region is the new one; the token alignment alone may pair one with an old member name. + const name = best.name; + const spans: Span[] = []; + for (const t of tokens(after.slice(start, afterTo), start)) { + if (t.text !== name) continue; + const lead = after.slice(Math.max(0, t.from - 2), t.from); + if (/(\.|->|::)\s*$/.test(lead) && !best.spans.some((s) => s.from === t.from)) continue; + spans.push({ from: t.from, to: t.from + name.length }); + } + return { name, spans }; +} + +async function nameSuggestions(view: EditorView, id: RefactoringId, expression: { kind: string; text: string } | null) { + const ctx = docOf(view); + if (!ctx || !expression || !EXPRESSION_REFACTORINGS.has(id)) return []; + const raw = suggestNames({ ...expression, language: ctx.language, constant: id === "extractConstant" }); + if (!raw.length) return []; + return uniqueNames(raw, async (name) => { + const found = await structure.identifiers(ctx.doc, name); + return found.some((o) => !o.qualified); + }); +} + +async function expressionInfo(view: EditorView): Promise<{ kind: string; text: string } | null> { + const ctx = docOf(view); + const sel = view.state.selection.main; + if (!ctx || sel.empty) return null; + const text = view.state.sliceDoc(sel.from, sel.to); + try { + const at = await structure.expressionAt(ctx.doc, offsetsToRange(view.state.doc, sel.from, sel.to)); + return { kind: at.exact && at.expression ? at.expression.kind : "", text }; + } catch { + return { kind: "", text }; + } +} + +async function docChange(view: EditorView, before: EditorView["state"]["doc"], ms = 1500): Promise { + for (let waited = 0; waited < ms && view.state.doc === before; waited += 20) await new Promise((r) => setTimeout(r, 20)); + return view.state.doc !== before; +} + +/** + * Applies a server extract / introduce action, then renames the introduced + * name in place (as IDEA does), with name suggestions for an expression. + */ +export async function applyIntroduceAction(view: EditorView, action: CodeAction, id: RefactoringId): Promise { + const expression = await expressionInfo(view); + const before = view.state.doc; + await applyCodeAction(view, action); + if (!(await docChange(view, before))) return; + // The server's edit may arrive in more than one transaction. + await new Promise((r) => setTimeout(r, 30)); + const doc = view.state.doc; + const found = introducedName(before.toString(), doc.toString()); + if (!found) return; + const suggestions = await nameSuggestions(view, id, expression).catch(() => [] as string[]); + if (view.state.doc !== doc) return; + const caret = view.state.selection.main.head; + // The occurrence at the caret (where the expression was), else the declaration. + const primary = found.spans.find((s) => s.from <= caret && caret <= s.to) ?? found.spans[0]!; + const others = found.spans.filter((s) => s !== primary); + const keys = shortcutLabel("refactor.rename"); + const names = suggestions.includes(found.name) ? suggestions : [...suggestions, found.name]; + view.focus(); + startInplaceRename(view, { + primary, + others, + suggestions: names.length > 1 ? names : undefined, + hint: keys ? `Press ${keys} to show dialog with more options` : "↵ to finish, Esc to keep the name", + commit: (name) => renameIntroduced(view, [primary, ...others], found.name, name), + dialog: (typed) => + openPopup({ + kind: "rename", + anchor: caretAnchor(view, primary.from), + title: `Rename '${found.name}'`, + initial: typed, + suggestions: names, + onSubmit: (name) => renameIntroduced(view, [primary, ...others], found.name, name), + }), + }); + // As in IDEA the best suggestion replaces the server's placeholder name right away. + if (suggestions[0] && suggestions[0] !== found.name) putSuggestion(view, suggestions[0]); +} + +/** Writes `name` over the introduced name's occurrences (the session restored `current` there). */ +export function renameIntroduced(view: EditorView, spans: readonly Span[], current: string, name: string) { + if (!name || name === current) return; + if (!isIdentifier(name) || isKeyword(name)) { + showHint(view, `'${name}' is not a valid name; kept '${current}'`, { tone: "warning" }); + return; + } + const state = view.state; + if (spans.some((s) => s.to > state.doc.length || state.sliceDoc(s.from, s.to) !== current)) { + showHint(view, "The document changed; the name was not applied"); + return; + } + view.dispatch({ + changes: spans.map((s) => ({ ...s, insert: name })), + userEvent: "refactor.rename", + scrollIntoView: true, + }); +} diff --git a/src/features/analysis/Analysis.module.css b/src/features/analysis/Analysis.module.css index 5f11a68c..d58ebcb1 100644 --- a/src/features/analysis/Analysis.module.css +++ b/src/features/analysis/Analysis.module.css @@ -278,3 +278,49 @@ .previewNew { color: var(--vcs-added); } + +/* Inspection Results: tree on the left, problem details on the right. */ +.panel { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; +} +.panel > * { + flex: 1; + min-height: 0; +} +.excluded .message, +.excluded .strong { + opacity: 0.5; + text-decoration: line-through; +} +.details { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 12px; + overflow: auto; + border-left: 1px solid var(--border); + font-size: var(--fs-ui); +} +.detailsEmpty { + align-items: center; + justify-content: center; + color: var(--fg-faint); +} +.detailsTitle { + display: flex; + align-items: center; + gap: 6px; + color: var(--fg-strong); +} +.detailsMessage { + margin: 0; + white-space: pre-wrap; + color: var(--fg); +} +.detailsActions { + display: flex; + gap: 6px; +} diff --git a/src/features/analysis/InspectionView.tsx b/src/features/analysis/InspectionView.tsx index ad967284..88673f49 100644 --- a/src/features/analysis/InspectionView.tsx +++ b/src/features/analysis/InspectionView.tsx @@ -1,13 +1,40 @@ -/** Inspection Results tool window: severity → inspection → file → problem. */ -import { useCallback, useMemo, useState } from "react"; +/** + * Inspection Results tool window: inspection → file → problem (or severity + * first, on request), with the selected problem's details on the right. + */ +import { useCallback, useMemo, useRef, useState, type KeyboardEvent } from "react"; +import clsx from "clsx"; +import { create } from "zustand"; +import { executeAction } from "@/platform/actions"; +import { shortcutLabel } from "@/platform/keymap"; import { basename, relativeDir, useProject } from "@/platform/project"; -import { editorService } from "@/workbench/contributions"; -import { EmptyState, Icon, IconButton, ProgressBar, Tree, fileIconName, type TreeRowState } from "@/ui"; +import { editorService, useOccurrenceNavigator } from "@/workbench/contributions"; +import { + Button, + EmptyState, + Icon, + IconButton, + ProgressBar, + SplitGroup, + SplitHandle, + SplitPanel, + Tree, + fileIconName, + showContextMenu, + type MenuItem, + type TreeHandle, + type TreeRowState, + T, +} from "@/ui"; import { cancelInspection, groupInspection, + problemId, rerunInspection, + severityKey, + toggleExcludedProblems, useInspection, + type InspectionKindGroup, type InspectionProblem, type SeverityKey, } from "./inspect"; @@ -15,7 +42,7 @@ import styles from "./Analysis.module.css"; type Node = | { kind: "severity"; key: string; severity: SeverityKey; count: number; children: Node[] } - | { kind: "inspection"; key: string; name: string; count: number; children: Node[] } + | { kind: "inspection"; key: string; name: string; severity: SeverityKey; count: number; children: Node[] } | { kind: "file"; key: string; path: string; count: number; children: Node[] } | { kind: "problem"; key: string; p: InspectionProblem }; @@ -25,47 +52,152 @@ const SEVERITY_LABEL: Record = { info: "Weak warnings and hints", }; -export function buildInspectionNodes(groups: ReturnType): Node[] { +// --------------------------------------------------------------------------- +// View options (persisted), in the tool window's ⋮ menu. IDEA's defaults: +// grouped by inspection, no autoscroll. + +const OPTIONS_KEY = "openidea.inspection.view.v1"; + +interface InspectionOptions { + groupBySeverity: boolean; + autoscroll: boolean; +} + +function loadOptions(): Partial { + try { + return JSON.parse(globalThis.localStorage?.getItem(OPTIONS_KEY) ?? "{}") as Partial; + } catch { + return {}; + } +} + +export const useInspectionOptions = create(() => ({ + groupBySeverity: false, + autoscroll: false, + ...loadOptions(), +})); + +useInspectionOptions.subscribe((s) => { + try { + globalThis.localStorage?.setItem(OPTIONS_KEY, JSON.stringify(s)); + } catch { + /* storage unavailable */ + } +}); + +/** The Inspection Results tool window's ⋮ menu entries. */ +export function useInspectionHeaderMenu(): MenuItem[] { + const o = useInspectionOptions(); + return [ + { + id: "groupBySeverity", + label: "Group by Severity", + checked: o.groupBySeverity, + run: () => useInspectionOptions.setState({ groupBySeverity: !o.groupBySeverity }), + }, + { + id: "autoscroll", + label: "Autoscroll to Source", + checked: o.autoscroll, + run: () => useInspectionOptions.setState({ autoscroll: !o.autoscroll }), + }, + ]; +} + +const SEVERITY_RANK: Record = { error: 0, warning: 1, info: 2 }; + +/** Inspections across severities, merged by name (the same check may report errors and warnings). */ +function mergeKinds( + groups: ReturnType, +): Array { + const byKind = new Map(); + for (const g of groups) { + for (const k of g.kinds) { + const prev = byKind.get(k.kind); + if (!prev) { + byKind.set(k.kind, { + ...k, + files: k.files.map((f) => ({ ...f, problems: [...f.problems] })), + severity: g.severity, + }); + continue; + } + prev.count += k.count; + for (const f of k.files) { + const same = prev.files.find((x) => x.path === f.path); + if (same) same.problems.push(...f.problems); + else prev.files.push({ ...f, problems: [...f.problems] }); + } + } + } + const pos = (p: InspectionProblem) => p.d.range.start.line * 1e6 + p.d.range.start.col; + return [...byKind.values()] + .map((k) => { + k.files.sort((a, b) => a.relPath.localeCompare(b.relPath)); + k.files.forEach((f) => f.problems.sort((a, b) => pos(a) - pos(b))); + return k; + }) + .sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || a.kind.localeCompare(b.kind)); +} + +function kindNode(k: InspectionKindGroup, severity: SeverityKey, prefix: string): Node { + const kindKey = `${prefix}\u0000${k.kind}`; + return { + kind: "inspection", + key: kindKey, + name: k.kind, + severity, + count: k.count, + children: k.files.map((f) => { + const fileKey = `${kindKey}\u0000${f.path}`; + return { + kind: "file", + key: fileKey, + path: f.path, + count: f.problems.length, + children: f.problems.map((p, i) => ({ kind: "problem", key: `${fileKey}\u0000${i}`, p }) as Node), + } as Node; + }), + }; +} + +/** Result tree: inspection → file → problem, under severity groups with `bySeverity`. */ +export function buildInspectionNodes(groups: ReturnType, bySeverity = true): Node[] { + if (!bySeverity) return mergeKinds(groups).map((k) => kindNode(k, k.severity, "kind")); return groups.map((s) => ({ kind: "severity", key: s.severity, severity: s.severity, count: s.count, - children: s.kinds.map((k) => { - const kindKey = `${s.severity}\u0000${k.kind}`; - return { - kind: "inspection", - key: kindKey, - name: k.kind, - count: k.count, - children: k.files.map((f) => { - const fileKey = `${kindKey}\u0000${f.path}`; - return { - kind: "file", - key: fileKey, - path: f.path, - count: f.problems.length, - children: f.problems.map((p, i) => ({ kind: "problem", key: `${fileKey}\u0000${i}`, p }) as Node), - } as Node; - }), - } as Node; - }), + children: s.kinds.map((k) => kindNode(k, s.severity, s.severity)), })); } -function open(p: InspectionProblem, focus = true) { - void editorService().openFile(p.path, { +function nodeProblems(n: Node): InspectionProblem[] { + return n.kind === "problem" ? [n.p] : n.children.flatMap(nodeProblems); +} + +function open(p: InspectionProblem, focus = true): Promise { + return editorService().openFile(p.path, { selection: { line: p.d.range.start.line, col: p.d.range.start.col }, focus, preview: !focus, }); } +/** Jump to the problem and show the editor's quick fixes there. */ +async function showFixes(p: InspectionProblem) { + await open(p, true); + void executeAction("code.showIntentions"); +} + export function InspectionHeaderActions() { const running = useInspection((s) => s.running); const last = useInspection((s) => s.last); return ( <> + + `${n} ${w}${n === 1 ? "" : "s"}`; export function InspectionView() { - const { files, running, total, done, summary, scopeLabel, error } = useInspection(); + const { files, running, total, done, summary, scopeLabel, error, excluded } = useInspection(); + const bySeverity = useInspectionOptions((o) => o.groupBySeverity); const root = useProject((s) => s.project?.root ?? ""); - const roots = useMemo(() => buildInspectionNodes(groupInspection(files.values())), [files]); + const roots = useMemo(() => buildInspectionNodes(groupInspection(files.values()), bySeverity), [files, bySeverity]); const [collapsed, setCollapsed] = useState>(() => new Set()); - // Severities and inspections start expanded; files too unless there are many problems. + const [lead, setLead] = useState(null); + const tree = useRef(null); + // Groups start expanded; files too unless there are many problems. const expanded = useMemo(() => { const out = new Set(); - const total = roots.reduce((n, r) => n + (r.kind === "severity" ? r.count : 0), 0); - const depthOpen = total <= 200 ? 3 : 2; + const total = roots.reduce((n, r) => n + (r.kind === "problem" ? 0 : r.count), 0); + const depthOpen = (total <= 200 ? 3 : 2) - (bySeverity ? 0 : 1); const visit = (nodes: Node[], depth: number) => { for (const n of nodes) { if (n.kind === "problem") continue; @@ -99,15 +234,40 @@ export function InspectionView() { }; visit(roots, 0); return out; - }, [roots, collapsed]); + }, [roots, collapsed, bySeverity]); const getChildren = useCallback((n: Node) => (n.kind === "problem" ? undefined : n.children), []); + const isExcluded = (n: Node) => nodeProblems(n).every((p) => excluded.has(problemId(p))); + + useOccurrenceNavigator("inspection", (dir, focus) => { + const step = tree.current?.selectOccurrence(dir); + const n = step?.node as Node | undefined; + if (!step || n?.kind !== "problem") return null; + void open(n.p, focus); + return step; + }); + + const exclude = (n: Node | null) => { + if (!n) return; + const problems = nodeProblems(n); + const wasExcluded = isExcluded(n); + toggleExcludedProblems(problems); + // Excluding moves on to the next problem, as in IDEA. + if (!wasExcluded) tree.current?.selectOccurrence(1, { skipSubtree: true }); + }; + const onKeyDown = (e: KeyboardEvent) => { + if ((e.key === "Backspace" || e.key === "Delete") && !e.metaKey && !e.altKey && !e.ctrlKey) { + e.preventDefault(); + exclude(lead); + } + }; const renderNode = (n: Node, state: TreeRowState) => { + const off = isExcluded(n) && styles.excluded; switch (n.kind) { case "severity": return ( - + {SEVERITY_LABEL[n.severity]} {plural(n.count, "problem")} @@ -115,14 +275,15 @@ export function InspectionView() { ); case "inspection": return ( - + + {!bySeverity && } {n.name} {n.count} ); case "file": return ( - + {basename(n.path)} {relativeDir(root, n.path)} @@ -131,7 +292,7 @@ export function InspectionView() { ); case "problem": return ( - + {n.p.d.message.split("\n")[0]} :{n.p.d.range.start.line + 1} @@ -145,7 +306,8 @@ export function InspectionView() { ? `${scopeLabel}: ${plural(summary.problems, "problem")} in ${plural(summary.files, "file")}` + (summary.skipped ? `, ${summary.skipped} without a language server` : "") + (summary.failed ? `, ${summary.failed} failed` : "") + - (summary.cancelled ? " (stopped)" : "") + (summary.cancelled ? " (stopped)" : "") + + (excluded.size ? `, ${excluded.size} excluded` : "") : null; return ( @@ -162,52 +324,132 @@ export function InspectionView() { )}
)} -
- - aria-label="Inspection results" - roots={roots} - getKey={(n) => n.key} - getLabel={(n) => - n.kind === "severity" - ? SEVERITY_LABEL[n.severity] - : n.kind === "inspection" - ? n.name - : n.kind === "file" - ? basename(n.path) - : n.p.d.message - } - getChildren={getChildren} - isExpandable={(n) => n.kind !== "problem"} - expanded={expanded} - onExpandedChange={(_next, node, isOpen) => - setCollapsed((prev) => { - const s = new Set(prev); - if (isOpen) s.delete(node.key); - else s.add(node.key); - return s; - }) - } - renderNode={renderNode} - onActivate={(n) => { - if (n.kind === "problem") open(n.p); - }} - onSelectionChange={(_sel, lead) => { - if (lead?.kind === "problem") open(lead.p, false); - }} - activateExpandable={false} - multiSelect={false} - empty={ - error ? ( - - ) : running ? ( - - ) : summary ? ( - - ) : ( - - ) - } - /> +
+ + + + aria-label="Inspection results" + handleRef={tree} + roots={roots} + getKey={(n) => n.key} + getLabel={(n) => + n.kind === "severity" + ? SEVERITY_LABEL[n.severity] + : n.kind === "inspection" + ? n.name + : n.kind === "file" + ? basename(n.path) + : n.p.d.message + } + getChildren={getChildren} + isExpandable={(n) => n.kind !== "problem"} + isOccurrence={(n) => n.kind === "problem" && !excluded.has(problemId(n.p))} + expanded={expanded} + onExpandedChange={(_next, node, isOpen) => + setCollapsed((prev) => { + const s = new Set(prev); + if (isOpen) s.delete(node.key); + else s.add(node.key); + return s; + }) + } + renderNode={renderNode} + onActivate={(n) => { + if (n.kind === "problem") void open(n.p); + }} + onSelectionChange={(_sel, node) => { + setLead(node); + // Autoscroll to Source (off by default, as in IDEA). + if (node?.kind === "problem" && useInspectionOptions.getState().autoscroll) void open(node.p, false); + }} + onContextMenu={(n, _sel, e) => { + if (!n) return; + showContextMenu(e, [ + ...(n.kind === "problem" + ? [ + { id: "jump", label: "Jump to Source", shortcut: "F4", run: () => void open(n.p) }, + { + id: "fixes", + label: "Show Quick Fixes", + icon: "bulb", + shortcut: shortcutLabel("code.showIntentions") ?? undefined, + run: () => void showFixes(n.p), + }, + { kind: "separator" as const }, + ] + : []), + { id: "exclude", label: isExcluded(n) ? "Include" : "Exclude", shortcut: "⌫", run: () => exclude(n) }, + ]); + }} + activateExpandable={false} + multiSelect={false} + empty={ + error ? ( + + ) : running ? ( + + ) : summary ? ( + + ) : ( + + ) + } + /> + + {roots.length > 0 && ( + <> + + + + + + )} + +
+
+ ); +} + +/** Right pane: the selected problem (or group) in full, with its actions. */ +function ProblemDetails({ node, root }: { node: Node | null; root: string }) { + if (!node || node.kind === "severity" || node.kind === "file") { + const count = node ? nodeProblems(node).length : 0; + return ( +
+ {node ? plural(count, "problem") : Select a problem to see its details} +
+ ); + } + if (node.kind === "inspection") { + return ( +
+
{node.name}
+
+ {SEVERITY_LABEL[node.severity]} · {plural(node.count, "problem")} +
+
+ ); + } + const { p } = node; + const sev = severityKey(p.d); + return ( +
+
+ + {p.d.source ? `${p.d.source}${p.d.code ? ` (${p.d.code})` : ""}` : Language server} +
+

{p.d.message}

+
+ {relativeDir(root, p.path) ? `${relativeDir(root, p.path)}/` : ""} + {basename(p.path)}:{p.d.range.start.line + 1}:{p.d.range.start.col + 1} +
+
+ +
); diff --git a/src/features/analysis/analysis.test.ts b/src/features/analysis/analysis.test.ts index f029a282..c783b2a7 100644 --- a/src/features/analysis/analysis.test.ts +++ b/src/features/analysis/analysis.test.ts @@ -68,6 +68,23 @@ describe("Inspect Code grouping", () => { expect(new Set(keys).size).toBe(keys.length); expect(nodes[0]!.kind).toBe("severity"); }); + + it("groups by inspection by default, worst severity first, merging severities", () => { + const withWarning = [...files, { path: "/p/d.ts", relPath: "d.ts", diagnostics: [diag(5, "warning", "ts", "2304")] }]; + const nodes = buildInspectionNodes(groupInspection(withWarning), false); + expect(nodes.map((n) => (n.kind === "inspection" ? [n.name, n.severity, n.count] : n.kind))).toEqual([ + ["ts(2304)", "error", 4], + ["ts(7006)", "error", 1], + ["eslint(no-unused-vars)", "warning", 1], + ["Language server", "info", 1], + ]); + const ts2304 = nodes[0]!; + expect(ts2304.kind !== "problem" && ts2304.children.map((f) => (f.kind === "file" ? f.path : ""))).toEqual([ + "/p/b.ts", + "/p/c.ts", + "/p/d.ts", + ]); + }); }); describe("Structural replace", () => { diff --git a/src/features/analysis/index.ts b/src/features/analysis/index.ts index a70ce1eb..aa0116bd 100644 --- a/src/features/analysis/index.ts +++ b/src/features/analysis/index.ts @@ -10,7 +10,7 @@ import { AnalysisDialogHost } from "./dialogs"; import { resetDuplicates } from "./duplicates"; import { DuplicatesHeaderActions, DuplicatesView } from "./DuplicatesView"; import { resetInspection } from "./inspect"; -import { InspectionHeaderActions, InspectionView } from "./InspectionView"; +import { InspectionHeaderActions, InspectionView, useInspectionHeaderMenu } from "./InspectionView"; export function activate(): () => void { const disposers = [ @@ -29,6 +29,7 @@ export function activate(): () => void { order: 36, component: InspectionView, headerActions: InspectionHeaderActions, + headerMenu: useInspectionHeaderMenu, }), registerToolWindow({ id: "duplicates", diff --git a/src/features/analysis/inspect.ts b/src/features/analysis/inspect.ts index ccb89585..505aa434 100644 --- a/src/features/analysis/inspect.ts +++ b/src/features/analysis/inspect.ts @@ -99,6 +99,8 @@ interface InspectState { error: string | null; /** Scope of the last run (Rerun). */ last: ScopeChoice | null; + /** Problems excluded from the results (⌫), by `problemId`. */ + excluded: ReadonlySet; } const initialInspection = (): InspectState => ({ @@ -110,8 +112,22 @@ const initialInspection = (): InspectState => ({ scopeLabel: null, error: null, last: null, + excluded: new Set(), }); +/** Stable identity of a problem across regroupings. */ +export const problemId = (p: InspectionProblem) => + `${p.path}\u0000${p.d.range.start.line}:${p.d.range.start.col}\u0000${kindOf(p.d)}\u0000${p.d.message}`; + +/** Excludes `problems`, or includes them again when all are excluded already. */ +export function toggleExcludedProblems(problems: readonly InspectionProblem[]) { + const ids = problems.map(problemId); + const excluded = new Set(useInspection.getState().excluded); + if (ids.every((id) => excluded.has(id))) ids.forEach((id) => excluded.delete(id)); + else ids.forEach((id) => excluded.add(id)); + useInspection.setState({ excluded }); +} + export const useInspection = create(initialInspection); let inflight: RequestId | null = null; @@ -146,6 +162,7 @@ export async function runInspection(choice: ScopeChoice): Promise { error: null, scopeLabel: describeScope(choice, scope), last: choice, + excluded: new Set(), }); toolWindowService().show("inspection"); if (!isTauri()) { diff --git a/src/features/app/__tests__/editCommand.test.ts b/src/features/app/__tests__/editCommand.test.ts new file mode 100644 index 00000000..6d1460cf --- /dev/null +++ b/src/features/app/__tests__/editCommand.test.ts @@ -0,0 +1,23 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { history } from "@codemirror/commands"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { editCommand } from "../appActions"; + +describe("Edit menu Undo / Redo", () => { + let view: EditorView | null = null; + afterEach(() => { + view?.destroy(); + view = null; + }); + + it("use the focused editor's history, including programmatic edits", () => { + view = new EditorView({ state: EditorState.create({ doc: "a", extensions: history() }), parent: document.body }); + view.dispatch({ changes: { from: 1, insert: "b" }, userEvent: "input.complete" }); + view.contentDOM.focus(); + editCommand("undo")(); + expect(view.state.doc.toString()).toBe("a"); + editCommand("redo")(); + expect(view.state.doc.toString()).toBe("ab"); + }); +}); diff --git a/src/features/app/appActions.ts b/src/features/app/appActions.ts index a78b961e..e6a95375 100644 --- a/src/features/app/appActions.ts +++ b/src/features/app/appActions.ts @@ -1,3 +1,5 @@ +import { redo, undo } from "@codemirror/commands"; +import { EditorView } from "@codemirror/view"; import type { Action } from "@/platform/actions"; import { t } from "@/platform/i18n"; import { call, commands, isTauri, type UpdateInfo } from "@/platform/ipc"; @@ -77,11 +79,20 @@ function notifyUpdate(update: UpdateInfo) { /** * Edit menu items on Windows/Linux (macOS uses native roles). They act on the - * focused element like the native commands would; CodeMirror handles the - * resulting `beforeinput` history events itself. + * focused element like the native commands would. Undo / Redo in an editor go + * to its own history: the native stack misses programmatic edits (templates, + * completion, refactorings). */ -function editCommand(command: "undo" | "redo" | "cut" | "copy" | "paste" | "selectAll") { +export function editCommand(command: "undo" | "redo" | "cut" | "copy" | "paste" | "selectAll") { return () => { + if (command === "undo" || command === "redo") { + const focused = document.activeElement; + const view = focused instanceof HTMLElement ? EditorView.findFromDOM(focused) : null; + if (view) { + (command === "undo" ? undo : redo)(view); + return; + } + } if (command === "paste" && navigator.clipboard?.readText) { void navigator.clipboard.readText().then((text) => document.execCommand("insertText", false, text)); return; diff --git a/src/features/code-insight/HierarchyView.tsx b/src/features/code-insight/HierarchyView.tsx index 55f6b90b..4ea71acd 100644 --- a/src/features/code-insight/HierarchyView.tsx +++ b/src/features/code-insight/HierarchyView.tsx @@ -1,11 +1,25 @@ /** "hierarchy" tool window: call hierarchy (⌃⌥H) and type hierarchy (⌃H). */ -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useRef } from "react"; +import { create } from "zustand"; import { basename, relativePath, useProject } from "@/platform/project"; -import { EmptyState, IconButton, Spinner, SymbolBadge, Tabs, Tree, badgeKind, type TreeRowState, T } from "@/ui"; +import { maybeEditorService, useOccurrenceNavigator } from "@/workbench/contributions"; +import { + EmptyState, + IconButton, + Spinner, + SymbolBadge, + Tabs, + Tree, + badgeKind, + type TreeHandle, + type TreeRowState, + T, +} from "@/ui"; import { openLocation } from "./ipc"; import { ROOT_ID, directionLabel, + hierarchyTarget, loadChildren, refreshHierarchy, setDirection, @@ -16,6 +30,25 @@ import styles from "./HierarchyView.module.css"; type Node = { kind: "root"; id: string } | { kind: "item"; id: string; node: HierarchyNode }; +const OPTIONS_KEY = "openidea.hierarchy.view.v1"; + +/** Autoscroll to Source: selecting a node shows it in the editor (IDEA: off). Persisted. */ +export const useHierarchyOptions = create<{ autoscroll: boolean }>(() => { + try { + return { autoscroll: false, ...JSON.parse(globalThis.localStorage?.getItem(OPTIONS_KEY) ?? "{}") }; + } catch { + return { autoscroll: false }; + } +}); + +useHierarchyOptions.subscribe((s) => { + try { + globalThis.localStorage?.setItem(OPTIONS_KEY, JSON.stringify(s)); + } catch { + /* storage unavailable */ + } +}); + /** Tool window header: `Callers of foo` content tab. */ export function HierarchyTabs() { const mode = useHierarchy((s) => s.mode); @@ -38,6 +71,7 @@ export function HierarchyTabs() { function HierarchyToolbar() { const mode = useHierarchy((s) => s.mode); const direction = useHierarchy((s) => s.direction); + const autoscroll = useHierarchyOptions((s) => s.autoscroll); return (
+ + + + useHierarchyOptions.setState({ autoscroll: !autoscroll })} + />
); } @@ -78,6 +123,23 @@ export function HierarchyView() { }, [children], ); + const tree = useRef(null); + const targetOf = (n: Node) => { + if (n.kind === "item") return hierarchyTarget(mode, direction, n.node.item); + return root ? { path: root.path, line: root.line, col: root.col } : null; + }; + const show = (n: Node, focus: boolean) => { + const t = targetOf(n); + if (t) void maybeEditorService()?.openFile(t.path, { selection: { line: t.line, col: t.col }, focus }); + }; + + // Every node is an occurrence, as in IDEA's hierarchy views. + useOccurrenceNavigator("hierarchy", (dir, focus) => { + const step = tree.current?.selectOccurrence(dir); + if (!step) return null; + show(step.node as Node, focus); + return step; + }); if (!root) return ( @@ -141,15 +203,17 @@ export function HierarchyView() { isExpandable={isExpandable} loadChildren={(n) => (n.kind === "item" ? loadChildren(n.node) : undefined)} defaultExpanded={[ROOT_ID]} + handleRef={tree} + isOccurrence={() => true} + onSelectionChange={(_sel, n) => { + if (n && useHierarchyOptions.getState().autoscroll) show(n, false); + }} renderNode={renderNode} activateExpandable multiSelect={false} onActivate={(n) => { - if (n.kind === "root") openLocation(root.path, root.line, root.col); - else { - const target = n.node.item.selectionRange.start; - openLocation(n.node.item.path, target.line, target.col); - } + const target = n.kind === "root" ? root : hierarchyTarget(mode, direction, n.node.item); + void openLocation(target.path, target.line, target.col); }} />
diff --git a/src/features/code-insight/ProblemsView.module.css b/src/features/code-insight/ProblemsView.module.css index fda40a6a..be2e71b6 100644 --- a/src/features/code-insight/ProblemsView.module.css +++ b/src/features/code-insight/ProblemsView.module.css @@ -89,3 +89,13 @@ flex: none; color: var(--info); } +.panel { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; +} +.panel > * { + flex: 1; + min-height: 0; +} diff --git a/src/features/code-insight/ProblemsView.tsx b/src/features/code-insight/ProblemsView.tsx index cfe9f1e2..5b60883a 100644 --- a/src/features/code-insight/ProblemsView.tsx +++ b/src/features/code-insight/ProblemsView.tsx @@ -1,13 +1,40 @@ /** Problems tool window: "File" (current editor) and "Project Errors" tabs. */ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo, useRef, useState, type KeyboardEvent } from "react"; import { create } from "zustand"; import clsx from "clsx"; import type { Diagnostic, Severity } from "@/bindings"; import { useEditorRegistry } from "@/editor/api"; +import { executeAction } from "@/platform/actions"; +import { formatBinding, hasPrimaryModifier, hostBinding, shortcutLabel } from "@/platform/keymap"; import { basename, relativeDir, useProject } from "@/platform/project"; -import { editorService } from "@/workbench/contributions"; -import { EmptyState, Icon, IconButton, Tabs, Tree, fileIconName, type TreeRowState, T } from "@/ui"; -import { countSeverities, fileDiagnostics, useProblems, type ProblemsMap } from "./problemsStore"; +import { editorService, useOccurrenceNavigator } from "@/workbench/contributions"; +import { + EmptyState, + Icon, + IconButton, + SourcePreview, + SplitGroup, + SplitHandle, + SplitPanel, + Tabs, + Tree, + fileIconName, + showContextMenu, + type MenuItem, + type TreeHandle, + type TreeRowState, + T, +} from "@/ui"; +import { previewText } from "./ipc"; +import { + DEFAULT_PROBLEM_ORDER, + countSeverities, + fileDiagnostics, + sortProblems, + useProblems, + type ProblemOrder, + type ProblemsMap, +} from "./problemsStore"; import styles from "./ProblemsView.module.css"; type Tab = "file" | "project"; @@ -26,6 +53,56 @@ export const useProblemsView = create((set) => ({ toggle: (sev) => set((s) => ({ show: { ...s.show, [sev]: !s.show[sev] } })), })); +// --------------------------------------------------------------------------- +// View options (persisted), in the tool window's ⋮ menu. IDEA's defaults: +// no autoscroll, no preview, sorted by severity. + +const OPTIONS_KEY = "openidea.problems.view.v1"; + +interface ProblemsOptions extends ProblemOrder { + /** Selecting a problem opens it in the editor. */ + autoscroll: boolean; + /** Source preview beside the list. */ + preview: boolean; +} + +function loadOptions(): Partial { + try { + return JSON.parse(globalThis.localStorage?.getItem(OPTIONS_KEY) ?? "{}") as Partial; + } catch { + return {}; + } +} + +export const useProblemsOptions = create(() => ({ + autoscroll: false, + preview: false, + ...DEFAULT_PROBLEM_ORDER, + ...loadOptions(), +})); + +useProblemsOptions.subscribe((s) => { + try { + globalThis.localStorage?.setItem(OPTIONS_KEY, JSON.stringify(s)); + } catch { + /* storage unavailable */ + } +}); + +/** The Problems tool window's ⋮ menu entries. */ +export function useProblemsHeaderMenu(): MenuItem[] { + const o = useProblemsOptions(); + const toggle = (key: keyof ProblemsOptions) => () => useProblemsOptions.setState({ [key]: !o[key] }); + return [ + { kind: "header", label: "Sort By" }, + { id: "bySeverity", label: "Severity", checked: o.bySeverity, run: toggle("bySeverity") }, + { id: "byName", label: "Name", checked: o.byName, run: toggle("byName") }, + { kind: "separator" }, + { id: "preview", label: "Show Preview", checked: o.preview, run: toggle("preview") }, + { id: "autoscroll", label: "Autoscroll to Source", checked: o.autoscroll, run: toggle("autoscroll") }, + ]; +} + const group = (s: Severity): "error" | "warning" | "info" => (s === "hint" ? "info" : s); type Node = @@ -36,10 +113,18 @@ const plural = (n: number, word: string) => (n ? `${n} ${word}${n === 1 ? "" : " const SEVERITY_ICON: Record = { error: "error", warning: "warning", info: "info", hint: "info" }; -export function buildProblemNodes(map: ProblemsMap, paths: readonly string[], show: ViewState["show"]): Node[] { +export function buildProblemNodes( + map: ProblemsMap, + paths: readonly string[], + show: ViewState["show"], + order: ProblemOrder = DEFAULT_PROBLEM_ORDER, +): Node[] { const files: Node[] = []; for (const path of paths) { - const items = fileDiagnostics(map, path).filter((d) => show[group(d.severity)]); + const items = sortProblems( + fileDiagnostics(map, path).filter((d) => show[group(d.severity)]), + order, + ); if (!items.length) continue; files.push({ kind: "file", key: path, path, items }); } @@ -47,14 +132,22 @@ export function buildProblemNodes(map: ProblemsMap, paths: readonly string[], sh return files.sort((a, b) => rank(a) - rank(b) || a.path.localeCompare(b.path)); } -function open(path: string, d: Diagnostic, focus = true) { - void editorService().openFile(path, { +function open(path: string, d: Diagnostic, focus = true): Promise { + return editorService().openFile(path, { selection: { line: d.range.start.line, col: d.range.start.col }, focus, preview: !focus, }); } +const problemText = (d: Diagnostic) => `${d.message}${d.source ? ` (${d.source}${d.code ? `(${d.code})` : ""})` : ""}`; + +/** ⌥⏎ on a problem: jump to it and show its quick fixes (the editor's context actions). */ +async function showQuickFixes(path: string, d: Diagnostic) { + await open(path, d, true); + void executeAction("code.showIntentions"); +} + function useCurrentPath(): string | null { return useEditorRegistry((s) => s.active?.ctx.path ?? null); } @@ -64,6 +157,8 @@ export function ProblemsHeaderActions() { const toggle = useProblemsView((s) => s.toggle); return ( <> + + toggle("error")} /> s.map); const { tab, show } = useProblemsView(); + const options = useProblemsOptions(); const current = useCurrentPath(); const root = useProject((s) => s.project?.root ?? ""); const [collapsed, setCollapsed] = useState>(() => new Set()); + const [lead, setLead] = useState(null); + const tree = useRef(null); + const order = useMemo( + () => ({ bySeverity: options.bySeverity, byName: options.byName }), + [options.bySeverity, options.byName], + ); const roots = useMemo(() => { const paths = tab === "file" ? (current ? [current] : []) : [...map.keys()]; - return buildProblemNodes(map, paths, show); - }, [map, tab, current, show]); + return buildProblemNodes(map, paths, show, order); + }, [map, tab, current, show, order]); + + useOccurrenceNavigator("problems", (dir, focus) => { + const step = tree.current?.selectOccurrence(dir); + const n = step?.node as Node | undefined; + if (!step || n?.kind !== "diag") return null; + void open(n.path, n.d, focus); + return step; + }); + + const target = (n: Node | null) => + n?.kind === "diag" ? { path: n.path, d: n.d } : n?.items[0] ? { path: n.path, d: n.items[0] } : null; + const copy = (n: Node | null) => { + if (n?.kind === "diag") void navigator.clipboard?.writeText(problemText(n.d)); + }; + const onKeyDownCapture = (e: KeyboardEvent) => { + const t = target(lead); + if (e.key === "Enter" && e.altKey && !e.metaKey && !e.ctrlKey && t) { + e.preventDefault(); + e.stopPropagation(); + void showQuickFixes(t.path, t.d); + } else if ((e.key === "c" || e.key === "C") && hasPrimaryModifier(e) && !e.altKey && lead?.kind === "diag") { + e.preventDefault(); + e.stopPropagation(); + copy(lead); + } + }; const expanded = useMemo( () => new Set(roots.filter((r) => !collapsed.has(r.key)).map((r) => r.key)), @@ -196,39 +324,93 @@ export function ProblemsView() { ); + const selected = target(lead); + const list = ( + + aria-label="Problems" + handleRef={tree} + roots={roots} + getKey={(n) => n.key} + getLabel={(n) => (n.kind === "file" ? basename(n.path) : n.d.message)} + getChildren={getChildren} + isExpandable={(n) => n.kind === "file"} + expanded={expanded} + onExpandedChange={(_next, node, isOpen) => + setCollapsed((prev) => { + const s = new Set(prev); + if (isOpen) s.delete(node.key); + else s.add(node.key); + return s; + }) + } + renderNode={renderNode} + onActivate={(n) => { + if (n.kind === "diag") void open(n.path, n.d); + else if (n.items[0]) void open(n.path, n.items[0]); + }} + onSelectionChange={(_sel, node) => { + setLead(node); + // Autoscroll to Source (off by default, as in IDEA): show the problem without leaving the list. + if (node?.kind === "diag" && useProblemsOptions.getState().autoscroll) void open(node.path, node.d, false); + }} + onContextMenu={(n, _sel, e) => { + const t = target(n); + if (!n || !t) return; + showContextMenu(e, [ + { + id: "showFixes", + label: "Show Quick Fixes", + icon: "bulb", + shortcut: shortcutLabel("code.showIntentions") ?? undefined, + run: () => void showQuickFixes(t.path, t.d), + }, + { id: "jump", label: "Jump to Source", shortcut: "F4", run: () => void open(t.path, t.d) }, + { kind: "separator" }, + { + id: "copy", + label: "Copy Problem Description", + icon: "copy", + shortcut: formatBinding(hostBinding("Meta+C")), + disabled: n.kind !== "diag", + run: () => copy(n), + }, + ]); + }} + activateExpandable={false} + multiSelect={false} + empty={empty} + /> + ); + return (
-
- - aria-label="Problems" - roots={roots} - getKey={(n) => n.key} - getLabel={(n) => (n.kind === "file" ? basename(n.path) : n.d.message)} - getChildren={getChildren} - isExpandable={(n) => n.kind === "file"} - expanded={expanded} - onExpandedChange={(_next, node, isOpen) => - setCollapsed((prev) => { - const s = new Set(prev); - if (isOpen) s.delete(node.key); - else s.add(node.key); - return s; - }) - } - renderNode={renderNode} - onActivate={(n) => { - if (n.kind === "diag") open(n.path, n.d); - else if (n.items[0]) open(n.path, n.items[0]); - }} - onSelectionChange={(_sel, lead) => { - // IDEA's autoscroll-to-source: selecting a problem shows it without leaving the list. - if (lead?.kind === "diag") open(lead.path, lead.d, false); - }} - activateExpandable={false} - multiSelect={false} - empty={empty} - /> +
+ {options.preview ? ( + + + {list} + + + + + + + ) : ( + list + )}
); } + +/** The problem's range on its first line. */ +function diagHighlight(d: Diagnostic) { + const { start, end } = d.range; + const to = end.line === start.line ? end.col : Number.MAX_SAFE_INTEGER; + return { line: start.line, ranges: [[start.col, Math.max(start.col + 1, to)] as const] }; +} diff --git a/src/features/code-insight/UsagesPopup.module.css b/src/features/code-insight/UsagesPopup.module.css index d00d9893..92554fbe 100644 --- a/src/features/code-insight/UsagesPopup.module.css +++ b/src/features/code-insight/UsagesPopup.module.css @@ -77,3 +77,47 @@ font-style: italic; color: var(--fg-subtle); } + +.focus { + display: flex; + flex-direction: column; + min-height: 0; + outline: none; +} +.filter { + flex: none; + font-family: var(--font-mono); + color: var(--fg-strong); + background: var(--accent-soft); + border-radius: var(--radius-small); + padding: 0 4px; +} +.noMatch { + color: var(--danger); +} +.empty { + padding: 4px 10px; + color: var(--fg-faint); + font-size: var(--fs-small); +} +.preview { + flex: none; + height: 200px; + border-top: 1px solid var(--border-popup); + overflow: hidden; +} +.previewEditor { + height: 100%; +} +.previewEditor :global(.cm-editor) { + height: 100%; +} +.previewHidden { + visibility: hidden; +} +.previewLine { + background: var(--bg-current-line); +} +.previewMatch { + background: var(--bg-text-search-result); +} diff --git a/src/features/code-insight/UsagesPopup.tsx b/src/features/code-insight/UsagesPopup.tsx index 1c74f73a..96537c65 100644 --- a/src/features/code-insight/UsagesPopup.tsx +++ b/src/features/code-insight/UsagesPopup.tsx @@ -1,70 +1,241 @@ -/** ⌥⌘F7 Show Usages: popup list at the caret. */ -import { useMemo, useState } from "react"; +/** + * ⌥⌘F7 Show Usages: popup list at the caret with speed search (typing + * filters), an optional code preview and "Open in Find Window", which moves + * the found usages to the Find Usages tool window. + */ +import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; +import { create } from "zustand"; +import clsx from "clsx"; +import { EditorState, StateEffect, StateField } from "@codemirror/state"; +import { Decoration, EditorView } from "@codemirror/view"; +import { codePreviewExtensions } from "@/editor/api"; import { basename } from "@/platform/project"; -import { executeAction } from "@/platform/actions"; import { shortcutLabel } from "@/platform/keymap"; -import { Icon, List, Popup, fileIconName } from "@/ui"; -import { openLocation } from "./ipc"; -import { usageCount, useUsagesPopup, type Usage } from "./usages"; +import { Icon, IconButton, List, Popup, fileIconName, listNavigate } from "@/ui"; +import { openLocation, readFileLines } from "./ipc"; +import { showUsageResults, usageCount, useUsagesPopup, type Usage, type UsageGroup } from "./usages"; import { UsagePreview } from "./UsagesView"; import styles from "./UsagesPopup.module.css"; const close = () => useUsagesPopup.setState({ open: false }); +const PREVIEW_KEY = "openidea.usages.popupPreview.v1"; + +function loadPreviewOption(): boolean { + try { + return localStorage.getItem(PREVIEW_KEY) === "true"; + } catch { + return false; + } +} + +/** The preview pane toggle (remembered). */ +export const useUsagesPopupPreview = create<{ preview: boolean }>(() => ({ preview: loadPreviewOption() })); + +export function setUsagesPopupPreview(preview: boolean) { + useUsagesPopupPreview.setState({ preview }); + try { + localStorage.setItem(PREVIEW_KEY, String(preview)); + } catch { + // storage unavailable: the toggle lasts for the session + } +} + +/** Usages whose file name or line text contains the typed text (case-insensitive). */ +export function filterUsages(items: readonly Usage[], filter: string): Usage[] { + const q = filter.trim().toLowerCase(); + if (!q) return items as Usage[]; + return items.filter((u) => u.text.toLowerCase().includes(q) || basename(u.path).toLowerCase().includes(q)); +} + +/** The popup's groups as locations for the Find Usages tool window. */ +export function usageLocations(groups: readonly UsageGroup[]) { + return groups.flatMap((g) => + g.usages.map((u) => ({ + path: u.path, + range: { start: { line: u.line, col: u.col }, end: { line: u.line, col: u.endCol } }, + })), + ); +} + export function UsagesPopup() { const open = useUsagesPopup((s) => s.open); return open ? : null; } +const ROW = 24; + function UsagesPopupInner() { const { anchor, symbol, groups, note } = useUsagesPopup(); - const items = useMemo(() => groups.flatMap((g) => g.usages), [groups]); + const preview = useUsagesPopupPreview((s) => s.preview); + const all = useMemo(() => groups.flatMap((g) => g.usages), [groups]); + const [filter, setFilter] = useState(""); + const items = useMemo(() => filterUsages(all, filter), [all, filter]); const [active, setActive] = useState(0); + const current = Math.min(active, items.length - 1); const total = usageCount(groups); - const pick = (u: Usage) => { + const pick = (u: Usage | undefined) => { + if (!u) return; close(); - openLocation(u.path, u.line, u.col); + void openLocation(u.path, u.line, u.col); }; + const openInFindWindow = () => { + close(); + void showUsageResults({ symbol, locations: usageLocations(groups) }); + }; + + const onKeyDown = (e: KeyboardEvent) => { + const next = listNavigate(e.key, { items, active: current, wrap: true, pageSize: 10 }); + if (next !== null) { + e.preventDefault(); + setActive(next); + return; + } + const plain = !e.metaKey && !e.ctrlKey && !e.altKey; + if (e.key === "Enter") { + e.preventDefault(); + pick(items[current]); + } else if (e.key === "Escape" && filter) { + // The first Esc clears the speed search, the next one closes. + e.preventDefault(); + e.stopPropagation(); + setFilter(""); + setActive(0); + } else if (e.key === "Backspace" && filter) { + e.preventDefault(); + setFilter((f) => f.slice(0, -1)); + setActive(0); + } else if (plain && e.key.length === 1 && (filter || e.key !== " ")) { + e.preventDefault(); + setFilter((f) => f + e.key); + setActive(0); + } + }; + const keys = shortcutLabel("find.usages"); return ( -
- - Usages{symbol ? ` of ${symbol}` : ""} {total} found - - -
- {note &&
{note}
} -
- `${u.path}:${u.line}:${u.col}`} - activeIndex={Math.min(active, items.length - 1)} - onActiveChange={setActive} - hoverSelects - aria-label="Usages" - onActivate={pick} - renderItem={(u) => ( -
- - - - - {basename(u.path)}:{u.line + 1} - -
+
+
+ + Usages{symbol ? ` of ${symbol}` : ""}{" "} + {filter ? `${items.length} of ${total}` : total} found + + {filter && ( + + {filter} + )} - /> + e.preventDefault()} + onClick={() => setUsagesPopupPreview(!preview)} + /> + +
+ {note &&
{note}
} +
+ `${u.path}:${u.line}:${u.col}`} + activeIndex={current} + onActiveChange={setActive} + focusable={false} + hoverSelects + rowHeight={ROW} + aria-label="Usages" + onActivate={pick} + empty={
Nothing matches '{filter}'
} + renderItem={(u) => ( +
+ + + + + {basename(u.path)}:{u.line + 1} + +
+ )} + /> +
+ {preview && }
); } + +const setMark = StateEffect.define<{ from: number; to: number } | null>(); +const usageMark = Decoration.mark({ class: styles.previewMatch }); +const usageLine = Decoration.line({ class: styles.previewLine }); +const marks = StateField.define({ + create: () => Decoration.none, + update(value, tr) { + for (const e of tr.effects) { + if (!e.is(setMark)) continue; + if (!e.value) return Decoration.none; + const line = tr.state.doc.lineAt(e.value.from); + const ranges = [usageLine.range(line.from)]; + if (e.value.to > e.value.from) ranges.push(usageMark.range(e.value.from, e.value.to)); + return Decoration.set(ranges); + } + return value.map(tr.changes); + }, + provide: (f) => EditorView.decorations.from(f), +}); + +/** Read-only view of the usage's file around its line, the usage marked. */ +function UsageCodePreview({ usage }: { usage: Usage | null }) { + const host = useRef(null); + const view = useRef(null); + const [loaded, setLoaded] = useState(null); + + useEffect(() => { + if (!host.current) return; + const v = new EditorView({ parent: host.current, state: EditorState.create({ extensions: marks }) }); + view.current = v; + return () => { + v.destroy(); + view.current = null; + }; + }, []); + + const path = usage?.path ?? null; + useEffect(() => { + if (!path) return; + let alive = true; + // Debounced so arrowing through the list doesn't load every file. + const t = setTimeout(() => { + void Promise.all([readFileLines(path), codePreviewExtensions(path)]).then(([lines, extensions]) => { + if (!alive || !view.current) return; + view.current.setState(EditorState.create({ doc: (lines ?? []).join("\n"), extensions: [extensions, marks] })); + setLoaded(path); + }); + }, 60); + return () => { + alive = false; + clearTimeout(t); + }; + }, [path]); + + useEffect(() => { + const v = view.current; + if (!v || !usage || loaded !== usage.path) return; + const doc = v.state.doc; + if (usage.line >= doc.lines) return; + const line = doc.line(usage.line + 1); + const from = line.from + Math.min(usage.col, line.length); + const to = line.from + Math.min(usage.endCol, line.length); + v.dispatch({ effects: [setMark.of({ from, to }), EditorView.scrollIntoView(from, { y: "center" })] }); + }, [usage, loaded]); + + return ( +
+
+
+ ); +} diff --git a/src/features/code-insight/UsagesView.module.css b/src/features/code-insight/UsagesView.module.css index 39818edd..6398fb41 100644 --- a/src/features/code-insight/UsagesView.module.css +++ b/src/features/code-insight/UsagesView.module.css @@ -102,3 +102,19 @@ font-style: italic; color: var(--fg-subtle); } +.excluded .preview, +.excluded .file, +.excluded .line { + opacity: 0.5; + text-decoration: line-through; +} +.panel { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; +} +.panel > * { + flex: 1; + min-height: 0; +} diff --git a/src/features/code-insight/UsagesView.tsx b/src/features/code-insight/UsagesView.tsx index 4c71625f..12109ced 100644 --- a/src/features/code-insight/UsagesView.tsx +++ b/src/features/code-insight/UsagesView.tsx @@ -1,10 +1,43 @@ -/** "usages" tool window: Find Usages results grouped by file with line previews. */ -import { useCallback, useMemo, useState } from "react"; +/** + * "usages" tool window: Find Usages results grouped by file with line + * previews, one tab per search (pinnable, as the Find tool window's tabs). + */ +import { useCallback, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; +import clsx from "clsx"; +import { create } from "zustand"; import { formatMessage, useTx } from "@/platform/i18n"; +import { shortcutLabel } from "@/platform/keymap"; import { basename, relativeDir, useProject } from "@/platform/project"; -import { EmptyState, Icon, IconButton, Spinner, Tabs, Tree, fileIconName, type TreeRowState, T } from "@/ui"; -import { openLocation } from "./ipc"; -import { trimPreview, usageCount, useUsages, type Usage, type UsageGroup } from "./usages"; +import { maybeEditorService, useOccurrenceNavigator } from "@/workbench/contributions"; +import { + EmptyState, + Icon, + IconButton, + SourcePreview, + SplitGroup, + SplitHandle, + SplitPanel, + Spinner, + Tabs, + Tree, + fileIconName, + showContextMenu, + type MenuItem, + type TreeHandle, + type TreeRowState, + T, +} from "@/ui"; +import { previewText } from "./ipc"; +import { + activeUsageSearch, + closeUsageTab, + pinUsageTab, + trimPreview, + usageCount, + useUsages, + type Usage, + type UsageGroup, +} from "./usages"; import styles from "./UsagesView.module.css"; type Node = @@ -23,29 +56,78 @@ export function UsagePreview({ usage }: { usage: Usage }) { ); } -/** Tool window header: the current search as a content tab (`Usages of 'foo'`). */ +/** Tool window header: one content tab per search (`Usages of 'foo'`). */ export function UsagesTabs() { const tx = useTx(); - const search = useUsages((s) => s.search); + const tabs = useUsages((s) => s.tabs); + const search = useUsages(activeUsageSearch); if (!search) return null; - const label = search.symbol ? formatMessage(tx("Usages of '{symbol}'"), { symbol: search.symbol }) : tx("Usages"); + const label = (t: (typeof tabs)[number]) => + t.symbol ? formatMessage(tx("Usages of '{symbol}'"), { symbol: t.symbol }) : tx("Usages"); return ( ({ + id: String(t.id), + label: label(t), + closable: true, + icon: t.pinned ? "pin" : undefined, + }))} activeId={String(search.id)} - onSelect={() => undefined} + onSelect={(id) => useUsages.setState({ active: Number(id) })} + onClose={(id) => closeUsageTab(Number(id))} + onContextMenu={(id, e) => { + const t = tabs.find((x) => x.id === Number(id)); + if (!t) return; + showContextMenu(e, [ + { id: "pin", label: t.pinned ? "Unpin Tab" : "Pin Tab", run: () => pinUsageTab(t.id, !t.pinned) }, + { kind: "separator" }, + { id: "close", label: "Close Tab", run: () => closeUsageTab(t.id) }, + { + id: "closeOthers", + label: "Close Other Tabs", + disabled: tabs.length < 2, + run: () => tabs.forEach((o) => o.id !== t.id && closeUsageTab(o.id)), + }, + ]); + }} /> ); } const SUMMARY_KEY = "\u0000summary"; +const usageKey = (u: Usage) => `${u.path}:${u.line}:${u.col}`; + +/** Excluded (⌫) usages per search. */ +const useExcluded = create<{ byTab: Record> }>(() => ({ byTab: {} })); +const NONE: ReadonlySet = new Set(); + +/** Usages under a node (itself for a usage). */ +function nodeUsages(n: Node, files: readonly Node[]): Usage[] { + if (n.kind === "usage") return [n.usage]; + if (n.kind === "file") return n.group.usages; + return files.flatMap((f) => (f.kind === "file" ? f.group.usages : [])); +} + +/** Toggles exclusion of `usages`: all excluded already → include them, else exclude them all. */ +export function toggleExcludedUsages(excluded: ReadonlySet, usages: readonly Usage[]): Set { + const keys = usages.map(usageKey); + const next = new Set(excluded); + if (keys.every((k) => excluded.has(k))) keys.forEach((k) => next.delete(k)); + else keys.forEach((k) => next.add(k)); + return next; +} + +function openUsage(u: Usage, focus: boolean) { + void maybeEditorService()?.openFile(u.path, { selection: { line: u.line, col: u.col }, focus }); +} + export function UsagesView() { const tx = useTx(); - const search = useUsages((s) => s.search); + const search = useUsages(activeUsageSearch); const root = useProject((s) => s.project?.root ?? ""); const [collapsed, setCollapsed] = useState<{ id: number | null; keys: ReadonlySet }>({ id: null, @@ -66,10 +148,21 @@ export function UsagesView() { n.kind === "summary" ? files : n.kind === "file" - ? n.group.usages.map((u) => ({ kind: "usage", key: `${u.path}:${u.line}:${u.col}`, usage: u }) as Node) + ? n.group.usages.map((u) => ({ kind: "usage", key: usageKey(u), usage: u }) as Node) : undefined, [files], ); + const tree = useRef(null); + const [lead, setLead] = useState(null); + const excluded = useExcluded((s) => (searchId != null ? s.byTab[searchId] : undefined)) ?? NONE; + + useOccurrenceNavigator("usages", (dir, focus) => { + const step = tree.current?.selectOccurrence(dir); + const n = step?.node as Node | undefined; + if (!step || n?.kind !== "usage") return null; + openUsage(n.usage, focus); + return step; + }); if (!search) return ( @@ -81,6 +174,21 @@ export function UsagesView() { const setAll = (collapse: boolean) => setCollapsed({ id: search.id, keys: collapse ? new Set(files.map((f) => f.key)) : new Set() }); + const exclude = () => { + if (!lead) return; + const next = toggleExcludedUsages(excluded, nodeUsages(lead, files)); + useExcluded.setState((s) => ({ byTab: { ...s.byTab, [search.id]: next } })); + // Excluding moves on to the next usage, as in IDEA. + if (next.size > excluded.size) tree.current?.selectOccurrence(1, { skipSubtree: true }); + }; + const onKeyDown = (e: KeyboardEvent) => { + if ((e.key === "Backspace" || e.key === "Delete") && !e.metaKey && !e.altKey && !e.ctrlKey) { + e.preventDefault(); + exclude(); + } + }; + const isExcluded = (n: Node) => nodeUsages(n, files).every((u) => excluded.has(usageKey(u))); + const renderNode = (n: Node, _state: TreeRowState) => { if (n.kind === "summary") return ( @@ -97,7 +205,7 @@ export function UsagesView() { if (n.kind === "file") { const path = n.group.path; return ( - + {basename(path)} {relativeDir(root, path)} @@ -108,7 +216,7 @@ export function UsagesView() { ); } return ( - + {n.usage.line + 1} @@ -123,14 +231,18 @@ export function UsagesView() { title={tx("Rerun Find Usages")} size="small" iconSize={16} + shortcut={shortcutLabel("usages.rerun")} disabled={!search.rerun} onClick={() => search.rerun?.()} /> + + + setAll(false)} /> setAll(true)} />
-
+
{(search.loading || search.error) && (
{search.loading ? ( @@ -146,38 +258,124 @@ export function UsagesView() { )}
)} - - key={search.id} - aria-label="Usages" - roots={roots} - getKey={(n) => n.key} - getLabel={(n) => - n.kind === "summary" ? tx("Found usages") : n.kind === "file" ? basename(n.group.path) : n.usage.text.trim() - } - getChildren={getChildren} - isExpandable={(n) => n.kind !== "usage"} - expanded={expanded} - onExpandedChange={(next) => - setCollapsed({ - id: search.id, - keys: new Set([SUMMARY_KEY, ...files.map((f) => f.key)].filter((k) => !next.has(k))), - }) - } - renderNode={renderNode} - multiSelect={false} - onActivate={(n) => { - const u = n.kind === "usage" ? n.usage : n.kind === "file" ? n.group.usages[0] : undefined; - if (u) openLocation(u.path, u.line, u.col); - }} - empty={ - !search.loading && !search.error ? ( - - No usages found - - ) : undefined - } - /> + }> + + key={search.id} + aria-label="Usages" + handleRef={tree} + roots={roots} + getKey={(n) => n.key} + getLabel={(n) => + n.kind === "summary" + ? tx("Found usages") + : n.kind === "file" + ? basename(n.group.path) + : n.usage.text.trim() + } + getChildren={getChildren} + isExpandable={(n) => n.kind !== "usage"} + isOccurrence={(n) => n.kind === "usage" && !excluded.has(n.key)} + expanded={expanded} + onExpandedChange={(next) => + setCollapsed({ + id: search.id, + keys: new Set([SUMMARY_KEY, ...files.map((f) => f.key)].filter((k) => !next.has(k))), + }) + } + renderNode={renderNode} + multiSelect={false} + onSelectionChange={(_sel, node) => setLead(node)} + onContextMenu={(n, _sel, e) => { + if (!n) return; + showContextMenu(e, [ + { + id: "exclude", + label: isExcluded(n) ? "Include" : "Exclude", + shortcut: "⌫", + run: () => { + setLead(n); + const next = toggleExcludedUsages(excluded, nodeUsages(n, files)); + useExcluded.setState((s) => ({ byTab: { ...s.byTab, [search.id]: next } })); + }, + }, + ]); + }} + onActivate={(n) => { + const u = n.kind === "usage" ? n.usage : n.kind === "file" ? n.group.usages[0] : undefined; + if (u) openUsage(u, true); + }} + empty={ + !search.loading && !search.error ? ( + + No usages found + + ) : undefined + } + /> +
); } + +// --------------------------------------------------------------------------- +// Preview pane (persisted toggle in the ⋮ menu; on by default, like the Find tool window's). + +const OPTIONS_KEY = "openidea.usages.view.v1"; + +export const useUsagesOptions = create<{ preview: boolean }>(() => { + try { + return { preview: true, ...JSON.parse(globalThis.localStorage?.getItem(OPTIONS_KEY) ?? "{}") }; + } catch { + return { preview: true }; + } +}); + +useUsagesOptions.subscribe((s) => { + try { + globalThis.localStorage?.setItem(OPTIONS_KEY, JSON.stringify(s)); + } catch { + /* storage unavailable */ + } +}); + +/** The Find Usages tool window's ⋮ menu entries. */ +export function useUsagesHeaderMenu(): MenuItem[] { + const preview = useUsagesOptions((s) => s.preview); + return [ + { + id: "preview", + label: "Preview Source", + checked: preview, + run: () => useUsagesOptions.setState({ preview: !preview }), + }, + ]; +} + +function UsagesSplit({ preview, children }: { preview: ReactNode; children: ReactNode }) { + const show = useUsagesOptions((s) => s.preview); + if (!show) return children; + return ( + + + {children} + + + + {preview} + + + ); +} + +function UsagesPreview({ node }: { node: Node | null }) { + const usage = node?.kind === "usage" ? node.usage : node?.kind === "file" ? (node.group.usages[0] ?? null) : null; + const search = useUsages(activeUsageSearch); + const highlights = useMemo(() => { + const group = usage && search?.groups.find((g) => g.path === usage.path); + return (group?.usages ?? []).map((u) => ({ line: u.line, ranges: [[u.col, u.endCol] as const] })); + }, [usage, search?.groups]); + return ( + + ); +} diff --git a/src/features/code-insight/hierarchy.test.ts b/src/features/code-insight/hierarchy.test.ts new file mode 100644 index 00000000..4f15ee91 --- /dev/null +++ b/src/features/code-insight/hierarchy.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from "vitest"; +import type { HierarchyItem, OutlineItem, SymbolKind } from "@/bindings"; + +const hint = vi.fn(); +vi.mock("@/workbench/contributions", async (orig) => ({ + ...(await orig()), + toolWindowService: () => ({ show: () => undefined }), +})); + +const range = (l1: number, c1: number, l2: number, c2: number) => ({ + start: { line: l1, col: c1 }, + end: { line: l2, col: c2 }, +}); +const outlineItem = (name: string, kind: SymbolKind, r: ReturnType, nameAt: [number, number], children: OutlineItem[] = []): OutlineItem => ({ + name, + detail: null, + kind, + range: r, + selection_range: range(nameAt[0], nameAt[1], nameAt[0], nameAt[1] + name.length), + children, +}); +// class Orders { void list() { int x = 1; } } — class on lines 0-10, `list` on lines 2-6. +const OUTLINE = [ + outlineItem("Orders", "class", range(0, 0, 10, 1), [0, 6], [ + outlineItem("list", "method", range(2, 2, 6, 3), [2, 7]), + outlineItem("count", "field", range(8, 2, 8, 12), [8, 6]), + ]), +]; + +const item = (name: string, fromRanges: HierarchyItem["fromRanges"] = []): HierarchyItem => ({ + name, + kind: "method", + detail: null, + path: "/p/Caller.java", + range: range(10, 0, 20, 1), + selectionRange: range(10, 7, 10, 7 + name.length), + fromRanges, +}); + +vi.mock("@/platform/ipc", async (orig) => { + const real = await orig(); + return { + ...real, + call: async (p: Promise) => p, + commands: { + ...real.commands, + docOutline: async () => OUTLINE, + lspCallHierarchy: async (_doc: number, pos: { line: number; col: number }) => { + // The server prepares a hierarchy only on the method name. + if (pos.line !== 2 || pos.col !== 7) throw new real.IpcError({ kind: "notFound", message: "No symbol" }); + return [item("main", [range(12, 4, 12, 8)])]; + }, + }, + }; +}); +vi.mock("./ipc", async (orig) => ({ + ...(await orig()), + caretContext: () => ({ doc: 1, path: "/p/Orders.java", line: 4, col: 10, word: "x", view: null }), + withDocument: async (_path: string, fn: (doc: number) => Promise) => fn(1), + lspRequest: async (run: () => Promise) => run(), + hint: (...a: unknown[]) => hint(...a), +})); + +const { enclosingDeclaration, hierarchyTarget, showHierarchy, useHierarchy, ROOT_ID } = await import("./hierarchy"); + +describe("hierarchy", () => { + it("finds the innermost enclosing declaration of the wanted kinds", () => { + const calls = new Set(["method", "function", "constructor"]); + const types = new Set(["class"]); + expect(enclosingDeclaration(OUTLINE, { line: 4, col: 10 }, calls)?.name).toBe("list"); + expect(enclosingDeclaration(OUTLINE, { line: 4, col: 10 }, types)?.name).toBe("Orders"); + expect(enclosingDeclaration(OUTLINE, { line: 8, col: 4 }, calls)).toBeNull(); + expect(enclosingDeclaration(OUTLINE, { line: 12, col: 0 }, types)).toBeNull(); + }); + + it("navigates callers to their call site, other nodes to the symbol", () => { + const caller = item("main", [range(12, 4, 12, 8), range(14, 4, 14, 8)]); + expect(hierarchyTarget("call", "incoming", caller)).toEqual({ path: "/p/Caller.java", line: 12, col: 4 }); + expect(hierarchyTarget("call", "outgoing", caller)).toEqual({ path: "/p/Caller.java", line: 10, col: 7 }); + expect(hierarchyTarget("type", "incoming", item("Base"))).toEqual({ path: "/p/Caller.java", line: 10, col: 7 }); + }); + + it("retries at the enclosing method when nothing is at the caret", async () => { + useHierarchy.setState({ mode: "call", direction: "incoming" }); + await showHierarchy("call"); + const s = useHierarchy.getState(); + expect(s.root).toEqual({ name: "list", path: "/p/Orders.java", line: 2, col: 7 }); + expect(s.children.get(ROOT_ID)?.map((n) => n.item.name)).toEqual(["main"]); + expect(s.failed.size).toBe(0); + expect(hint).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/code-insight/hierarchy.ts b/src/features/code-insight/hierarchy.ts index 1e64c588..effd0f66 100644 --- a/src/features/code-insight/hierarchy.ts +++ b/src/features/code-insight/hierarchy.ts @@ -1,10 +1,10 @@ /** Call / type hierarchy state, with lazily expanded levels. */ import { create } from "zustand"; -import type { HierarchyDirection, HierarchyItem } from "@/bindings"; -import { commands } from "@/platform/ipc"; +import type { HierarchyDirection, HierarchyItem, OutlineItem, Pos, SymbolKind } from "@/bindings"; +import { IpcError, call, commands } from "@/platform/ipc"; import { toolWindowService } from "@/workbench/contributions"; import { basename } from "@/platform/project"; -import { caretContext, hint, lspRequest, reportError, withDocument } from "./ipc"; +import { caretContext, hint, lspRequest, reportError, withDocument, type CaretContext } from "./ipc"; export type HierarchyMode = "call" | "type"; @@ -88,24 +88,61 @@ function setChildren(generation: number, parent: string, items: HierarchyItem[]) useHierarchy.setState({ children: new Map(s.children).set(parent, nodes) }); } -async function loadRoot() { +type RootResult = "loaded" | "noTarget" | "failed" | "stale"; + +/** Loads the root's level. `quiet`: a position without a symbol isn't reported (the caller retries). */ +async function loadRoot(quiet = false): Promise { const s = useHierarchy.getState(); const root = s.root; - if (!root) return; + if (!root) return "stale"; const generation = s.generation + 1; useHierarchy.setState({ generation, children: new Map(), failed: new Set(), loading: true }); try { const items = await withDocument(root.path, (doc) => request(s.mode, doc, root.line, root.col, s.direction)); setChildren(generation, ROOT_ID, items); + return "loaded"; } catch (e) { - if (useHierarchy.getState().generation === generation) - useHierarchy.setState((st) => ({ failed: new Set(st.failed).add(ROOT_ID) })); + if (useHierarchy.getState().generation !== generation) return "stale"; + const noTarget = e instanceof IpcError && e.kind === "notFound"; + if (quiet && noTarget) return "noTarget"; + useHierarchy.setState((st) => ({ failed: new Set(st.failed).add(ROOT_ID) })); reportError(e); + return noTarget ? "noTarget" : "failed"; } finally { if (useHierarchy.getState().generation === generation) useHierarchy.setState({ loading: false }); } } +const CALLABLE = new Set(["function", "method", "constructor"]); +const TYPES = new Set(["class", "interface", "struct", "enum", "trait"]); + +const before = (a: Pos, b: Pos) => a.line < b.line || (a.line === b.line && a.col <= b.col); + +/** The innermost outline symbol of `kinds` whose extent contains `pos`. */ +export function enclosingDeclaration( + items: readonly OutlineItem[], + pos: Pos, + kinds: ReadonlySet, +): OutlineItem | null { + for (const item of items) { + if (!before(item.range.start, pos) || !before(pos, item.range.end)) continue; + return enclosingDeclaration(item.children, pos, kinds) ?? (kinds.has(item.kind) ? item : null); + } + return null; +} + +/** IDEA: inside a method / class body the hierarchy is the enclosing declaration's. */ +async function enclosingRoot(c: CaretContext, mode: HierarchyMode): Promise { + try { + const outline = await call(commands.docOutline(c.doc)); + const decl = enclosingDeclaration(outline, { line: c.line, col: c.col }, mode === "call" ? CALLABLE : TYPES); + if (!decl) return null; + const at = decl.selection_range.start; + return { name: decl.name, path: c.path, line: at.line, col: at.col }; + } catch { + return null; + } +} export async function loadChildren(node: HierarchyNode) { const { mode, direction, generation } = useHierarchy.getState(); const at = node.item.selectionRange.start; @@ -136,7 +173,28 @@ export async function showHierarchy(mode: HierarchyMode) { root: { name: c.word ?? basename(c.path), path: c.path, line: c.line, col: c.col }, }); toolWindowService().show("hierarchy"); - await loadRoot(); + if ((await loadRoot(true)) !== "noTarget") return; + // Nothing at the caret: retry at the enclosing method / class. + const root = await enclosingRoot(c, mode); + const now = useHierarchy.getState(); + if (now.mode !== mode || now.root?.path !== c.path || now.root.line !== c.line || now.root.col !== c.col) return; + if (root && (root.line !== c.line || root.col !== c.col)) { + useHierarchy.setState({ root }); + await loadRoot(); + } else { + useHierarchy.setState((st) => ({ failed: new Set(st.failed).add(ROOT_ID) })); + hint(mode === "call" ? "No method at the caret to show the call hierarchy of" : "No type at the caret to show the hierarchy of"); + } +} + +/** Where activating a node goes: a caller's call site (as IDEA), else the symbol itself. */ +export function hierarchyTarget( + mode: HierarchyMode, + direction: HierarchyDirection, + item: HierarchyItem, +): { path: string; line: number; col: number } { + const at = mode === "call" && direction === "incoming" && item.fromRanges[0] ? item.fromRanges[0].start : item.selectionRange.start; + return { path: item.path, line: at.line, col: at.col }; } export function setDirection(direction: HierarchyDirection) { diff --git a/src/features/code-insight/index.ts b/src/features/code-insight/index.ts index c82cf6d2..cca63ee9 100644 --- a/src/features/code-insight/index.ts +++ b/src/features/code-insight/index.ts @@ -8,10 +8,10 @@ import { useProject } from "@/platform/project"; import { registerOverlay, registerStatusItem, registerToolWindow } from "@/workbench/contributions"; import { clearProblems, installProblemsEvents, useProblems } from "./problemsStore"; import { installProblemDecorations } from "./decorations"; -import { ProblemsHeaderActions, ProblemsTabs, ProblemsView } from "./ProblemsView"; -import { UsagesTabs, UsagesView } from "./UsagesView"; +import { ProblemsHeaderActions, ProblemsTabs, ProblemsView, useProblemsHeaderMenu } from "./ProblemsView"; +import { UsagesTabs, UsagesView, useUsagesHeaderMenu } from "./UsagesView"; import { UsagesPopup } from "./UsagesPopup"; -import { findUsages, resetUsages, showUsageResults, showUsagesPopup } from "./usages"; +import { findUsages, rerunActiveUsages, resetUsages, showUsageResults, showUsagesPopup } from "./usages"; import { HierarchyTabs, HierarchyView } from "./HierarchyView"; import { resetHierarchy, showHierarchy } from "./hierarchy"; import { installServerEvents } from "./servers"; @@ -53,6 +53,7 @@ export function activate(): () => void { component: ProblemsView, headerContent: ProblemsTabs, headerActions: ProblemsHeaderActions, + headerMenu: useProblemsHeaderMenu, useBadge: useErrorBadge, }), registerToolWindow({ @@ -64,6 +65,7 @@ export function activate(): () => void { order: 35, component: UsagesView, headerContent: UsagesTabs, + headerMenu: useUsagesHeaderMenu, }), registerToolWindow({ id: "hierarchy", @@ -92,6 +94,15 @@ export function activate(): () => void { when: EDITOR_WHEN, run: () => showUsagesPopup(), }, + { + // ⌘R in the Find Usages tool window, as in IDEA. + id: "usages.rerun", + title: "Rerun Find Usages", + category: "Find", + icon: "rerun", + when: "projectOpen && activeToolWindow == 'usages'", + run: rerunActiveUsages, + }, { id: "find.showUsageResults", title: "Show Usage Results", diff --git a/src/features/code-insight/ipc.ts b/src/features/code-insight/ipc.ts index c6214711..9fcdb4af 100644 --- a/src/features/code-insight/ipc.ts +++ b/src/features/code-insight/ipc.ts @@ -86,6 +86,13 @@ export async function readFileLines(path: string): Promise { } } +/** File text for a preview pane (open buffer, else disk). */ +export async function previewText(path: string): Promise { + const lines = await readFileLines(path); + if (!lines) throw new Error("Cannot read the file"); + return lines.join("\n"); +} + export async function readManyFileLines(paths: readonly string[], concurrency = 4): Promise> { const out = new Map(); const queue = [...new Set(paths)]; @@ -111,6 +118,6 @@ export async function withDocument(path: string, fn: (doc: number) => Promise } } -export function openLocation(path: string, line: number, col: number) { - void maybeEditorService()?.openFile(path, { selection: { line, col }, focus: true }); +export function openLocation(path: string, line: number, col: number): Promise { + return maybeEditorService()?.openFile(path, { selection: { line, col }, focus: true }) ?? Promise.resolve(); } diff --git a/src/features/code-insight/problemsStore.ts b/src/features/code-insight/problemsStore.ts index 0c90b7a4..99a35bbc 100644 --- a/src/features/code-insight/problemsStore.ts +++ b/src/features/code-insight/problemsStore.ts @@ -47,6 +47,30 @@ export function fileDiagnostics(map: ProblemsMap, path: string): Diagnostic[] { ); } +export interface ProblemOrder { + /** Errors before warnings before infos (IDEA's default). */ + bySeverity: boolean; + /** By message text, then position; otherwise by position, then text. */ + byName: boolean; +} + +export const DEFAULT_PROBLEM_ORDER: ProblemOrder = { bySeverity: true, byName: false }; + +const natural = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); + +/** A file's problems in the Problems view order (Sort by Severity / Name). */ +export function sortProblems(items: readonly Diagnostic[], order: ProblemOrder): Diagnostic[] { + const position = (a: Diagnostic, b: Diagnostic) => + a.range.start.line - b.range.start.line || a.range.start.col - b.range.start.col; + return [...items].sort( + (a, b) => + (order.bySeverity ? SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] : 0) || + (order.byName + ? natural.compare(a.message, b.message) || position(a, b) + : position(a, b) || natural.compare(a.message, b.message)), + ); +} + export function countSeverities(diags: Iterable): SeverityCounts { const c: SeverityCounts = { error: 0, warning: 0, info: 0, hint: 0 }; for (const d of diags) c[d.severity]++; diff --git a/src/features/code-insight/usageResults.test.ts b/src/features/code-insight/usageResults.test.ts index 2c37e8ae..b7782b12 100644 --- a/src/features/code-insight/usageResults.test.ts +++ b/src/features/code-insight/usageResults.test.ts @@ -10,14 +10,14 @@ vi.mock("./ipc", async (orig) => ({ readManyFileLines: async () => new Map([["/p/b.rs", ["", " used();"]]]), })); -const { showUsageResults, useUsages } = await import("./usages"); +const { activeUsageSearch, openUsageTab, pinUsageTab, resetUsages, showUsageResults, useUsages } = await import("./usages"); describe("showUsageResults", () => { it("shows given usages in the Find Usages tool window", async () => { const loc = { path: "/p/b.rs", range: { start: { line: 1, col: 4 }, end: { line: 1, col: 8 } } }; await showUsageResults({ symbol: "used", locations: [loc] }); expect(show).toHaveBeenCalledWith("usages"); - const search = useUsages.getState().search!; + const search = activeUsageSearch(useUsages.getState())!; expect(search).toMatchObject({ symbol: "used", loading: false, error: null, rerun: null }); expect(search.groups).toEqual([{ path: "/p/b.rs", usages: [{ path: "/p/b.rs", line: 1, col: 4, endCol: 8, text: " used();" }] }]); }); @@ -28,3 +28,20 @@ describe("showUsageResults", () => { expect(show).not.toHaveBeenCalled(); }); }); + +describe("usage tabs", () => { + const search = (id: number) => ({ id, symbol: `s${id}`, loading: false, groups: [], error: null, note: null, rerun: null }); + + it("replace the selected tab unless it is pinned", () => { + resetUsages(); + openUsageTab(search(101)); + openUsageTab(search(102)); + expect(useUsages.getState().tabs.map((t) => t.id)).toEqual([102]); + pinUsageTab(102, true); + openUsageTab(search(103)); + expect(useUsages.getState().tabs.map((t) => t.id)).toEqual([102, 103]); + expect(activeUsageSearch(useUsages.getState())?.id).toBe(103); + openUsageTab(search(104)); + expect(useUsages.getState().tabs.map((t) => t.id)).toEqual([102, 104]); + }); +}); diff --git a/src/features/code-insight/usages.ts b/src/features/code-insight/usages.ts index 39156f26..fe967345 100644 --- a/src/features/code-insight/usages.ts +++ b/src/features/code-insight/usages.ts @@ -62,7 +62,7 @@ export function trimPreview(u: Usage): { text: string; from: number; to: number return { text, from, to }; } -interface UsageSearch { +export interface UsageSearch { /** Identifies one search run (UI state such as expansion resets per run). */ id: number; symbol: string | null; @@ -73,11 +73,64 @@ interface UsageSearch { note: string | null; /** Re-runs the same query (rerun button). */ rerun: (() => void) | null; + /** Kept when a new search runs (Pin Tab). */ + pinned?: boolean; } let searchSeq = 0; -export const useUsages = create<{ search: UsageSearch | null }>(() => ({ search: null })); +/** One tab per search; as in IDEA a new search replaces the selected tab unless it is pinned. */ +interface UsagesState { + tabs: UsageSearch[]; + active: number | null; +} + +export const useUsages = create(() => ({ tabs: [], active: null })); + +/** The selected tab's search. */ +export const activeUsageSearch = (s: UsagesState): UsageSearch | null => + s.tabs.find((t) => t.id === s.active) ?? s.tabs.at(-1) ?? null; + +const MAX_TABS = 8; + +/** Shows `search` in a new tab, or in place of the selected one unless that is pinned. */ +export function openUsageTab(search: UsageSearch) { + useUsages.setState((s) => { + const reused = s.tabs.find((t) => t.id === s.active && !t.pinned); + const tabs = reused ? s.tabs.map((t) => (t === reused ? search : t)) : [...s.tabs, search]; + return { tabs: tabs.slice(-MAX_TABS), active: search.id }; + }); +} + +/** Replaces tab `id` (a rerun or a finished search), keeping its pin and selection. */ +function replaceUsageTab(id: number, search: UsageSearch): boolean { + const s = useUsages.getState(); + const old = s.tabs.find((t) => t.id === id); + if (!old) return false; + useUsages.setState({ + tabs: s.tabs.map((t) => (t === old ? { ...search, pinned: old.pinned } : t)), + active: s.active === id ? search.id : s.active, + }); + return true; +} + +export function pinUsageTab(id: number, pinned: boolean) { + useUsages.setState((s) => ({ tabs: s.tabs.map((t) => (t.id === id ? { ...t, pinned } : t)) })); +} + +export function closeUsageTab(id: number) { + useUsages.setState((s) => { + const i = s.tabs.findIndex((t) => t.id === id); + const tabs = s.tabs.filter((t) => t.id !== id); + const active = s.active === id ? (tabs[Math.min(i, tabs.length - 1)]?.id ?? null) : s.active; + return { tabs, active }; + }); +} + +/** Rerun of the selected tab (⌘R in the Find Usages tool window). */ +export function rerunActiveUsages() { + activeUsageSearch(useUsages.getState())?.rerun?.(); +} interface UsagesPopupState { open: boolean; @@ -128,7 +181,7 @@ export async function fetchUsages(c: CaretContext): Promise { /** The window switched projects: results (and in-flight searches) of the previous one are dropped. */ export function resetUsages() { - useUsages.setState({ search: null }); + useUsages.setState({ tabs: [], active: null }); useUsagesPopup.setState({ open: false, symbol: null, groups: [], note: null }); } @@ -138,23 +191,22 @@ export async function findUsages() { hint("Open a file to find usages"); return; } - const run = async () => { + const run = async (prev: number | null) => { const id = ++searchSeq; - const current = () => useUsages.getState().search?.id === id; - const base = { id, symbol: c.word, rerun: () => void run() }; - useUsages.setState({ search: { ...base, loading: true, groups: [], error: null, note: null } }); + const base = { id, symbol: c.word, rerun: () => void run(id) }; + const loading = { ...base, loading: true, groups: [], error: null, note: null }; + if (prev === null || !replaceUsageTab(prev, loading)) openUsageTab(loading); toolWindowService().show("usages"); try { const { groups, note } = await fetchUsages(c); - if (current()) useUsages.setState({ search: { ...base, loading: false, groups, error: null, note } }); + replaceUsageTab(id, { ...base, loading: false, groups, error: null, note }); } catch (e) { const message = e instanceof Error ? e.message : String(e); - if (!current()) return; - useUsages.setState({ search: { ...base, loading: false, groups: [], error: message, note: null } }); + if (!replaceUsageTab(id, { ...base, loading: false, groups: [], error: message, note: null })) return; reportError(e); } }; - await run(); + await run(null); } /** @@ -168,11 +220,10 @@ export async function showUsageResults(arg: unknown) { const symbol = typeof a.symbol === "string" ? a.symbol : null; const id = ++searchSeq; const base = { id, symbol, rerun: null }; - useUsages.setState({ search: { ...base, loading: true, groups: [], error: null, note: null } }); + openUsageTab({ ...base, loading: true, groups: [], error: null, note: null }); toolWindowService().show("usages"); const files = await readManyFileLines(locs.map((l) => l.path)); - if (useUsages.getState().search?.id !== id) return; - useUsages.setState({ search: { ...base, loading: false, groups: groupUsages(locs, files), error: null, note: null } }); + replaceUsageTab(id, { ...base, loading: false, groups: groupUsages(locs, files), error: null, note: null }); } export async function showUsagesPopup() { @@ -189,8 +240,10 @@ export async function showUsagesPopup() { return; } if (total === 1) { + // IDEA jumps to the only usage and says so in the target editor. const u = groups[0]!.usages[0]!; - openLocation(u.path, u.line, u.col); + await openLocation(u.path, u.line, u.col); + hint("Only one usage in Project Files"); return; } useUsagesPopup.setState({ open: true, anchor: caretPoint(), symbol: c.word, groups, note }); diff --git a/src/features/code-insight/usagesPopup.test.tsx b/src/features/code-insight/usagesPopup.test.tsx new file mode 100644 index 00000000..9a0276e8 --- /dev/null +++ b/src/features/code-insight/usagesPopup.test.tsx @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen } from "@testing-library/react"; + +const calls: string[] = []; +const loc = { path: "/p/b.ts", range: { start: { line: 1, col: 4 }, end: { line: 1, col: 8 } } }; +vi.mock("./ipc", async (orig) => ({ + ...(await orig()), + caretContext: () => ({ doc: 1, path: "/p/a.ts", line: 0, col: 0, word: "used", view: null }), + lspRequest: async () => [loc], + readManyFileLines: async () => new Map([["/p/b.ts", ["", " used();"]]]), + openLocation: async (path: string, line: number) => void calls.push(`open ${path}:${line}`), + hint: (text: string) => void calls.push(`hint ${text}`), +})); +const showUsageResults = vi.fn(); +vi.mock("./usages", async (orig) => ({ + ...(await orig()), + showUsageResults: (a: unknown) => showUsageResults(a), +})); + +const { UsagesPopup, filterUsages } = await import("./UsagesPopup"); +const { showUsagesPopup, useUsagesPopup } = await import("./usages"); + +const usage = (path: string, line: number, text: string) => ({ path, line, col: 0, endCol: 3, text }); +const groups = [ + { path: "/p/a.ts", usages: [usage("/p/a.ts", 2, "foo(order)"), usage("/p/a.ts", 5, "foo(cart)")] }, + { path: "/p/b.ts", usages: [usage("/p/b.ts", 1, "foo(order, 2)")] }, +]; + +function open() { + render(); + act(() => useUsagesPopup.setState({ open: true, anchor: { x: 0, y: 0 }, symbol: "foo", groups, note: null })); + return screen.getByRole("dialog", { name: "Usages" }).querySelector("[tabindex='0']")!; +} + +afterEach(() => act(() => useUsagesPopup.setState({ open: false }))); + +describe("Show Usages popup", () => { + it("filters by typing; Esc clears the filter, then closes", () => { + const body = open(); + expect(screen.getAllByRole("option")).toHaveLength(3); + for (const key of "cart") fireEvent.keyDown(body, { key }); + expect(screen.getAllByRole("option")).toHaveLength(1); + expect(screen.getByText("1 of 3 found")).toBeInTheDocument(); + fireEvent.keyDown(body, { key: "Escape" }); + expect(screen.getAllByRole("option")).toHaveLength(3); + expect(useUsagesPopup.getState().open).toBe(true); + fireEvent.keyDown(body, { key: "Escape" }); + expect(useUsagesPopup.getState().open).toBe(false); + }); + + it("matches file names and line text", () => { + const all = groups.flatMap((g) => g.usages); + expect(filterUsages(all, "b.ts")).toHaveLength(1); + expect(filterUsages(all, "ORDER")).toHaveLength(2); + expect(filterUsages(all, "")).toBe(all); + }); + + it("Open in Find Window moves the found usages instead of searching again", () => { + open(); + fireEvent.click(screen.getByText(/Open in Find Window/)); + expect(useUsagesPopup.getState().open).toBe(false); + expect(showUsageResults).toHaveBeenCalledWith({ + symbol: "foo", + locations: [ + { path: "/p/a.ts", range: { start: { line: 2, col: 0 }, end: { line: 2, col: 3 } } }, + { path: "/p/a.ts", range: { start: { line: 5, col: 0 }, end: { line: 5, col: 3 } } }, + { path: "/p/b.ts", range: { start: { line: 1, col: 0 }, end: { line: 1, col: 3 } } }, + ], + }); + }); + + it("the preview pane toggles", () => { + open(); + expect(screen.queryByRole("region", { name: "Preview" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Show Preview" })); + expect(screen.getByRole("region", { name: "Preview" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Show Preview" })); + }); + + it("a single usage is opened with a hint in its editor", async () => { + calls.length = 0; + await showUsagesPopup(); + expect(calls).toEqual(["open /p/b.ts:1", "hint Only one usage in Project Files"]); + expect(useUsagesPopup.getState().open).toBe(false); + }); +}); diff --git a/src/features/code-insight/views.test.ts b/src/features/code-insight/views.test.ts index 5000bd96..9a2c9d15 100644 --- a/src/features/code-insight/views.test.ts +++ b/src/features/code-insight/views.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from "vitest"; import { groupUsages, trimPreview, usageCount } from "./usages"; import { serversSummary, type ServerEntry } from "./servers"; import { buildProblemNodes } from "./ProblemsView"; -import { mergeDiagnostics } from "./problemsStore"; +import { mergeDiagnostics, sortProblems } from "./problemsStore"; +import { toggleExcludedUsages } from "./UsagesView"; import { childId, directionLabel } from "./hierarchy"; const loc = (path: string, line: number, col: number, endCol: number) => ({ @@ -83,3 +84,34 @@ describe("hierarchy", () => { expect(childId("root", item)).toBe("root//a.rs:1:3:f"); }); }); + +describe("sortProblems", () => { + const d = (severity: "error" | "warning", line: number, message: string) => ({ + range: { start: { line, col: 0 }, end: { line, col: 1 } }, + severity, + message, + source: null, + code: null, + }); + const items = [d("warning", 1, "b"), d("error", 5, "c"), d("warning", 0, "z"), d("error", 2, "a")]; + const lines = (order: { bySeverity: boolean; byName: boolean }) => sortProblems(items, order).map((x) => x.range.start.line); + + it("orders by severity, then position (IDEA's default)", () => { + expect(lines({ bySeverity: true, byName: false })).toEqual([2, 5, 0, 1]); + }); + it("orders by position or by name when asked", () => { + expect(lines({ bySeverity: false, byName: false })).toEqual([0, 1, 2, 5]); + expect(lines({ bySeverity: false, byName: true })).toEqual([2, 1, 5, 0]); + }); +}); + +describe("toggleExcludedUsages", () => { + const u = (line: number) => ({ path: "/a", line, col: 0, endCol: 1, text: "" }); + it("excludes a group unless all of it is excluded already", () => { + const one = toggleExcludedUsages(new Set(), [u(1)]); + expect([...one]).toEqual(["/a:1:0"]); + const both = toggleExcludedUsages(one, [u(1), u(2)]); + expect([...both].sort()).toEqual(["/a:1:0", "/a:2:0"]); + expect(toggleExcludedUsages(both, [u(1), u(2)]).size).toBe(0); + }); +}); diff --git a/src/features/debugger/__tests__/editor.test.ts b/src/features/debugger/__tests__/editor.test.ts index 58ef0307..feb4a5be 100644 --- a/src/features/debugger/__tests__/editor.test.ts +++ b/src/features/debugger/__tests__/editor.test.ts @@ -88,7 +88,10 @@ describe("breakpoint click area", () => { parent: document.body, }); views.push(view); - const down = (el: Element) => el.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 })); + const down = (el: Element) => { + el.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 })); + el.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, button: 0 })); + }; const cells = view.dom.querySelectorAll(".extra .cm-gutterElement"); down(cells[cells.length - 1]!.firstElementChild!); expect(breakpointsOf(ctx.path)).toHaveLength(0); @@ -98,10 +101,12 @@ describe("breakpoint click area", () => { }); describe("breakpoint gutter clicks", () => { - const click = (view: EditorView, init: MouseEventInit) => - view.dom - .querySelector(".cm-lineNumbers .cm-gutterElement:not([style*='hidden'])")! - .dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0, ...init })); + // Plain and modifier clicks act on release, so a drag over the gutter can select lines. + const click = (view: EditorView, init: MouseEventInit) => { + const cell = view.dom.querySelector(".cm-lineNumbers .cm-gutterElement:not([style*='hidden'])")!; + cell.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0, ...init })); + cell.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, button: 0, ...init })); + }; it("⇧-click adds a non-suspending logging breakpoint, ⌥-click a temporary one", () => { localStorage.clear(); diff --git a/src/features/debugger/__tests__/smartStep.test.ts b/src/features/debugger/__tests__/smartStep.test.ts new file mode 100644 index 00000000..b2ef4408 --- /dev/null +++ b/src/features/debugger/__tests__/smartStep.test.ts @@ -0,0 +1,135 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { _addEditor, _removeEditor, type TextEditorHandle } from "@/editor/api"; +import { useExecutionPoint } from "../state"; +import { useOverlays } from "../ui/popoverStore"; +import { + calleeName, + installSmartStepPicking, + locateStepTargets, + smartStepTargets, + startInlineSmartStep, + targetsOnLine, +} from "../editor/smartStep"; + +const ipc = vi.hoisted(() => ({ debugStepInTarget: vi.fn() })); +vi.mock("@/platform/ipc", async (orig) => { + const real = await orig(); + return { + ...real, + call: (p: Promise) => p, + commands: { ...real.commands, debugStepInTarget: (...a: unknown[]) => (ipc.debugStepInTarget(...a), Promise.resolve(null)) }, + }; +}); + +const target = (id: number, label: string, column: number | null = null, endColumn: number | null = null) => ({ + id, + label, + line: null, + column, + endColumn, +}); + +describe("locating Smart Step Into targets", () => { + it("names the callee of a label", () => { + expect(calleeName("parse(str(i))")).toBe("parse"); + expect(calleeName("java.lang.String.valueOf(int)")).toBe("valueOf"); + expect(calleeName("")).toBeNull(); + }); + + it("places targets by name, in line order, or by adapter columns", () => { + const line = " x = parse(str(i)) + str (j)"; + const r = locateStepTargets(line, 3, [target(1, "str(i)"), target(2, "parse(str(i))"), target(3, "str(j)")]); + expect(r).toEqual([ + { id: 2, from: 8, to: 13 }, + { id: 1, from: 14, to: 17 }, + { id: 3, from: 24, to: 27 }, + ]); + expect(locateStepTargets(line, 3, [target(7, "whatever", 8, 13), target(8, "x", 14)])).toEqual([ + { id: 7, from: 8, to: 13 }, + { id: 8, from: 14, to: 17 }, + ]); + expect(locateStepTargets(line, 3, [target(1, "missing()")])).toBeNull(); + }); + + it("keeps only the paused line's calls when the adapter lists the whole function", () => { + const line = " total = add(total, square(i))"; + const all = [target(0, "square(i)"), target(1, "add(total, square(i))"), target(2, 'print("total", total)')]; + expect(targetsOnLine(line, 11, all).map((t) => t.id)).toEqual([0, 1]); + expect(targetsOnLine(line, 11, [{ ...target(5, "print()"), line: 11 }, { ...target(6, "add()"), line: 12 }]).map((t) => t.id)).toEqual([5]); + expect(targetsOnLine("pass", 0, all)).toBe(all); + }); +}); + +describe("picking in the editor", () => { + const views: EditorView[] = []; + const cleanup: Array<() => void> = []; + afterEach(() => { + cleanup.splice(0).forEach((d) => d()); + views.splice(0).forEach((v) => v.destroy()); + useOverlays.setState({ smartStep: null }); + useExecutionPoint.setState({ path: null, line: 0, top: true, values: {} }); + ipc.debugStepInTarget.mockReset(); + }); + + function setup() { + const ctx = { doc: 1, path: "/p/app.py", language: "python", uiMode: null, readOnly: false }; + const view = new EditorView({ + state: EditorState.create({ doc: "def f():\n x = parse(str(i))\n", extensions: [smartStepTargets(ctx)] }), + parent: document.body, + }); + views.push(view); + const handle: TextEditorHandle = { view, ctx }; + _addEditor(handle); + cleanup.push(() => _removeEditor(handle), installSmartStepPicking()); + useExecutionPoint.setState({ path: "/p/app.py", line: 1, top: true }); + return view; + } + + const flush = () => new Promise((r) => setTimeout(r, 0)); + const key = (k: string, init: KeyboardEventInit = {}) => + window.dispatchEvent(new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true, ...init })); + const marked = (view: EditorView) => + [...view.dom.querySelectorAll("[data-step-target]")].map((e) => [e.textContent, e.getAttribute("aria-current")]); + + it("highlights the calls, moves with arrows and steps in with Enter", async () => { + const view = setup(); + expect(startInlineSmartStep(3, 1, [target(1, "str(i)"), target(2, "parse(str(i))")])).toBe(true); + await flush(); + expect(marked(view)).toEqual([ + ["parse", null], + ["str", "true"], + ]); + key("ArrowLeft"); + await flush(); + expect(marked(view)[0]).toEqual(["parse", "true"]); + key("Enter"); + expect(ipc.debugStepInTarget).toHaveBeenCalledWith(3, 1, 2); + expect(useOverlays.getState().smartStep).toBeNull(); + await flush(); + expect(marked(view)).toEqual([]); + }); + + it("Escape cancels; a click on a call steps into it", async () => { + const view = setup(); + startInlineSmartStep(3, 1, [target(1, "str(i)"), target(2, "parse(str(i))")]); + key("Escape"); + expect(useOverlays.getState().smartStep).toBeNull(); + expect(ipc.debugStepInTarget).not.toHaveBeenCalled(); + + startInlineSmartStep(3, 1, [target(1, "str(i)"), target(2, "parse(str(i))")]); + await flush(); + const parse = view.dom.querySelector("[data-step-target='2']")!; + parse.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true })); + expect(ipc.debugStepInTarget).toHaveBeenCalledWith(3, 1, 2); + }); + + it("uses the dialog when the line is not open or the calls cannot be placed", () => { + setup(); + expect(startInlineSmartStep(3, 1, [target(1, "a()"), target(2, "b()")])).toBe(false); + useExecutionPoint.setState({ path: "/p/other.py" }); + expect(startInlineSmartStep(3, 1, [target(1, "str(i)"), target(2, "parse(str(i))")])).toBe(false); + expect(useOverlays.getState().smartStep).toBeNull(); + }); +}); diff --git a/src/features/debugger/actions.ts b/src/features/debugger/actions.ts index b8013ddb..f70c73c7 100644 --- a/src/features/debugger/actions.ts +++ b/src/features/debugger/actions.ts @@ -33,6 +33,7 @@ import { } from "./state"; import { focusedDebugView } from "./ui/focusedView"; import { expressionAt } from "./editor/valueTooltip"; +import { pausedLineTargets, startInlineSmartStep } from "./editor/smartStep"; import { openBreakpointsDialog, openConfigChooser, @@ -79,7 +80,10 @@ function selectedFrame(s: DebugSession) { return s.frames.find((f) => f.id === s.selectedFrame) ?? s.frames[0] ?? null; } -/** Smart Step Into: the calls on the current line; one call (or none) steps straight in, like IDEA. */ +/** + * Smart Step Into: the calls on the current line, picked in the editor (the + * dialog when they cannot be placed); one call (or none) steps straight in, like IDEA. + */ async function smartStepInto() { const t = pausedTarget(); if (!t) return; @@ -88,7 +92,7 @@ async function smartStepInto() { await call(commands.debugStep(t.s.id, t.thread, "into")); return; } - const targets = await call(commands.debugStepInTargets(t.s.id, frame.id)); + const targets = pausedLineTargets(await call(commands.debugStepInTargets(t.s.id, frame.id))); if (targets.length <= 1) { await call( targets[0] @@ -97,7 +101,7 @@ async function smartStepInto() { ); return; } - openSmartStepChooser(t.s.id, t.thread, targets); + if (!startInlineSmartStep(t.s.id, t.thread, targets)) openSmartStepChooser(t.s.id, t.thread, targets); } /** Opens the Attach to Process popup and loads the process list. */ diff --git a/src/features/debugger/editor/breakpointGutter.ts b/src/features/debugger/editor/breakpointGutter.ts index c5445fb7..de8cb10f 100644 --- a/src/features/debugger/editor/breakpointGutter.ts +++ b/src/features/debugger/editor/breakpointGutter.ts @@ -219,6 +219,20 @@ export function breakpointGutter(ctx: EditorDocContext): Extension { } if (!canPlaceBreakpoint(ctx.language) && !bp) return; e.preventDefault(); + // Acts on release over the same line: a drag across the gutter selects lines instead. + const up = (u: MouseEvent) => { + window.removeEventListener("mouseup", up, true); + if (u.button === 0 && lineAtEvent(this.view, u) === line) this.onClick(e, line, special, bp); + }; + window.addEventListener("mouseup", up, true); + }; + + onClick = ( + e: MouseEvent, + line: number, + special: ReturnType[number] | undefined, + bp: ReturnType[number] | undefined, + ) => { if (special || bp) { if (e.shiftKey || e.altKey) return; if (special) removeSpecial(special.id); diff --git a/src/features/debugger/editor/editor.module.css b/src/features/debugger/editor/editor.module.css index b45db337..2a89c9df 100644 --- a/src/features/debugger/editor/editor.module.css +++ b/src/features/debugger/editor/editor.module.css @@ -172,3 +172,15 @@ .valueType { color: var(--fg-subtle); } + +/* Smart Step Into: calls on the paused line framed, the selected one filled. */ +.stepTarget { + border-radius: 2px; + box-shadow: inset 0 0 0 1px var(--border-strong); + cursor: pointer; +} + +.stepTargetSelected { + background: var(--accent-soft); + box-shadow: inset 0 0 0 1px var(--accent); +} diff --git a/src/features/debugger/editor/smartStep.ts b/src/features/debugger/editor/smartStep.ts new file mode 100644 index 00000000..bd149f12 --- /dev/null +++ b/src/features/debugger/editor/smartStep.ts @@ -0,0 +1,262 @@ +/** + * Smart Step Into in the editor, as in IDEA: the calls on the current line are + * highlighted in place; ←/→ (or Tab / ⇧Tab) move between them, Enter or a + * click steps into the chosen one, Esc cancels. Hovering a call selects it. + * Falls back to the chooser dialog when the calls cannot be placed on the line. + */ +import { RangeSetBuilder, type Extension } from "@codemirror/state"; +import { Decoration, EditorView, ViewPlugin, type DecorationSet } from "@codemirror/view"; +import { editorsFor, activeEditor, type EditorDocContext, type TextEditorHandle } from "@/editor/api"; +import { call, commands, type DebugSessionId, type StepInTarget } from "@/platform/ipc"; +import { suspendKeyboardDispatch } from "@/platform/keymap"; +import { notifications } from "@/workbench/contributions"; +import { useExecutionPoint } from "../state"; +import { useOverlays, type InlineStepTarget } from "../ui/popoverStore"; +import styles from "./editor.module.css"; + +const IDENT = /[\p{L}\p{N}_$]/u; + +/** The callee name of a target label: `a.b.parse(str(i))` → `parse`. */ +export function calleeName(label: string): string | null { + const head = label.split("(")[0]!.trim(); + const name = head.split(/[.:#\s]+/).pop() ?? ""; + return name && [...name].every((c) => IDENT.test(c)) ? name : null; +} + +/** A call of `name`: `name(` not preceded by an identifier character. */ +function callPattern(name: string, flags: string): RegExp { + return new RegExp(`(?(); + const out: InlineStepTarget[] = []; + for (const t of targets) { + let from = -1; + let to = -1; + if (t.column != null && (t.line == null || t.line === line) && t.column < lineText.length) { + from = t.column; + to = t.endColumn != null && t.endColumn > from ? Math.min(t.endColumn, lineText.length) : identifierEnd(lineText, from); + } else { + const name = calleeName(t.label); + if (!name) return null; + for (const m of lineText.matchAll(callPattern(name, "gu"))) { + if (used.has(m.index!)) continue; + from = m.index!; + to = from + name.length; + break; + } + } + if (from < 0 || to <= from || used.has(from)) return null; + used.add(from); + out.push({ id: t.id, from, to }); + } + return out.sort((a, b) => a.from - b.from); +} + +/** + * Targets that belong to the paused line. Some adapters (debugpy) list every + * call left in the function without positions; those are kept only when their + * callee appears on the line. Unchanged when nothing would remain. + */ +export function targetsOnLine(lineText: string, line: number, targets: StepInTarget[]): StepInTarget[] { + const kept = targets.filter((t) => { + if (t.line != null) return t.line === line; + if (t.column != null) return true; + const name = calleeName(t.label); + return !name || callPattern(name, "u").test(lineText); + }); + return kept.length ? kept : targets; +} + +/** Targets of the paused line, when that line is open in an editor. */ +export function pausedLineTargets(targets: StepInTarget[]): StepInTarget[] { + const { path, line } = useExecutionPoint.getState(); + const doc = path ? editorShowing(path)?.view.state.doc : null; + return doc && line < doc.lines ? targetsOnLine(doc.line(line + 1).text, line, targets) : targets; +} + +/** Editor to pick in: the active one when it shows `path`, else any showing it. */ +function editorShowing(path: string): TextEditorHandle | null { + const active = activeEditor(); + if (active?.ctx.path === path) return active; + return editorsFor(path)[0] ?? null; +} + +/** + * Starts inline picking when the paused line is open in an editor and every + * target can be placed on it; returns false otherwise (caller shows the dialog). + */ +export function startInlineSmartStep(session: DebugSessionId, thread: number, targets: StepInTarget[]): boolean { + const { path, line, top } = useExecutionPoint.getState(); + if (!path || !top) return false; + const editor = editorShowing(path); + const doc = editor?.view.state.doc; + if (!editor || !doc || line >= doc.lines) return false; + const ranges = locateStepTargets(doc.line(line + 1).text, line, targets); + if (!ranges || ranges.length < 2) return false; + const first = ranges.findIndex((r) => r.id === targets[0]!.id); + useOverlays.setState({ smartStep: { session, thread, targets, inline: { path, line, ranges, index: Math.max(0, first) } } }); + editor.view.focus(); + return true; +} + +export function cancelSmartStep() { + useOverlays.setState({ smartStep: null }); +} + +function selectStepTarget(index: number) { + const s = useOverlays.getState().smartStep; + if (!s?.inline) return; + const n = s.inline.ranges.length; + useOverlays.setState({ smartStep: { ...s, inline: { ...s.inline, index: ((index % n) + n) % n } } }); +} + +/** Steps into the target `id` (the selected one by default) and leaves picking mode. */ +export function chooseStepTarget(id?: number) { + const s = useOverlays.getState().smartStep; + if (!s) return; + const target = id ?? s.inline?.ranges[s.inline.index]?.id; + cancelSmartStep(); + if (target == null) return; + void call(commands.debugStepInTarget(s.session, s.thread, target)).catch((e) => + notifications().notify({ + group: "run", + level: "error", + title: "Smart Step Into failed", + body: e instanceof Error ? e.message : String(e), + }), + ); +} + +const MODIFIERS = new Set(["Shift", "Control", "Alt", "Meta", "CapsLock"]); + +function targetIdOf(e: Event): number | null { + const el = e.target instanceof Element ? e.target.closest("[data-step-target]") : null; + return el ? Number(el.dataset.stepTarget) : null; +} + +/** + * While picking: shortcuts are suspended and keys / clicks go to the picker + * (window capture, ahead of the editor). Any other key, a click elsewhere or + * the debugger moving on cancels. + */ +export function installSmartStepPicking(target: Window = window): () => void { + let teardown: (() => void) | null = null; + const onKeyDown = (e: KeyboardEvent) => { + const s = useOverlays.getState().smartStep?.inline; + if (!s || MODIFIERS.has(e.key)) return; + e.preventDefault(); + e.stopPropagation(); + if (e.key === "ArrowLeft" || e.key === "ArrowUp" || (e.key === "Tab" && e.shiftKey)) selectStepTarget(s.index - 1); + else if (e.key === "ArrowRight" || e.key === "ArrowDown" || e.key === "Tab") selectStepTarget(s.index + 1); + else if (e.key === "Enter") chooseStepTarget(); + else cancelSmartStep(); + }; + const onMouseDown = (e: MouseEvent) => { + if (!useOverlays.getState().smartStep?.inline) return; + const id = targetIdOf(e); + if (id == null) return cancelSmartStep(); + e.preventDefault(); + e.stopPropagation(); + chooseStepTarget(id); + }; + const onMouseOver = (e: MouseEvent) => { + const s = useOverlays.getState().smartStep?.inline; + const id = targetIdOf(e); + if (!s || id == null) return; + const index = s.ranges.findIndex((r) => r.id === id); + if (index >= 0 && index !== s.index) selectStepTarget(index); + }; + const start = () => { + const resume = suspendKeyboardDispatch(); + target.addEventListener("keydown", onKeyDown, true); + target.addEventListener("mousedown", onMouseDown, true); + target.addEventListener("mouseover", onMouseOver, true); + const unsubExec = useExecutionPoint.subscribe((p, prev) => { + if (p.path !== prev.path || p.line !== prev.line || p.top !== prev.top) cancelSmartStep(); + }); + teardown = () => { + resume(); + target.removeEventListener("keydown", onKeyDown, true); + target.removeEventListener("mousedown", onMouseDown, true); + target.removeEventListener("mouseover", onMouseOver, true); + unsubExec(); + }; + }; + const sync = () => { + const active = !!useOverlays.getState().smartStep?.inline; + if (active && !teardown) start(); + else if (!active && teardown) { + teardown(); + teardown = null; + } + }; + const unsub = useOverlays.subscribe(sync); + sync(); + return () => { + unsub(); + teardown?.(); + teardown = null; + }; +} + +function decorations(view: EditorView, path: string): DecorationSet { + const inline = useOverlays.getState().smartStep?.inline; + if (!inline || inline.path !== path) return Decoration.none; + const doc = view.state.doc; + if (inline.line >= doc.lines) return Decoration.none; + const line = doc.line(inline.line + 1); + const builder = new RangeSetBuilder(); + inline.ranges.forEach((r, i) => { + if (r.to > line.length) return; + const selected = i === inline.index; + builder.add( + line.from + r.from, + line.from + r.to, + Decoration.mark({ + class: selected ? `${styles.stepTarget} ${styles.stepTargetSelected}` : styles.stepTarget, + attributes: { "data-step-target": String(r.id), ...(selected ? { "aria-current": "true" } : {}) }, + }), + ); + }); + return builder.finish(); +} + +/** Highlights the Smart Step Into targets while picking in this editor. */ +export function smartStepTargets(ctx: EditorDocContext): Extension { + const path = ctx.path; + return ViewPlugin.fromClass( + class { + decorations: DecorationSet; + private unsub: () => void; + private destroyed = false; + constructor(readonly view: EditorView) { + this.decorations = decorations(view, path); + this.unsub = useOverlays.subscribe((s, prev) => { + if (s.smartStep === prev.smartStep) return; + queueMicrotask(() => !this.destroyed && this.view.dispatch({})); + }); + } + update() { + this.decorations = decorations(this.view, path); + } + destroy() { + this.destroyed = true; + this.unsub(); + } + }, + { decorations: (v) => v.decorations }, + ); +} diff --git a/src/features/debugger/index.ts b/src/features/debugger/index.ts index 716836e7..7eaa0bbb 100644 --- a/src/features/debugger/index.ts +++ b/src/features/debugger/index.ts @@ -15,6 +15,7 @@ import { loadExceptionBreakpoints } from "./breakpoints/exceptions"; import { loadSpecialBreakpoints } from "./breakpoints/special"; import { breakpointGutter } from "./editor/breakpointGutter"; import { executionPoint } from "./editor/execution"; +import { installSmartStepPicking, smartStepTargets } from "./editor/smartStep"; import { onHotCodeReplace } from "./hotswap"; import { handleDebugEvent, isLive, loadWatches, publishContext, resetSessions, stopSession, useDebug } from "./state"; import { DebuggerOverlays } from "./ui/Overlays"; @@ -50,8 +51,9 @@ export function activate(): () => void { registerEditorContribution({ id: "debugger", order: 60, - extensions: (ctx) => [breakpointGutter(ctx), executionPoint(ctx), valueTooltip()], + extensions: (ctx) => [breakpointGutter(ctx), executionPoint(ctx), valueTooltip(), smartStepTargets(ctx)], }), + installSmartStepPicking(), onIdeEvent("debug", (e) => { if (e.event.kind === "hotCodeReplace") onHotCodeReplace(e.session, e.event); else handleDebugEvent(e.session, e.event); diff --git a/src/features/debugger/ui/Overlays.tsx b/src/features/debugger/ui/Overlays.tsx index a837d02b..570f1a97 100644 --- a/src/features/debugger/ui/Overlays.tsx +++ b/src/features/debugger/ui/Overlays.tsx @@ -956,7 +956,9 @@ function AttachPopup() { // --- Smart Step Into ------------------------------------------------------------- function SmartStepChooser() { - const state = useOverlays((s) => s.smartStep); + const all = useOverlays((s) => s.smartStep); + // Picked in the editor instead. + const state = all?.inline ? null : all; const close = () => useOverlays.setState({ smartStep: null }); const pick = (targetId: number) => { if (!state) return; diff --git a/src/features/debugger/ui/popoverStore.ts b/src/features/debugger/ui/popoverStore.ts index 449e2797..3b5e1ff4 100644 --- a/src/features/debugger/ui/popoverStore.ts +++ b/src/features/debugger/ui/popoverStore.ts @@ -2,6 +2,13 @@ import { create } from "zustand"; import type { AttachableProcess, DebugSessionId, RunConfig, StepInTarget } from "@/platform/ipc"; +/** A Smart Step Into target placed on the paused line (UTF-16 offsets within the line). */ +export interface InlineStepTarget { + id: number; + from: number; + to: number; +} + interface OverlayState { popover: { path: string; id: string; x: number; y: number; focusLog: boolean } | null; evaluate: { expression: string } | null; @@ -9,8 +16,16 @@ interface OverlayState { chooser: { configs: RunConfig[] } | null; /** Attach to Process popup; `processes` is null while loading. */ attach: { processes: AttachableProcess[] | null; error: string | null } | null; - /** Smart Step Into chooser. */ - smartStep: { session: DebugSessionId; thread: number; targets: StepInTarget[] } | null; + /** + * Smart Step Into: picked in the editor when `inline` is set (targets sorted + * by position, `index` the selected one), else in the chooser dialog. + */ + smartStep: { + session: DebugSessionId; + thread: number; + targets: StepInTarget[]; + inline?: { path: string; line: number; ranges: InlineStepTarget[]; index: number }; + } | null; } export const useOverlays = create(() => ({ diff --git a/src/features/project-tree/HeaderActions.tsx b/src/features/project-tree/HeaderActions.tsx index e1dc6289..27053d31 100644 --- a/src/features/project-tree/HeaderActions.tsx +++ b/src/features/project-tree/HeaderActions.tsx @@ -19,9 +19,9 @@ export function ProjectHeaderActions() { <> diff --git a/src/features/project-tree/actions.ts b/src/features/project-tree/actions.ts index d6242a1c..c1ff897d 100644 --- a/src/features/project-tree/actions.ts +++ b/src/features/project-tree/actions.ts @@ -1,6 +1,8 @@ /** Actions of the project view (Find Action, keymap, other features via `executeAction`). */ -import { registerActions, type Action } from "@/platform/actions"; -import { maybeEditorService, toolWindowService } from "@/workbench/contributions"; +import { activeEditor } from "@/editor/api"; +import { getAction, registerActions, type Action } from "@/platform/actions"; +import { showContextMenu, type MenuItem } from "@/ui"; +import { maybeEditorService, menuGroupItems, toolWindowService } from "@/workbench/contributions"; import { OPTION_LABELS } from "./HeaderActions"; import { revealLabel } from "./contextMenu"; import type { ProjectNode } from "./model"; @@ -77,6 +79,34 @@ export function selectInProjectView(path?: string | null, focus = true) { return revealPath(target, focus); } +/** + * Select In (⌥F1): a chooser of the places that can show `path` (default: the + * active editor's file): the project view, contributed targets (`selectIn` + * menu groups), the navigation bar, Finder / Explorer and the terminal. + */ +export function selectInTargets(path: string): MenuItem[] { + return [ + { kind: "header", label: "Select In" }, + { label: "Project View", icon: "toolwindow-project", run: () => void selectInProjectView(path) }, + ...menuGroupItems("selectIn", { paths: [path] }), + ...(getAction("navigate.navigationBar") && maybeEditorService()?.activePath() === path + ? [{ kind: "action" as const, action: "navigate.navigationBar", label: "Navigation Bar" }] + : []), + { label: revealLabel(), icon: "external-link", run: () => void revealInFinder(nodesFrom(path)) }, + ...(getAction("terminal.openAt") + ? [{ label: "Terminal", icon: "terminal", run: () => void delegate("terminal.openAt", parentOf(path)) }] + : []), + ]; +} + +function showSelectIn(path: string) { + // At the caret, like IDEA's popup; centred near the top otherwise. + const view = activeEditor()?.view; + const coords = view && activeEditor()?.ctx.path === path ? view.coordsAtPos(view.state.selection.main.head) : null; + const at = coords ? { x: coords.left, y: coords.bottom + 2 } : { x: window.innerWidth / 2 - 100, y: 80 }; + showContextMenu(at, selectInTargets(path)); +} + export function registerProjectActions(): () => void { const copyPath = (kind: PathKind, id: string, title: string): Action => ({ id, @@ -97,16 +127,27 @@ export function registerProjectActions(): () => void { return registerActions([ { + // A path argument (links, breadcrumbs, Open In › Project View) selects it in the project view. id: "navigate.selectIn", + title: "Select In…", + category: "Navigate", + when: OPEN, + keywords: ["locate", "reveal", "show in"], + run: ({ arg }) => { + const path = typeof arg === "string" ? arg : (arg as { path?: string } | undefined)?.path; + if (path) return void selectInProjectView(path); + const target = maybeEditorService()?.activePath() ?? leadNode()?.path; + if (target) showSelectIn(target); + }, + }, + { + id: "navigate.selectInProjectView", title: "Select in Project View", category: "Navigate", icon: "locate", when: OPEN, - keywords: ["locate", "reveal", "scroll from source"], - run: ({ arg }) => - void selectInProjectView( - typeof arg === "string" ? arg : ((arg as { path?: string } | undefined)?.path ?? null), - ), + keywords: ["locate", "reveal", "scroll from source", "select opened file"], + run: ({ arg }) => void selectInProjectView(typeof arg === "string" ? arg : null), }, { id: "project.reveal", diff --git a/src/features/project-tree/selectIn.test.ts b/src/features/project-tree/selectIn.test.ts new file mode 100644 index 00000000..0c8bcdc5 --- /dev/null +++ b/src/features/project-tree/selectIn.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { executeAction, getAction, registerActions } from "@/platform/actions"; +import { setContext } from "@/platform/context"; +import { resolveMenuItems, useContextMenuStore, type MenuItem } from "@/ui"; +import { provideEditorService, registerMenuGroup, type EditorService } from "@/workbench/contributions"; +import { registerProjectActions, selectInTargets } from "./actions"; +import { revealLabel } from "./contextMenu"; + +const labels = (items: MenuItem[]) => resolveMenuItems(items).map((r) => (r.kind === "separator" ? "-" : r.label)); + +const disposers: Array<() => void> = []; +afterEach(() => { + disposers.splice(0).forEach((d) => d()); + useContextMenuStore.setState({ menu: null }); +}); + +describe("Select In (⌥F1)", () => { + it("lists the project view, contributed targets, Finder and the terminal", () => { + disposers.push( + registerMenuGroup({ id: "test.selectIn", menus: ["selectIn"], items: () => [{ label: "Structure", run: () => {} }] }), + registerActions([{ id: "terminal.openAt", title: "Open Terminal At", run: () => {} }]), + ); + expect(labels(selectInTargets("/p/src/a.ts"))).toEqual([ + "Select In", + "Project View", + "Structure", + "-", + revealLabel(), + "Terminal", + ]); + }); + + it("without a path shows the chooser for the active file; with one selects it in the project view", async () => { + disposers.push(registerProjectActions(), () => setContext("projectOpen", false)); + setContext("projectOpen", true); + provideEditorService({ activePath: () => "/p/src/a.ts" } as unknown as EditorService); + expect(getAction("navigate.selectInProjectView")?.title).toBe("Select in Project View"); + await executeAction("navigate.selectIn"); + expect(useContextMenuStore.getState().menu?.items[1]).toMatchObject({ label: "Project View" }); + useContextMenuStore.setState({ menu: null }); + await executeAction("navigate.selectIn", { path: "/p/src/a.ts" }); + expect(useContextMenuStore.getState().menu).toBeNull(); + }); +}); diff --git a/src/features/run/RunView.tsx b/src/features/run/RunView.tsx index 3a1c03a3..ed0a1d50 100644 --- a/src/features/run/RunView.tsx +++ b/src/features/run/RunView.tsx @@ -9,11 +9,12 @@ import { runDirectory } from "./navigate"; import { acceptsInput, clearConsole, - closeSession, + closeSessionTab, closeStdin, failedTests, rerunFailed, rerunSession, + runSelectedTests, runtime, sendStdin, setPendingInput, @@ -152,6 +153,7 @@ function SessionPanel({ session }: { session: RunSession }) { running={running} selected={selectedTest} onSelect={setSelectedTest} + onRunTests={(ids, debug) => void runSelectedTests(session.id, ids, debug)} /> @@ -215,7 +217,7 @@ export function RunTabs() { items={items} activeId={"session" in current ? current.session.id : current.trace.id} onSelect={(id) => useRun.setState({ activeSession: id })} - onClose={(id) => (traces.some((t) => t.id === id) ? closeStackTrace(id) : closeSession(id))} + onClose={(id) => (traces.some((t) => t.id === id) ? closeStackTrace(id) : void closeSessionTab(id))} panelIdPrefix="openidea-run" /> ); diff --git a/src/features/run/TestTreeView.test.tsx b/src/features/run/TestTreeView.test.tsx new file mode 100644 index 00000000..1a07f390 --- /dev/null +++ b/src/features/run/TestTreeView.test.tsx @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { ContextMenuHost } from "@/ui"; +import { TestResults } from "./testing"; +import { TestTreeView } from "./TestTreeView"; + +const nav = vi.hoisted(() => ({ openTarget: vi.fn(async () => {}) })); +vi.mock("./navigate", () => nav); +const actions = vi.hoisted(() => ({ executeAction: vi.fn(async () => {}) })); +vi.mock("@/platform/actions", async (orig) => ({ ...(await orig()), executeAction: actions.executeAction })); + +function results() { + const r = new TestResults(); + r.apply([ + { type: "result", path: ["MathTest", "adds()"], status: "passed" }, + { type: "result", path: ["MathTest", "subs()"], status: "failed" }, + { + type: "output", + path: ["MathTest", "subs()"], + line: "org.opentest4j.AssertionFailedError: expected: <1> but was: <2>", + }, + { type: "location", path: ["MathTest", "subs()"], location: { path: "MathTest.java", line: 9, resolve: "name" } }, + ]); + r.finish(1); + return r; +} + +function Host(props: { onRunTests?: (ids: string[], debug: boolean) => void }) { + return ( + <> + {}} + running={false} + {...props} + /> + + + ); +} + +describe("TestTreeView", () => { + beforeEach(() => localStorage.clear()); + afterEach(() => vi.clearAllMocks()); + + it("hides passed tests by default and remembers the toggle", () => { + const { unmount } = render(); + expect(screen.queryByText("adds()")).toBeNull(); + expect(screen.getByText("subs()")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Show Passed" })); + expect(screen.getByText("adds()")).toBeTruthy(); + unmount(); + render(); + expect(screen.getByText("adds()")).toBeTruthy(); + }); + + it("a click selects without navigating; double-click jumps to the source", () => { + render(); + fireEvent.mouseDown(screen.getByText("subs()")); + fireEvent.click(screen.getByText("subs()")); + expect(nav.openTarget).not.toHaveBeenCalled(); + fireEvent.doubleClick(screen.getByText("subs()")); + expect(nav.openTarget).toHaveBeenCalledWith( + expect.objectContaining({ path: "MathTest.java", line: 9 }), + "/p", + true, + ); + }); + + it("context menu: Run / Debug the test and Show Diff of an assertion failure", () => { + const onRunTests = vi.fn(); + render(); + fireEvent.contextMenu(screen.getByText("subs()")); + fireEvent.click(screen.getByText("Debug 'subs()'")); + expect(onRunTests).toHaveBeenCalledWith(["MathTest\u0000subs()"], true); + fireEvent.contextMenu(screen.getByText("subs()")); + fireEvent.click(screen.getByText("Show Diff")); + expect(actions.executeAction).toHaveBeenCalledWith( + "diff.compareTexts", + expect.objectContaining({ left: { text: "1", label: "Expected" }, right: { text: "2", label: "Actual" } }), + ); + }); +}); diff --git a/src/features/run/TestTreeView.tsx b/src/features/run/TestTreeView.tsx index 1d4bc323..0e9ab244 100644 --- a/src/features/run/TestTreeView.tsx +++ b/src/features/run/TestTreeView.tsx @@ -1,10 +1,51 @@ -import { useMemo, useState } from "react"; +import { useMemo, useState, type MouseEvent } from "react"; import clsx from "clsx"; -import { Icon, IconButton, Spinner, Tree } from "@/ui"; +import { executeAction } from "@/platform/actions"; +import { Icon, IconButton, showContextMenu, Spinner, Tree, type MenuItem } from "@/ui"; import { openTarget } from "./navigate"; import { nodeStatus, type TestNode, type TestResults, type TestStatus } from "./testing"; +import { parseComparison } from "./testing/comparison"; import styles from "./Run.module.css"; +/** Test tree filters, kept across runs and restarts (IDEA hides passed tests by default). */ +const FILTERS_KEY = "openidea.run.testFilters"; + +interface TestFilters { + showPassed: boolean; + showIgnored: boolean; +} + +export function loadTestFilters(): TestFilters { + try { + const raw = globalThis.localStorage?.getItem(FILTERS_KEY); + const v = raw ? (JSON.parse(raw) as Partial) : {}; + return { showPassed: v.showPassed === true, showIgnored: v.showIgnored !== false }; + } catch { + return { showPassed: false, showIgnored: true }; + } +} + +function saveTestFilters(f: TestFilters) { + try { + globalThis.localStorage?.setItem(FILTERS_KEY, JSON.stringify(f)); + } catch { + /* storage unavailable */ + } +} + +/** "Show Diff" for an assertion failure: Expected on the left, Actual on the right. */ +export function showTestDiff(node: TestNode): boolean { + const c = parseComparison(node.output); + if (!c) return false; + void executeAction("diff.compareTexts", { + path: `${node.path.join("/")}.txt`, + title: `Comparison Failure: ${node.name}`, + left: { text: c.expected, label: c.labels?.expected ?? "Expected" }, + right: { text: c.actual, label: c.labels?.actual ?? "Actual" }, + }); + return true; +} + export function formatDuration(ms: number | undefined): string { if (ms === undefined) return ""; if (ms < 1000) return `${Math.round(ms)} ms`; @@ -62,12 +103,50 @@ export interface TestTreeViewProps { selected: string | null; onSelect: (id: string | null) => void; running: boolean; + /** Run / Debug the selected tests (context menu); absent when the configuration can't be narrowed. */ + onRunTests?: (ids: string[], debug: boolean) => void; } -export function TestTreeView({ results, version, cwd, selected, onSelect, running }: TestTreeViewProps) { - const [showPassed, setShowPassed] = useState(true); - const [showIgnored, setShowIgnored] = useState(true); +export function TestTreeView({ results, version, cwd, selected, onSelect, running, onRunTests }: TestTreeViewProps) { + const [filters, setFilters] = useState(loadTestFilters); + const { showPassed, showIgnored } = filters; + const toggle = (key: keyof TestFilters) => + setFilters((f) => { + const next = { ...f, [key]: !f[key] }; + saveTestFilters(next); + return next; + }); const [expanded, setExpanded] = useState | null>(null); + const [selection, setSelection] = useState>(new Set()); + const selectedKeys = useMemo( + () => (selected === null ? new Set() : selection.has(selected) ? selection : new Set([selected])), + [selected, selection], + ); + + const jumpToSource = (n: TestNode) => { + const target = n.location ?? firstLocated(n)?.location; + if (target) void openTarget(target, cwd, true); + }; + + const contextMenu = (node: TestNode | null, sel: Set, e: MouseEvent) => { + if (!node) return; + const ids = sel.has(node.id) ? [...sel] : [node.id]; + const name = ids.length === 1 ? (results.get(ids[0]!)?.name ?? node.name) : `${ids.length} tests`; + const located = node.location ?? firstLocated(node)?.location; + const items: MenuItem[] = []; + if (onRunTests) { + items.push( + { label: `Run '${name}'`, verbatim: true, icon: "run", run: () => onRunTests(ids, false) }, + { label: `Debug '${name}'`, verbatim: true, icon: "debug", run: () => onRunTests(ids, true) }, + { kind: "separator" }, + ); + } + if (ids.length === 1 && nodeStatus(node) === "failed" && parseComparison(node.output)) + items.push({ label: "Show Diff", icon: "diff", run: () => void showTestDiff(node) }); + items.push({ label: "Jump to Source", icon: "external-link", shortcut: "F4", disabled: !located, run: () => jumpToSource(node) }); + e.preventDefault(); + showContextMenu(e, items); + }; const counts = useMemo(() => results.counts(), [results, version]); // eslint-disable-line react-hooks/exhaustive-deps const roots = useMemo(() => { @@ -112,7 +191,7 @@ export function TestTreeView({ results, version, cwd, selected, onSelect, runnin iconSize={13} title="Show Passed" pressed={showPassed} - onClick={() => setShowPassed((v) => !v)} + onClick={() => toggle("showPassed")} /> setShowIgnored((v) => !v)} + onClick={() => toggle("showIgnored")} /> @@ -133,16 +212,15 @@ export function TestTreeView({ results, version, cwd, selected, onSelect, runnin isExpandable={(n) => n.children.length > 0} expanded={effectiveExpanded} onExpandedChange={(next) => setExpanded(next)} - selected={selected ? new Set([selected]) : new Set()} - onSelectionChange={(_sel, lead) => { + selected={selectedKeys} + // A click only selects (shows the test's output); double-click / Enter / F4 jump to the source, as in IDEA. + onSelectionChange={(sel, lead) => { + setSelection(sel); onSelect(lead?.id ?? null); - if (lead && lead.location && nodeStatus(lead) === "failed") void openTarget(lead.location, cwd, false); }} activateExpandable - onActivate={(n) => { - const target = n.location ?? firstLocated(n)?.location; - if (target) void openTarget(target, cwd, true); - }} + onActivate={jumpToSource} + onContextMenu={contextMenu} renderNode={(n) => { const s = nodeStatus(n); const d = totalDuration(n); @@ -154,7 +232,11 @@ export function TestTreeView({ results, version, cwd, selected, onSelect, runnin ); }} - empty={
{running ? "Waiting for test results…" : "No test results"}
} + empty={ +
+ {running ? "Waiting for test results…" : results.isEmpty ? "No test results" : "Passed tests are hidden"} +
+ } /> ); diff --git a/src/features/run/context.ts b/src/features/run/context.ts index cf9622bf..a0691a54 100644 --- a/src/features/run/context.ts +++ b/src/features/run/context.ts @@ -39,17 +39,21 @@ function testKinds(relPath: string): string[] { return []; } -/** Config whose working directory most specifically contains `relPath`. */ -function closest(configs: RunConfig[], relPath: string): RunConfig | undefined { +/** Configs whose working directory most specifically contains `relPath` (ties in their original order). */ +function allClosest(configs: RunConfig[], relPath: string): RunConfig[] { const score = (c: RunConfig) => { const cwd = (c.cwd ?? "").replace(/^\.\/?/, "").replace(/\/+$/, ""); if (!cwd) return 0; return relPath === cwd || relPath.startsWith(cwd + "/") ? cwd.length : -1; }; - return configs - .map((c) => [c, score(c)] as const) - .filter(([, s]) => s >= 0) - .sort((a, b) => b[1] - a[1])[0]?.[0]; + const scored = configs.map((c) => [c, score(c)] as const).filter(([, s]) => s >= 0); + const best = Math.max(...scored.map(([, s]) => s)); + return scored.filter(([, s]) => s === best).map(([c]) => c); +} + +/** Config whose working directory most specifically contains `relPath`. */ +function closest(configs: RunConfig[], relPath: string): RunConfig | undefined { + return allClosest(configs, relPath)[0]; } function relToCwd(relPath: string, config: RunConfig): string { @@ -111,11 +115,25 @@ function implicitPytest(relPath: string, configs: RunConfig[]): RunConfig | null }; } -/** File-level test configuration for `relPath` (the closest matching test config, narrowed to the file). */ -export function testConfigFor(relPath: string, configs: RunConfig[]): RunConfig | null { +/** + * Test configurations that can run `relPath` equally well (same, closest working + * directory: `npm run test` and `npm run test:unit`…); the default one first. + */ +export function testBasesFor(relPath: string, configs: RunConfig[]): RunConfig[] { const kinds = testKinds(relPath); const candidates = configs.filter((c) => c.isTest && kinds.includes(c.kind)); - const base = closest(candidates, relPath) ?? candidates[0] ?? implicitPytest(relPath, configs); + const best = allClosest(candidates, relPath); + if (best.length) return best; + const fallback = candidates[0] ?? implicitPytest(relPath, configs); + return fallback ? [fallback] : []; +} + +/** + * File-level test configuration for `relPath` (the closest matching test config, + * or `base`, narrowed to the file). + */ +export function testConfigFor(relPath: string, configs: RunConfig[], base?: RunConfig): RunConfig | null { + base ??= testBasesFor(relPath, configs)[0]; if (!base) return null; const file = relToCwd(relPath, base); switch (base.kind) { diff --git a/src/features/run/contextRun.test.ts b/src/features/run/contextRun.test.ts index bb7cac67..9120f414 100644 --- a/src/features/run/contextRun.test.ts +++ b/src/features/run/contextRun.test.ts @@ -2,8 +2,14 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { RunConfig } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { menuGroupItems, registerMenuGroup } from "@/workbench/contributions"; -import { contextMenuItems } from "./contextRun"; +import type { MenuItem } from "@/ui"; +import { choiceItems, contextMenuItems } from "./contextRun"; +import { OPTIMISTIC, produceContextChoices } from "./producers"; +import { gutterTestState, markerIcon, targetMenuItems } from "./runGutter"; import { useRun } from "./store"; +import type { RunTarget } from "./targets"; +import { TestResults } from "./testing"; +import { clearTestStates, recordTestStates } from "./testing/states"; const ROOT = "/work/app"; const SCRATCHES = "/Users/me/scratches"; @@ -131,3 +137,53 @@ describe("debug entry", () => { expect(labels(rs)[1]).toBe("run.debugContext|Debug 'a.rs'|debug"); }); }); + +describe("run gutter popup", () => { + const unit: RunConfig = { ...npmTest, id: "npm:test:unit", name: "npm run test:unit", args: ["run", "test:unit"] }; + const text = "describe('math', () => {\n it('adds', () => {\n });\n});\n"; + const adds: RunTarget = { line: 1, kind: "test", name: "math > adds", path: ["math", "adds"] }; + const itemLabels = (items: MenuItem[]): string[] => + items.map((i) => (i.kind === "separator" ? "---" : "label" in i ? `${i.label}${"submenu" in i && i.submenu ? " ▸" : ""}` : "")); + + beforeEach(() => { + useProject.setState({ project: { name: "app", root: ROOT, isGit: false, settings: {} } as never }); + useRun.setState({ configs: [npmTest], loaded: true }); + }); + afterEach(() => { + useProject.setState({ project: null }); + useRun.setState({ configs: [], loaded: false }); + clearTestStates(); + }); + + it("ends with Modify Run Configuration… after the executors", () => { + const items = targetMenuItems(adds, `${ROOT}/src/m.test.ts`, text); + expect(itemLabels(items)).toEqual(["Run 'math > adds'", "Debug 'math > adds'", "---", "Modify Run Configuration…"]); + }); + + it("lists each matching test configuration when several match", () => { + useRun.setState({ configs: [npmTest, unit] }); + const items = targetMenuItems(adds, `${ROOT}/src/m.test.ts`, text); + expect(itemLabels(items).slice(0, 2)).toEqual(["Run 'math > adds' ▸", "Debug 'math > adds' ▸"]); + const run = items[0] as Extract; + expect(run.submenu!.map((i) => ("shortcut" in i ? i.shortcut : null))).toEqual(["test", "npm run test:unit"]); + // ⌃⇧R's chooser lists the same, under a header. + expect(choiceItems(produceContextChoices({ path: `${ROOT}/src/m.test.ts`, root: ROOT, configs: [npmTest, unit], content: text, line: 1 }, OPTIMISTIC), false)[0]).toEqual({ + kind: "header", + label: "Choose configuration to run", + }); + }); + + it("icons follow the last test result", () => { + const path = `${ROOT}/src/m.test.ts`; + expect(markerIcon("test", gutterTestState(adds, path))).toBe("gutter-run"); + const r = new TestResults(); + r.apply([{ type: "result", path: ["src/m.test.ts", "math", "adds"], status: "failed" }]); + recordTestStates(r); + expect(markerIcon("test", gutterTestState(adds, path))).toBe("gutter-run-failed"); + expect(markerIcon("suite", gutterTestState({ ...adds, kind: "suite", name: "math", path: ["math"] }, path))).toBe( + "gutter-run-failed", + ); + // `main` targets never show results. + expect(gutterTestState({ line: 0, kind: "main", name: "main", path: [] }, path)).toBeNull(); + }); +}); diff --git a/src/features/run/contextRun.ts b/src/features/run/contextRun.ts index d0d53aa0..5c3b70bb 100644 --- a/src/features/run/contextRun.ts +++ b/src/features/run/contextRun.ts @@ -9,7 +9,7 @@ import { executeAction, getAction } from "@/platform/actions"; import { call, commands, isTauri, type RunConfig } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { isVirtualUri } from "@/platform/virtualDocs"; -import type { MenuItem } from "@/ui"; +import { showContextMenu, type MenuItem } from "@/ui"; import { maybeEditorService, notifications, @@ -21,6 +21,7 @@ import { neededTools, OPTIMISTIC, produceContext, + produceContextChoices, produceDirectory, produceFile, scriptLanguage, @@ -160,12 +161,43 @@ export async function runContextAction(debug: boolean, arg?: unknown): Promise 1) { + // Several test configurations match equally well: like IDEA, let the user choose. + showContextMenu(chooserPoint(target === null), choiceItems(choices, debug)); + return true; + } + const p = await produceResolved(input, dir); if (!p) return false; await launchProduced(p, debug); return true; } +/** Menu entries for each of several productions: "Run ''" with the configuration it derives from. */ +export function choiceItems(choices: readonly Produced[], debug: boolean): MenuItem[] { + return [ + { kind: "header", label: debug ? "Choose configuration to debug" : "Choose configuration to run" }, + ...choices.map( + (p): MenuItem => ({ + label: `${debug ? "Debug" : "Run"} '${p.name}'`, + verbatim: true, + icon: debug ? "debug" : "run", + shortcut: p.detail, + run: () => void launchProduced(p, debug), + }), + ), + ]; +} + +/** Below the caret for the editor context, else the middle of the window. */ +function chooserPoint(fromEditor: boolean): { x: number; y: number } { + const editor = fromEditor ? activeEditor() : null; + const caret = editor?.view.coordsAtPos(editor.view.state.selection.main.head); + if (caret) return { x: caret.left, y: caret.bottom + 2 }; + return { x: Math.round(window.innerWidth / 2 - 120), y: Math.round(window.innerHeight / 3) }; +} + /** Runs the context with a run executor (Run with Coverage, Profile…). False when there is nothing to run. */ export async function executeContext(executor: string, arg?: unknown): Promise { await ensureConfigs(); diff --git a/src/features/run/dialogs.tsx b/src/features/run/dialogs.tsx index 2f4aa98d..3bae27b3 100644 --- a/src/features/run/dialogs.tsx +++ b/src/features/run/dialogs.tsx @@ -36,6 +36,25 @@ export async function confirmRerun(name: string, instances = 1): Promise { + const answer = await confirmChecked({ + title: `Process '${name}' Is Running`, + message: `Do you want to terminate the process '${name}'?`, + ok: "Terminate", + checkbox: "Do not ask again", + }); + if (answer?.checked && isTauri()) { + try { + await call(commands.settingsSet("global", "run.confirmTerminate", "false")); + await loadSettings(); + } catch { + /* keep asking */ + } + } + return answer !== null; +} + /** Asks for the value of `$Prompt$`; null when cancelled. */ export function promptValue(title: string): Promise { return new Promise((resolve) => useRunDialog.setState({ pending: { title, resolve } })); diff --git a/src/features/run/java.test.ts b/src/features/run/java.test.ts index 2b8476ce..64dfdcea 100644 --- a/src/features/run/java.test.ts +++ b/src/features/run/java.test.ts @@ -274,7 +274,7 @@ describe("JUnit XML reports", () => { expect(events).toContainEqual({ type: "location", path: ["a.B$Inner", "bad"], - location: { path: "B.java", line: 42, resolve: "name" }, + location: { path: "a/B.java", line: 42, resolve: "name", className: "a.B$Inner" }, }); }); }); diff --git a/src/features/run/links.test.ts b/src/features/run/links.test.ts index f607c3d3..a24ad7a3 100644 --- a/src/features/run/links.test.ts +++ b/src/features/run/links.test.ts @@ -35,9 +35,27 @@ describe("findOutputLinks", () => { expect(link).toEqual({ path: "app/main.py", line: 3, resolve: "path", text: 'File "app/main.py", line 3' }); }); - it("java / kotlin frames resolve by file name", () => { + it("java / kotlin frames resolve by fully qualified class name", () => { const [link] = files("\tat com.acme.orders.OrderController.list(OrderController.kt:22)"); - expect(link).toEqual({ path: "OrderController.kt", line: 22, resolve: "name", text: "OrderController.kt:22" }); + expect(link).toEqual({ + path: "com/acme/orders/OrderController.kt", + line: 22, + resolve: "name", + className: "com.acme.orders.OrderController", + text: "OrderController.kt:22", + }); + // Nested classes and lambdas live in the outer class's file; JDK module prefixes are skipped. + expect(files("\tat com.acme.Outer$Inner.lambda$run$0(Outer.java:7)")[0]).toMatchObject({ + path: "com/acme/Outer.java", + className: "com.acme.Outer$Inner", + }); + expect(files("\tat java.base/java.util.ArrayList.get(ArrayList.java:427)")[0]).toMatchObject({ + path: "java/util/ArrayList.java", + className: "java.util.ArrayList", + }); + // The default package, and a bare `(File.java:3)` without a frame. + expect(files("\tat Main.main(Main.java:3)")[0]).toMatchObject({ path: "Main.java", className: "Main" }); + expect(files("(Main.java:3)")[0]).toEqual({ path: "Main.java", line: 3, resolve: "name", text: "Main.java:3" }); }); it("go and generic path:line", () => { diff --git a/src/features/run/links.ts b/src/features/run/links.ts index 2179566c..e299098e 100644 --- a/src/features/run/links.ts +++ b/src/features/run/links.ts @@ -10,10 +10,28 @@ export interface FileTarget { line?: number; col?: number; /** - * `path`: resolve against the run directory; `name`: a bare file name from a - * JVM stack frame (`(OrderController.kt:22)`) — look it up in the project. + * `path`: resolve against the run directory; `name`: a file name from a JVM + * stack frame — look it up in the project (`path` then is the file under its + * package directory, `com/acme/OrderController.kt`, when the frame names the class). */ resolve: "path" | "name"; + /** JVM frames: the class's binary name (`com.acme.OrderController$Inner`). */ + className?: string; +} + +/** + * The target of a JVM stack frame `at com.acme.Foo.bar(Foo.kt:22)`: the file + * under its package directory, so frames resolve by fully qualified class name + * (as in IDEA), not by a bare file name that several packages may share. + */ +export function jvmFrameTarget(className: string | undefined, file: string, line: number | undefined): FileTarget { + const pkg = className?.includes(".") ? className.slice(0, className.lastIndexOf(".")).replace(/\./g, "/") : ""; + return { + path: pkg ? `${pkg}/${file}` : file, + line, + resolve: "name", + ...(className ? { className } : {}), + }; } export type OutputLink = @@ -49,10 +67,11 @@ const PATTERNS: Pattern[] = [ }, // JVM: at com.acme.OrderController.list(OrderController.kt:22) { - re: /\(([\w$.-]+\.(?:java|kt|kts|scala|groovy|clj)):(\d+)\)/dg, + re: /(?:([\w$]+(?:\.[\w$]+)*)\.[\w$<>-]+)?\(([\w$.-]+\.(?:java|kt|kts|scala|groovy|clj)):(\d+)\)/dg, build: (m) => { - const [start, end] = groupRange(m, 0); - return { kind: "file", start: start + 1, end: end - 1, target: { path: m[1]!, line: num(m[2]), resolve: "name" } }; + const [start] = groupRange(m, 2); + const [, end] = groupRange(m, 3); + return { kind: "file", start, end, target: jvmFrameTarget(m[1], m[2]!, num(m[3])) }; }, }, // JS / Node: at foo (src/a.ts:3:7) · at file:///abs/a.mjs:3:7 · at /abs/a.js:3:7 diff --git a/src/features/run/navigate.test.ts b/src/features/run/navigate.test.ts new file mode 100644 index 00000000..bd3c041e --- /dev/null +++ b/src/features/run/navigate.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { jvmFrameTarget } from "./links"; + +const files: Record = { + "/p/src/main/java/com/acme/Util.java": "package com.acme;\nclass Util {}", + "/p/other/Util.java": "package org.other;\nclass Util {}", + "/p/src/main/kotlin/Orders.kt": "package com.acme.orders\nfun main() {}", +}; +const symbols = vi.hoisted(() => ({ list: [] as unknown[] })); + +vi.mock("@/platform/ipc", () => ({ + isTauri: () => true, + nextRequestId: () => 1, + call: async (v: unknown) => v, + commands: { + fsReadDir: async () => [], + fsReadText: async (path: string) => files[path] ?? "", + findFiles: async (name: string) => + Object.keys(files) + .filter((p) => p.endsWith(`/${name}`)) + .map((path) => ({ path, relPath: path.slice(3) })), + lspWorkspaceSymbols: async () => symbols.list, + }, +})); + +const { resolveTarget } = await import("./navigate"); + +describe("JVM stack frame resolution", () => { + afterEach(() => { + symbols.list = []; + }); + + it("picks the file of the frame's package, not any file of that name", async () => { + expect(await resolveTarget(jvmFrameTarget("com.acme.Util", "Util.java", 3), "/p")).toBe( + "/p/src/main/java/com/acme/Util.java", + ); + expect(await resolveTarget(jvmFrameTarget("org.other.Util", "Util.java", 3), "/p")).toBe("/p/other/Util.java"); + }); + + it("finds Kotlin files outside their package directory by their package declaration", async () => { + expect(await resolveTarget(jvmFrameTarget("com.acme.orders.OrdersKt", "Orders.kt", 1), "/p")).toBe( + "/p/src/main/kotlin/Orders.kt", + ); + }); + + it("library classes open the language server's source, else nothing", async () => { + expect(await resolveTarget(jvmFrameTarget("java.util.ArrayList", "ArrayList.java", 427), "/p")).toBeNull(); + symbols.list = [ + { + name: "ArrayList", + kind: "class", + container: "java.util", + location: { path: "jdt://contents/ArrayList.class", uri: "jdt://contents/ArrayList.class" }, + }, + ]; + expect(await resolveTarget(jvmFrameTarget("java.util.ArrayList", "ArrayList.java", 427), "/p")).toBe( + "jdt://contents/ArrayList.class", + ); + // Same simple name in another package isn't it. + expect(await resolveTarget(jvmFrameTarget("java.awt.List$X", "List.java", 1), "/p")).toBeNull(); + }); +}); diff --git a/src/features/run/navigate.ts b/src/features/run/navigate.ts index f39a87f8..2d73e7ba 100644 --- a/src/features/run/navigate.ts +++ b/src/features/run/navigate.ts @@ -1,5 +1,5 @@ /** Opens file locations printed in run output (resolving relative and bare names). */ -import { call, commands, isTauri } from "@/platform/ipc"; +import { call, commands, isTauri, nextRequestId } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { editorService, notifications } from "@/workbench/contributions"; import { joinPath, type FileTarget } from "./links"; @@ -16,13 +16,55 @@ async function exists(path: string): Promise { } } -async function findByName(path: string): Promise { - const name = path.split("/").pop() ?? path; +const packageOf = (className: string) => (className.includes(".") ? className.slice(0, className.lastIndexOf(".")) : ""); + +/** Whether a JVM source file declares `pkg` (Kotlin files needn't sit in their package's directory). */ +async function declaresPackage(path: string, pkg: string): Promise { try { - const hits = await call(commands.findFiles(name, 5)); - const tail = path.replace(/^\.?\//, ""); - const best = hits.find((h) => h.relPath.endsWith(tail)) ?? hits.find((h) => h.relPath.split("/").pop() === name); - return best?.path ?? null; + const text = (await call(commands.fsReadText(path))).slice(0, 20_000); + const declared = /^\s*package\s+([\w.`]+)/m.exec(text)?.[1]?.replace(/`/g, "") ?? ""; + return declared === pkg; + } catch { + return false; + } +} + +async function findByName(target: FileTarget): Promise { + const name = target.path.split("/").pop() ?? target.path; + try { + const hits = await call(commands.findFiles(name, 50)); + const tail = target.path.replace(/^\.?\//, ""); + const exact = hits.find((h) => h.relPath === tail || h.relPath.endsWith(`/${tail}`)); + if (exact) return exact.path; + const named = hits.filter((h) => h.relPath.split("/").pop() === name); + // A stack frame names its class: a same-named file of another package is not it. + if (target.className) { + const pkg = packageOf(target.className); + for (const h of named) if (await declaresPackage(h.path, pkg)) return h.path; + return null; + } + return named[0]?.path ?? null; + } catch { + return null; + } +} + +const TYPE_KINDS = new Set(["class", "interface", "enum", "struct", "trait"]); + +/** + * Library / JDK classes: the source the language server has for them (attached + * sources or decompiled), opened as a read-only virtual document. + */ +async function findLibraryClass(className: string): Promise { + const outer = className.split("$")[0]!; + const simple = outer.slice(outer.lastIndexOf(".") + 1); + const pkg = packageOf(outer); + try { + const symbols = await call(commands.lspWorkspaceSymbols(nextRequestId(), simple)); + const hit = symbols.find( + (s) => s.name === simple && TYPE_KINDS.has(s.kind) && (s.container ?? "") === pkg, + ); + return hit ? (hit.location.uri ?? hit.location.path) : null; } catch { return null; } @@ -45,12 +87,23 @@ export async function resolveTarget(target: FileTarget, cwd: string): Promise { if (!isTauri()) return; const path = await resolveTarget(target, cwd); + if (!path && target.className) { + // A library or JDK frame without sources: nothing to open, which is no error. + notifications().notify({ + group: "run", + level: "info", + title: "No sources for this class", + body: `${target.className.split("$")[0]} is not part of the project, and no library sources are available for it.`, + key: "run-link", + }); + return; + } if (!path) { notifications().notify({ group: "run", level: "warning", title: "File not found", body: target.path, key: "run-link" }); return; diff --git a/src/features/run/producers.test.ts b/src/features/run/producers.test.ts index 66afb090..773b7043 100644 --- a/src/features/run/producers.test.ts +++ b/src/features/run/producers.test.ts @@ -5,6 +5,7 @@ import { nodeTypeStripping, OPTIMISTIC, produceContext, + produceContextChoices, produceDirectory, produceFile, shebang, @@ -277,6 +278,24 @@ describe("project producers", () => { expect(at(7)).toMatchObject({ name: "m.test.ts", scope: "file" }); }); + it("several equally close test configurations: one choice each, the default first", () => { + const withUnit = [ + ...configs, + cfg({ id: "npm:test:unit", name: "npm run test:unit", kind: "npm", command: "npm", args: ["run", "test:unit"], isTest: true }), + ]; + const src = "describe('math', () => {\n it('adds', () => {\n });\n});\n"; + const input = { path: `${ROOT}/src/m.test.ts`, root: ROOT, configs: withUnit, content: src, line: 1 }; + const choices = produceContextChoices(input, ALL); + expect(choices.map((c) => [c.name, c.detail, c.scope])).toEqual([ + ["math > adds", "npm:test", "element"], + ["math > adds", "npm run test:unit", "element"], + ]); + expect(choices[1]!.config!.args.slice(0, 2)).toEqual(["run", "test:unit"]); + // The file context offers the same choice; a single matching configuration needs none. + expect(produceContextChoices({ ...input, line: undefined }, ALL).map((c) => c.detail)).toEqual(["npm:test", "npm run test:unit"]); + expect(produceContextChoices({ ...input, configs }, ALL)).toHaveLength(1); + }); + it("python: test function > test class > file; __main__ runs the file", () => { const src = ["class TestMath:", " def test_add(self):", " assert 1", "", " x = 1", "", "def helper():", " pass"].join("\n"); const at = (line: number) => diff --git a/src/features/run/producers.ts b/src/features/run/producers.ts index 1e820580..42e2d304 100644 --- a/src/features/run/producers.ts +++ b/src/features/run/producers.ts @@ -14,7 +14,7 @@ import { isTestPath } from "@/platform/languageSettings"; import { currentRoots } from "@/platform/projectRoots"; import { applicationConfig, jvmModuleDir } from "./application"; import { formatArgs } from "./configs"; -import { contextConfig, fileRole, projectPython, testConfigFor } from "./context"; +import { contextConfig, fileRole, projectPython, testBasesFor, testConfigFor } from "./context"; import { detectRunTargets, targetConfig, targetLanguage, type RunTarget } from "./targets"; export interface LocatedTool { @@ -67,6 +67,8 @@ export interface Produced { missing?: Missing; /** Compiled single files: build `build`, then debug `config` (the binary). */ debug?: { build: RunConfig; config: RunConfig }; + /** Among several choices: the configuration it derives from (`npm run test:unit`). */ + detail?: string; } // --------------------------------------------------------------------------- @@ -757,6 +759,37 @@ export function produceContext(input: ContextInput, env: ToolEnv): Produced | nu return produceFile(input, env); } +/** + * Every configuration that can run the context, when several test + * configurations match it equally well (`npm run test` and `npm run test:unit`): + * like IDEA, the user then chooses one. A single production otherwise. + */ +export function produceContextChoices(input: ContextInput, env: ToolEnv): Produced[] { + const produced = produceContext(input, env); + if (!produced?.config || produced.scope === "directory" || !inProject(input.path, input.root)) return produced ? [produced] : []; + const rel = relTo(input.path, input.root); + const bases = testBasesFor(rel, [...input.configs]); + if (bases.length < 2) return [produced]; + let element: RunTarget | null = null; + if (produced.scope === "element") { + const targets = fileTargets(input, (input.content ?? "").slice(0, 400_000)); + element = innermostTarget(input.content ?? "", targets, input.line ?? 0, /\.py$/.test(input.path)); + if (!element || element.kind === "main") return [produced]; + } else if (!produced.config.isTest) { + return [produced]; + } + const out: Produced[] = []; + for (const base of bases) { + const found = element + ? targetConfig(element, rel, [...input.configs], input.content, base) + : testConfigFor(rel, [...input.configs], base); + const config = found && goTestNamed(found, input, element?.name); + if (!config || out.some((o) => o.config?.id === config.id)) continue; + out.push({ name: config.name, scope: produced.scope, config, detail: base.name }); + } + return out.length > 1 ? out : [produced]; +} + // --------------------------------------------------------------------------- // Directories (project view): "Run 'Tests in 'dir''", "Run 'All Tests'" diff --git a/src/features/run/runGutter.ts b/src/features/run/runGutter.ts index 38c78999..e42f206e 100644 --- a/src/features/run/runGutter.ts +++ b/src/features/run/runGutter.ts @@ -10,13 +10,14 @@ import type { EditorDocContext } from "@/editor/api"; import { useProject } from "@/platform/project"; import { isVirtualUri } from "@/platform/virtualDocs"; import { shortcutLabel } from "@/platform/keymap"; -import { iconMarkup, showContextMenu } from "@/ui"; +import { iconMarkup, showContextMenu, type MenuItem } from "@/ui"; import { notifications, runExecutors } from "@/workbench/contributions"; -import { executeContext, launchProduced, produceResolved } from "./contextRun"; +import { choiceItems, executeContext, launchProduced, modifyContext, produceResolved } from "./contextRun"; import { goModulePath, loadGoModules } from "./goModules"; -import { fileTargets, OPTIMISTIC, produceContext, type ContextInput } from "./producers"; +import { fileTargets, OPTIMISTIC, produceContextChoices, type ContextInput } from "./producers"; import { loadConfigs, useRun } from "./store"; import { targetLanguage, type RunTarget } from "./targets"; +import { onTestStatesChanged, testState, testStatesVersion, type TestState } from "./testing/states"; /** Languages with gutter run targets (C / C++ `main` included). */ const hasGutterTargets = (path: string) => !!targetLanguage(path) || /\.(c|cpp|cc|cxx)$/.test(path); @@ -89,43 +90,56 @@ export function label(t: RunTarget): string { return t.name; } -function showTargetMenu(view: EditorView, target: RunTarget, path: string, at: { x: number; y: number }) { - const content = view.state.doc.toString(); - const p = produceContext(targetInput(path, content.slice(0, 400_000), target.line), OPTIMISTIC); +export function targetMenuItems(target: RunTarget, path: string, content: string): MenuItem[] { + const input = targetInput(path, content.slice(0, 400_000), target.line); + const choices = produceContextChoices(input, OPTIMISTIC); + const p = choices[0] ?? null; const name = p?.name ?? label(target); + const context = { path, line: target.line, content: content.slice(0, 400_000) }; + // Several test configurations match: one entry per configuration, as IDEA's gutter does. + const pick = (debug: boolean): Partial => + choices.length > 1 + ? { submenu: choiceItems(choices, debug).filter((i) => i.kind !== "header") } + : { run: () => void launch(target, path, debug, content) }; // Like IDEA, Debug only where a debugger can run it. const debuggable = !p || !!(p.debug || p.config?.debugAdapter || p.missing); - showContextMenu(at, [ - { - label: `Run '${name}'`, - icon: "run", - shortcut: shortcutLabel("run.runContext"), - run: () => void launch(target, path, false, content), - }, + return [ + { label: `Run '${name}'`, icon: "run", shortcut: shortcutLabel("run.runContext"), ...pick(false) } as MenuItem, ...(debuggable - ? [ - { - label: `Debug '${name}'`, - icon: "debug", - shortcut: shortcutLabel("run.debugContext"), - run: () => void launch(target, path, true, content), - }, - ] + ? [{ label: `Debug '${name}'`, icon: "debug", shortcut: shortcutLabel("run.debugContext"), ...pick(true) } as MenuItem] : []), ...runExecutors() .filter((e) => !!p?.config && e.supports(p.config)) - .map((e) => ({ - label: e.label(name), - icon: e.icon, - run: () => void executeContext(e.id, { path, line: target.line, content: content.slice(0, 400_000) }), - })), - ]); + .map((e) => ({ label: e.label(name), icon: e.icon, run: () => void executeContext(e.id, context) })), + { kind: "separator" }, + { label: "Modify Run Configuration…", icon: "edit", run: () => void modifyContext(context) }, + ]; +} + +function showTargetMenu(view: EditorView, target: RunTarget, path: string, at: { x: number; y: number }) { + showContextMenu(at, targetMenuItems(target, path, view.state.doc.toString())); +} + +/** Results of frameworks that root their test tree at the file (jest/vitest, pytest) also match by file. */ +const fileRootedResults = (path: string) => /\.(py|[cm]?[jt]sx?)$/.test(path); + +/** Last test result of a gutter target (IDEA: green / red run icons after a test run). */ +export function gutterTestState(target: RunTarget, path: string): TestState | null { + if (target.kind === "main") return null; + return testState({ path: target.path, file: fileRootedResults(path) ? path : undefined }); +} + +export function markerIcon(kind: RunTarget["kind"], state: TestState | null): string { + if (state === "failed") return "gutter-run-failed"; + if (state === "passed") return "gutter-run-passed"; + return kind === "suite" ? "gutter-run-all" : "gutter-run"; } class RunMarker extends GutterMarker { constructor( readonly target: PlacedTarget, readonly path: string, + readonly state: TestState | null = null, ) { super(); } @@ -134,7 +148,8 @@ class RunMarker extends GutterMarker { other instanceof RunMarker && other.target.kind === this.target.kind && other.target.name === this.target.name && - other.path === this.path + other.path === this.path && + other.state === this.state ); } override toDOM(): Node { @@ -143,18 +158,29 @@ class RunMarker extends GutterMarker { el.title = `${targetTitle(this.target)} '${label(this.target)}'`; el.setAttribute("role", "button"); el.setAttribute("aria-label", el.title); - el.innerHTML = iconMarkup(this.target.kind === "suite" ? "gutter-run-all" : "gutter-run", 14) ?? ""; + el.innerHTML = iconMarkup(markerIcon(this.target.kind, this.state), 14) ?? ""; return el; } } +/** The gutter asks for markers on every update: rebuild only when targets or test results changed. */ +const markerCache = new WeakMap }>(); + function markers(state: EditorState, path: string): RangeSet { - return RangeSet.of( - state.field(targetsField).map((t) => new RunMarker(t, path).range(t.pos)), + const targets = state.field(targetsField); + const version = testStatesVersion(); + const cached = markerCache.get(targets); + if (cached?.version === version) return cached.set; + const set = RangeSet.of( + targets.map((t) => new RunMarker(t, path, gutterTestState(t, path)).range(t.pos)), true, ); + markerCache.set(targets, { version, set }); + return set; } +const testStatesChanged = StateEffect.define(); + const DEBOUNCE = 350; export function runGutter(ctx: EditorDocContext): Extension { @@ -167,8 +193,13 @@ export function runGutter(ctx: EditorDocContext): Extension { const detector = ViewPlugin.fromClass( class { timer: ReturnType | undefined; + readonly unsubscribe: () => void; constructor(readonly view: EditorView) { this.schedule(0); + // Redraw the markers with the new results (the gutter recomputes them on any update). + this.unsubscribe = onTestStatesChanged(() => { + if (view.state.field(targetsField).length) view.dispatch({ effects: testStatesChanged.of(null) }); + }); } update(u: ViewUpdate) { if (u.docChanged) this.schedule(DEBOUNCE); @@ -187,6 +218,7 @@ export function runGutter(ctx: EditorDocContext): Extension { } destroy() { clearTimeout(this.timer); + this.unsubscribe(); } }, ); diff --git a/src/features/run/session.test.tsx b/src/features/run/session.test.tsx index c4031cc4..5d1d4a78 100644 --- a/src/features/run/session.test.tsx +++ b/src/features/run/session.test.tsx @@ -13,10 +13,11 @@ import { ConsoleModel } from "./console"; import { ConsoleView, type ConsoleInput } from "./ConsoleView"; import { buildRows, mnemonics } from "./ConfigChooser"; import { activate } from "./index"; -import { resetRuns, startRun, useRun, type RunSession } from "./store"; +import { closeSessionTab, resetRuns, startRun, useRun, type RunSession } from "./store"; const confirm = vi.hoisted(() => vi.fn(async () => true)); -vi.mock("./dialogs", () => ({ confirmRerun: confirm, promptValue: vi.fn(), RunDialogsOverlay: () => null })); +const confirmTerminate = vi.hoisted(() => vi.fn(async () => true)); +vi.mock("./dialogs", () => ({ confirmRerun: confirm, confirmTerminate, promptValue: vi.fn(), RunDialogsOverlay: () => null })); const config = (id: string, extra: Partial = {}): RunConfig => ({ id, @@ -51,7 +52,7 @@ beforeEach(() => { provideNotificationService({ notify: () => undefined }); }); -describe("single-instance runs (IDEA restartRunProfile)", () => { +describe("single-instance runs", () => { it("Run on a running configuration asks, then restarts it in its tab", async () => { const app = config("app"); useRun.setState({ sessions: [running("run1", app)] }); @@ -85,6 +86,26 @@ describe("single-instance runs (IDEA restartRunProfile)", () => { }); }); +describe("closing a Run tab", () => { + it("asks before terminating a running process and keeps the tab when cancelled", async () => { + const app = config("app"); + useRun.setState({ sessions: [running("run1", app)] }); + confirmTerminate.mockResolvedValueOnce(false); + expect(await closeSessionTab("run1")).toBe(false); + expect(confirmTerminate).toHaveBeenCalledWith("app"); + expect(useRun.getState().sessions).toHaveLength(1); + expect(await closeSessionTab("run1")).toBe(true); + expect(useRun.getState().sessions).toHaveLength(0); + }); + + it("closes a finished session without asking", async () => { + confirmTerminate.mockClear(); + useRun.setState({ sessions: [{ ...running("run1", config("app")), status: "finished", exitCode: 0 }] }); + expect(await closeSessionTab("run1")).toBe(true); + expect(confirmTerminate).not.toHaveBeenCalled(); + }); +}); + describe("Stop", () => { let dispose: () => void; beforeEach(() => { diff --git a/src/features/run/store.ts b/src/features/run/store.ts index 82eb70c5..62b468ff 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -8,12 +8,15 @@ import { Channel } from "@tauri-apps/api/core"; import { call, commands, isTauri, type OutputChunk, type RunConfig, type RunId, type TestCaseReport } from "@/platform/ipc"; import { formatCompileErrors, isJvmApplication, parseJavaArgs, resolveJavaCommand } from "@/platform/javaLaunch"; import { useProject } from "@/platform/project"; +import { executeAction } from "@/platform/actions"; import { workflowSettings } from "@/platform/workflowSettings"; import { maybeEditorService, notifications, toolWindowService } from "@/workbench/contributions"; import { ConsoleModel } from "./console"; -import { confirmRerun } from "./dialogs"; +import { jvmFrameTarget } from "./links"; +import { confirmRerun, confirmTerminate } from "./dialogs"; import { createTestParser, frameworkFor, TestResults, withTestReporting, type TestEvent, type TestOutputParser } from "./testing"; -import { failedTestPaths, rerunFailedConfig } from "./testing/rerun"; +import { recordTestStates } from "./testing/states"; +import { failedTestPaths, leafTestPaths, rerunFailedConfig, testsConfig } from "./testing/rerun"; export type SessionStatus = "starting" | "running" | "finished" | "failed"; @@ -518,6 +521,7 @@ function finishSession(sessionId: string, code: number | null) { if (frameworkFor(session.config) === "jvm") rt.tests.apply(await jvmReportEvents(session)); if (runtimes.get(sessionId) !== rt) return; rt.tests.finish(code); + recordTestStates(rt.tests); } patchSession(sessionId, { status: "finished", exitCode: code, endedAt: Date.now() }); bump(sessionId); @@ -553,10 +557,10 @@ export function testReportEvents(reports: readonly TestCaseReport[]): TestEvent[ const text = [r.message, r.output].filter(Boolean).join("\n"); for (const line of text.split("\n").slice(0, 200)) events.push({ type: "output", path, line }); const outer = r.className.split("$")[0]!; - const frame = new RegExp(`at ${outer.replace(/[.$]/g, "\\$&")}(?:\\$[\\w$]+)?\\.[\\w$<>]+\\(([\\w$-]+\\.(?:java|kt|groovy|scala)):(\\d+)\\)`).exec( + const frame = new RegExp(`at (${outer.replace(/[.$]/g, "\\$&")}(?:\\$[\\w$]+)?)\\.[\\w$<>]+\\(([\\w$-]+\\.(?:java|kt|groovy|scala)):(\\d+)\\)`).exec( r.output ?? "", ); - if (frame) events.push({ type: "location", path, location: { path: frame[1]!, line: Number(frame[2]), resolve: "name" } }); + if (frame) events.push({ type: "location", path, location: jvmFrameTarget(frame[1], frame[2]!, Number(frame[3])) }); } return events; } @@ -635,6 +639,26 @@ export function rerunFailed(sessionId: string): boolean { return true; } +/** + * Run / Debug the tests selected in a session's test tree (IDEA: a temporary + * configuration for them). False when the framework can't filter tests. + */ +export async function runSelectedTests(sessionId: string, nodeIds: readonly string[], debug: boolean): Promise { + const session = getSession(sessionId); + const tests = runtimes.get(sessionId)?.tests; + if (!session || !tests) return false; + const nodes = nodeIds.map((id) => tests.get(id)).filter((n) => !!n); + const paths = leafTestPaths(nodes); + const base = session.baseConfig ?? session.config; + const name = nodes.length === 1 ? nodes[0]!.name : `${paths.length} tests`; + const derived = testsConfig(base, paths, name); + if (!derived) return false; + const config = adoptTemporary({ ...derived, id: `context:tests:${base.id}:${paths.map((p) => p.join("/")).join("|")}` }); + if (debug) await executeAction("run.debugConfig", config); + else await startRun(config); + return true; +} + /** Whether the console takes typed input: the process runs and its stdin is open. */ export function acceptsInput(session: RunSession): boolean { return session.status === "running" && session.runId !== null && !session.stdinClosed; @@ -676,6 +700,16 @@ export function clearConsole(sessionId: string) { bump(sessionId); } +/** Closing a Run tab: a process still running is terminated once the user agrees, as IDEA asks. */ +export async function closeSessionTab(sessionId: string): Promise { + const session = getSession(sessionId); + if (!session) return false; + const running = session.status === "running" || session.status === "starting"; + if (running && workflowSettings().run.confirmTerminate && !(await confirmTerminate(session.config.name))) return false; + closeSession(sessionId); + return true; +} + export function closeSession(sessionId: string) { const session = getSession(sessionId); if (!session) return; diff --git a/src/features/run/targets.ts b/src/features/run/targets.ts index 8340a425..f39de78d 100644 --- a/src/features/run/targets.ts +++ b/src/features/run/targets.ts @@ -319,12 +319,14 @@ export function targetConfig( relPath: string, configs: RunConfig[], content?: string, + /** The test configuration to narrow (one of `testBasesFor`); the closest by default. */ + base?: RunConfig, ): RunConfig | null { if (target.kind === "main") { if (target.className) return applicationConfig({ className: target.className, spring: target.spring }, relPath, configs); return contextConfig(relPath, configs, content); } - const file = testConfigFor(relPath, configs); + const file = testConfigFor(relPath, configs, base); if (!file) return null; if (target.className && (file.kind === "maven" || file.kind === "gradle")) return jvmTestConfig(file, target.className, target.kind === "test" ? target.path[target.path.length - 1] : undefined); diff --git a/src/features/run/testing/comparison.test.ts b/src/features/run/testing/comparison.test.ts new file mode 100644 index 00000000..c5d38102 --- /dev/null +++ b/src/features/run/testing/comparison.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { parseComparison } from "./comparison"; + +describe("parseComparison", () => { + it("JUnit / opentest4j", () => { + expect(parseComparison(["org.opentest4j.AssertionFailedError: expected: <3> but was: <4>"])).toEqual({ + expected: "3", + actual: "4", + }); + expect(parseComparison(['expected: "a b"', ' but was: "a c"'])).toEqual({ expected: '"a b"', actual: '"a c"' }); + }); + + it("kotlin.test", () => { + expect(parseComparison(["java.lang.AssertionError: Expected <1>, actual <2>."])).toEqual({ + expected: "1", + actual: "2", + }); + }); + + it("jest / vitest", () => { + expect( + parseComparison([ + " expect(received).toBe(expected) // Object.is equality", + "", + " Expected: 3", + " Received: 4", + ]), + ).toEqual({ expected: "3", actual: "4" }); + }); + + it("rust assert_eq! keeps left / right", () => { + expect(parseComparison(["assertion `left == right` failed", " left: 3", " right: 4"])).toEqual({ + expected: "3", + actual: "4", + labels: { expected: "Left", actual: "Right" }, + }); + }); + + it("go testify and pytest", () => { + expect( + parseComparison(["\tError: \tNot equal: ", "\t \texpected: 3", "\t \tactual : 4"]), + ).toEqual({ + expected: "3", + actual: "4", + }); + expect(parseComparison(["> assert add(1, 2) == 4", "E assert 3 == 4"])).toEqual({ + expected: "4", + actual: "3", + }); + }); + + it("no comparison in other failures", () => { + expect(parseComparison(["java.lang.IllegalStateException: boom", "\tat com.acme.A.b(A.java:3)"])).toBeNull(); + }); +}); diff --git a/src/features/run/testing/comparison.ts b/src/features/run/testing/comparison.ts new file mode 100644 index 00000000..20b4fc26 --- /dev/null +++ b/src/features/run/testing/comparison.ts @@ -0,0 +1,54 @@ +/** + * Expected / actual values of an assertion failure, read from a test's captured + * output, for the test tree's "Show Diff" (as in IDEA's comparison failure diff). + */ + +export interface Comparison { + expected: string; + actual: string; + /** Side labels when the framework doesn't say which side is expected (Rust `left` / `right`). */ + labels?: { expected: string; actual: string }; +} + +/** Strips the quotes / angle brackets frameworks wrap values in (`<1>`, `"a"` stays as written). */ +const unwrap = (s: string) => s.trim().replace(/^<([\s\S]*)>$/, "$1"); + +export function parseComparison(output: readonly string[]): Comparison | null { + // Output lines reach the parsers with ANSI escapes already stripped. + const lines = output; + const text = lines.join("\n"); + let m: RegExpExecArray | null; + // JUnit / opentest4j / AssertJ / Hamcrest-style: `expected: but was: `. + if ((m = /expected:\s*(<[\s\S]*?>|\S[^\n]*?)\s*but was:\s*(<[\s\S]*?>|[^\n]*)/.exec(text))) + return { expected: unwrap(m[1]!), actual: unwrap(m[2]!) }; + // kotlin.test: `Expected , actual .` + if ((m = /Expected <([\s\S]*?)>, actual <([\s\S]*?)>/.exec(text))) return { expected: m[1]!, actual: m[2]! }; + // Rust `assert_eq!`: ` left: x` / ` right: y` (the macro doesn't say which one is expected). + const left = lines.findIndex((l) => /^\s*left:\s/.test(l)); + if (left >= 0 && /^\s*right:\s/.test(lines[left + 1] ?? "")) + return { + expected: lines[left]!.replace(/^\s*left:\s*/, ""), + actual: lines[left + 1]!.replace(/^\s*right:\s*/, ""), + labels: { expected: "Left", actual: "Right" }, + }; + // Jest / Vitest: `Expected: x` then `Received: y`. + const exp = lines.findIndex((l) => /^\s*Expected:\s/.test(l)); + const rec = exp >= 0 ? lines.findIndex((l, i) => i > exp && /^\s*Received:\s/.test(l)) : -1; + if (exp >= 0 && rec >= 0) + return { + expected: lines[exp]!.replace(/^\s*Expected:\s*/, ""), + actual: lines[rec]!.replace(/^\s*Received:\s*/, ""), + }; + // Go testify: `expected: x` then `actual : y`. + const gexp = lines.findIndex((l) => /^\s*(?:Error:\s+Not equal:\s*)?expected:\s/.test(l)); + const gact = gexp >= 0 ? lines.findIndex((l, i) => i > gexp && /^\s*actual\s*:\s/.test(l)) : -1; + if (gexp >= 0 && gact >= 0) + return { + expected: lines[gexp]!.replace(/^.*?expected:\s*/, ""), + actual: lines[gact]!.replace(/^\s*actual\s*:\s*/, ""), + }; + // pytest: `assert actual == expected`. + if ((m = /^(?:E\s+)?(?:AssertionError:\s*)?assert\s+(.+?)\s+==\s+(.+)$/m.exec(text))) + return { expected: m[2]!.trim(), actual: m[1]!.trim() }; + return null; +} diff --git a/src/features/run/testing/jvm.ts b/src/features/run/testing/jvm.ts index e4212b0a..cec56175 100644 --- a/src/features/run/testing/jvm.ts +++ b/src/features/run/testing/jvm.ts @@ -1,9 +1,11 @@ /** Gradle test logging (`Class > method() PASSED`) and Maven Surefire reports. */ +import { jvmFrameTarget } from "../links"; import { parseDuration, type TestEvent, type TestOutputParser } from "./types"; const GRADLE_RE = /^(\S[^>]*?(?:\s>\s[^>]+?)+)\s+(PASSED|FAILED|SKIPPED)(?:\s+\(([\d.]+\s*m?s)\))?$/; const GRADLE_SUMMARY_RE = /^(\d+) tests? completed(?:, (\d+) failed)?(?:, (\d+) skipped)?/; -const FRAME_RE = /\(([\w$.-]+\.(?:java|kt|kts|scala|groovy)):(\d+)\)|\bat ([\w$.-]+\.(?:java|kt|kts|scala|groovy)):(\d+)/; +const FRAME_RE = + /(?:([\w$]+(?:\.[\w$]+)*)\.[\w$<>-]+)?\(([\w$.-]+\.(?:java|kt|kts|scala|groovy)):(\d+)\)|\bat ([\w$.-]+\.(?:java|kt|kts|scala|groovy)):(\d+)/; // [ERROR] com.acme.OrderTest.shouldX -- Time elapsed: 0.01 s <<< FAILURE! (Surefire 3) // shouldX(com.acme.OrderTest) Time elapsed: 0.01 sec <<< FAILURE! (Surefire 2) const SUREFIRE3_RE = /^(?:\[\w+\]\s+)?([\w$.]+)\.([\w$]+)(?:\(\))?\s+--\s+Time elapsed: ([\d.]+ s(?:ec)?)\s+<<<\s+(FAILURE|ERROR)!/; @@ -83,7 +85,9 @@ export function jvmParser(): TestOutputParser { events.push({ type: "location", path: failure, - location: { path: (f[1] ?? f[3])!, line: Number(f[2] ?? f[4]), resolve: "name" }, + location: f[2] + ? jvmFrameTarget(f[1], f[2], Number(f[3])) + : { path: f[4]!, line: Number(f[5]), resolve: "name" }, }); } } diff --git a/src/features/run/testing/parsers.test.ts b/src/features/run/testing/parsers.test.ts index 964c8b71..d5cd001f 100644 --- a/src/features/run/testing/parsers.test.ts +++ b/src/features/run/testing/parsers.test.ts @@ -390,10 +390,12 @@ org.opentest4j.AssertionFailedError: expected: <3> but was: <2> `, ); expect(nodeStatus(r.get("com.acme.OrderTest")!)).toBe("failed"); + // Resolved by the frame's class (package directory + file), not by a bare file name. expect(leaf(r, "com.acme.OrderTest", "shouldTotal")?.location).toEqual({ - path: "OrderTest.java", + path: "com/acme/OrderTest.java", line: 31, resolve: "name", + className: "com.acme.OrderTest", }); expect(r.get("com.acme.UserTest")?.own).toBe("passed"); expect(r.summary).toMatchObject({ passed: 2, failed: 1 }); diff --git a/src/features/run/testing/rerun.test.ts b/src/features/run/testing/rerun.test.ts index 0bbf3b51..93a9f85c 100644 --- a/src/features/run/testing/rerun.test.ts +++ b/src/features/run/testing/rerun.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import type { RunConfig } from "@/platform/ipc"; import { TestResults } from "./results"; -import { failedTestPaths, rerunFailedConfig } from "./rerun"; +import { failedTestPaths, leafTestPaths, rerunFailedConfig, testsConfig } from "./rerun"; const cfg = (kind: string, command: string, args: string[]): RunConfig => ({ id: `${kind}:test`, @@ -29,6 +29,38 @@ describe("failedTestPaths", () => { }); }); +describe("tests selected in the test tree", () => { + it("expands suites to their tests; a suite without reported tests stands for itself", () => { + const r = new TestResults(); + r.apply([ + { type: "result", path: ["com.acme.ATest", "one()"], status: "passed" }, + { type: "result", path: ["com.acme.ATest", "two()"], status: "failed" }, + { type: "result", path: ["com.acme.BTest"], status: "passed" }, + ]); + const nodes = [ + r.get("com.acme.ATest")!, + r.get(["com.acme.ATest", "one()"].join("\u0000"))!, + r.get("com.acme.BTest")!, + ]; + expect(leafTestPaths(nodes)).toEqual([ + ["com.acme.ATest", "one()"], + ["com.acme.ATest", "two()"], + ["com.acme.BTest"], + ]); + const c = testsConfig(cfg("gradle", "./gradlew", ["test"]), leafTestPaths(nodes), "3 tests")!; + expect(c.name).toBe("3 tests"); + expect(c.args).toEqual([ + "test", + "--tests", + "com.acme.ATest.one", + "--tests", + "com.acme.ATest.two", + "--tests", + "com.acme.BTest", + ]); + }); +}); + describe("rerunFailedConfig", () => { it("cargo: exact names after --", () => { const c = rerunFailedConfig(cfg("cargo", "cargo", ["test", "--workspace"]), [ diff --git a/src/features/run/testing/rerun.ts b/src/features/run/testing/rerun.ts index c7b783b5..93de0703 100644 --- a/src/features/run/testing/rerun.ts +++ b/src/features/run/testing/rerun.ts @@ -113,8 +113,29 @@ function jvmArgs(config: RunConfig, paths: string[][]): string[] | null { return [...dropFlag(config.args, ["--tests"]), ...filters.flatMap((f) => ["--tests", f])]; } +/** Leaf test paths under the given nodes (a node without reported children stands for itself). */ +export function leafTestPaths(nodes: readonly TestNode[]): string[][] { + const out: string[][] = []; + const seen = new Set(); + const visit = (n: TestNode) => { + if (n.children.length) return n.children.forEach(visit); + const key = n.path.join("\u0000"); + if (seen.has(key)) return; + seen.add(key); + out.push(n.path); + }; + nodes.forEach(visit); + return out; +} + /** Config running only `paths` (failed test paths from the results tree), or null when unsupported. */ export function rerunFailedConfig(config: RunConfig, paths: string[][]): RunConfig | null { + const baseName = config.name.replace(/^Failed: /, ""); + return testsConfig(config, paths, `Failed: ${baseName}`); +} + +/** Config running only the tests at `paths` (test tree paths), named `name`; null when unsupported. */ +export function testsConfig(config: RunConfig, paths: string[][], name: string): RunConfig | null { if (!paths.length) return null; let args: string[] | null; switch (frameworkFor(config)) { @@ -137,6 +158,5 @@ export function rerunFailedConfig(config: RunConfig, paths: string[][]): RunConf args = null; } if (!args) return null; - const baseName = config.name.replace(/^Failed: /, ""); - return { ...config, args, name: `Failed: ${baseName}`, detected: true, env: { ...config.env } }; + return { ...config, args, name, detected: true, env: { ...config.env } }; } diff --git a/src/features/run/testing/states.test.ts b/src/features/run/testing/states.test.ts new file mode 100644 index 00000000..dca6da2e --- /dev/null +++ b/src/features/run/testing/states.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TestResults } from "./results"; +import { clearTestStates, onTestStatesChanged, recordTestStates, testState } from "./states"; + +function run(events: Parameters[0]) { + const r = new TestResults(); + r.apply(events); + r.finish(0); + recordTestStates(r); +} + +describe("test states for the run gutter", () => { + afterEach(() => clearTestStates()); + + it("matches Rust tests by module path under any crate suite", () => { + run([ + { type: "result", path: ["src/lib.rs", "tests", "adds"], status: "passed" }, + { type: "result", path: ["src/lib.rs", "tests", "subs"], status: "failed" }, + ]); + expect(testState({ path: ["tests", "adds"] })).toBe("passed"); + expect(testState({ path: ["tests", "subs"] })).toBe("failed"); + // The suite (`mod tests`) failed because one of its tests did. + expect(testState({ path: ["tests"] })).toBe("failed"); + expect(testState({ path: ["tests", "other"] })).toBeNull(); + }); + + it("matches JVM results named by qualified class and `method()`", () => { + run([ + { type: "result", path: ["com.acme.OrderTest", "lists()"], status: "passed" }, + { type: "result", path: ["com.acme.OrderTest", "creates()"], status: "failed" }, + ]); + expect(testState({ path: ["OrderTest", "lists"] })).toBe("passed"); + expect(testState({ path: ["OrderTest", "creates"] })).toBe("failed"); + expect(testState({ path: ["OrderTest"] })).toBe("failed"); + }); + + it("checks the file for results rooted at it, and any parameter failing fails the test", () => { + run([ + { type: "result", path: ["tests/test_math.py", "test_add[1]"], status: "passed" }, + { type: "result", path: ["tests/test_math.py", "test_add[2]"], status: "failed" }, + { type: "result", path: ["src/math.test.ts", "math", "adds"], status: "passed" }, + ]); + expect(testState({ path: ["test_add"], file: "/work/app/tests/test_math.py" })).toBe("failed"); + expect(testState({ path: ["test_add"], file: "/work/app/other/test_math2.py" })).toBeNull(); + expect(testState({ path: ["math", "adds"], file: "/work/app/src/math.test.ts" })).toBe("passed"); + }); + + it("newer runs win and listeners hear about changes", () => { + const listener = vi.fn(); + const off = onTestStatesChanged(listener); + run([{ type: "result", path: ["pkg", "TestA"], status: "failed" }]); + run([{ type: "result", path: ["pkg", "TestA"], status: "passed" }]); + expect(testState({ path: ["TestA"] })).toBe("passed"); + expect(listener).toHaveBeenCalledTimes(2); + // Unchanged results don't redraw the gutters. + run([{ type: "result", path: ["pkg", "TestA"], status: "passed" }]); + expect(listener).toHaveBeenCalledTimes(2); + off(); + }); +}); diff --git a/src/features/run/testing/states.ts b/src/features/run/testing/states.ts new file mode 100644 index 00000000..bc23a1f9 --- /dev/null +++ b/src/features/run/testing/states.ts @@ -0,0 +1,101 @@ +/** + * Last known result per test, across runs, for the gutter ▶ icons: like IDEA, a + * test (or suite) that failed last time shows a red run icon, a passed one green. + * Results are matched to gutter targets by their test path (the trailing name + * parts), plus the file for frameworks whose results are rooted at the file. + */ +import { nodeStatus, type TestNode, type TestResults } from "./results"; +import type { TestStatus } from "./types"; + +export type TestState = "passed" | "failed"; + +interface Entry { + path: string[]; + state: TestState; +} + +const SEP = "\u0000"; +const entries = new Map(); +const listeners = new Set<() => void>(); +let version = 0; + +export function testStatesVersion(): number { + return version; +} + +export function onTestStatesChanged(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +function toState(s: TestStatus | null): TestState | null { + return s === "passed" || s === "failed" ? s : null; +} + +/** Records the outcome of every finished test and suite of a run (newer runs win). */ +export function recordTestStates(results: Pick) { + let changed = false; + const visit = (n: TestNode) => { + const state = toState(nodeStatus(n)); + if (state) { + const key = n.path.join(SEP); + if (entries.get(key)?.state !== state) changed = true; + entries.set(key, { path: n.path, state }); + } + n.children.forEach(visit); + }; + results.roots.forEach(visit); + if (!changed) return; + version++; + for (const l of listeners) l(); +} + +export function clearTestStates() { + if (!entries.size) return; + entries.clear(); + version++; + for (const l of listeners) l(); +} + +/** `name()`, `name(int)`, `test_x[param]` → `name` / `test_x`. */ +const norm = (s: string) => s.replace(/\s*(\(.*\)|\[.*\])$/, "").trim(); + +function segmentMatches(result: string, target: string): boolean { + const a = norm(result); + const b = norm(target); + // JVM results name the class by its fully qualified name, gutter targets by the simple one. + return a === b || a.endsWith(`.${b}`) || b.endsWith(`.${a}`); +} + +const cleanPath = (p: string) => p.replace(/\\/g, "/").replace(/^\.\//, ""); + +function sameFile(resultRoot: string, relPath: string): boolean { + const a = cleanPath(resultRoot); + const b = cleanPath(relPath); + return a === b || b.endsWith(`/${a}`) || a.endsWith(`/${b}`); +} + +export interface StateQuery { + /** Test path of the gutter target (describe chain, module path, `[Class, method]`…). */ + path: string[]; + /** Project-relative path of the file, checked when results are rooted at their file. */ + file?: string; +} + +/** + * Last state of a gutter target: failed when any matching result failed (a + * parameterised test failed for one of its parameters), else passed. + */ +export function testState({ path, file }: StateQuery): TestState | null { + if (!path.length) return null; + let passed = false; + for (const e of entries.values()) { + if (e.path.length < path.length) continue; + const offset = e.path.length - path.length; + if (!path.every((p, i) => segmentMatches(e.path[offset + i]!, p))) continue; + if (file !== undefined && (offset === 0 || !sameFile(e.path[0]!, file))) continue; + if (e.state === "failed") return "failed"; + passed = true; + } + return passed ? "passed" : null; +} diff --git a/src/features/run/testing/types.ts b/src/features/run/testing/types.ts index 84b1dad0..60228a2b 100644 --- a/src/features/run/testing/types.ts +++ b/src/features/run/testing/types.ts @@ -10,8 +10,10 @@ export interface TestLocation { /** 1-based, as printed. */ line?: number; col?: number; - /** `name`: a bare file name to look up in the project (JVM / Go frames). */ + /** `name`: a file name to look up in the project (JVM / Go frames). */ resolve: "path" | "name"; + /** JVM frames: the class's binary name; `path` is then the file under its package directory. */ + className?: string; } export type TestEvent = diff --git a/src/features/search/__tests__/bookmarkPopups.test.tsx b/src/features/search/__tests__/bookmarkPopups.test.tsx new file mode 100644 index 00000000..7ba36286 --- /dev/null +++ b/src/features/search/__tests__/bookmarkPopups.test.tsx @@ -0,0 +1,88 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { BookmarkPopupsOverlay, bookmarkRows } from "../bookmarks/BookmarkPopups"; +import { bookmarkGutter } from "../bookmarks/gutter"; +import { loadBookmarks, updateBookmarks, useBookmarkPopups, useBookmarks, type Bookmark } from "../bookmarks/store"; + +beforeEach(() => { + localStorage.clear(); + loadBookmarks("/p"); +}); + +afterEach(() => { + act(() => useBookmarkPopups.setState({ chooser: null, list: false, editing: null })); +}); + +const set = (items: Bookmark[]) => act(() => updateBookmarks(() => items)); + +describe("mnemonic chooser", () => { + it("a typed letter assigns that mnemonic (with the description) and closes", () => { + set([{ path: "/p/b", line: 0, text: "b", created: 1, mnemonic: "K" }]); + render(); + act(() => useBookmarkPopups.setState({ chooser: { path: "/p/a", line: 2, text: "fun a()" } })); + expect(screen.getByText("Add Mnemonic Bookmark")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "K" }).className).toMatch(/keyAssigned/); + fireEvent.change(screen.getByLabelText("Bookmark description"), { target: { value: "entry" } }); + fireEvent.keyDown(screen.getByRole("button", { name: "1" }), { key: "k" }); + expect(useBookmarkPopups.getState().chooser).toBeNull(); + const items = useBookmarks.getState().items; + expect(items.find((b) => b.path === "/p/a")).toMatchObject({ line: 2, mnemonic: "K", description: "entry" }); + expect(items.find((b) => b.path === "/p/b")?.mnemonic).toBeUndefined(); + }); +}); + +describe("Bookmarks popup", () => { + it("groups by file; Delete removes and F2 edits the description", () => { + set([ + { path: "/p/a", line: 1, text: "one", created: 1 }, + { path: "/p/a", line: 5, text: "five", created: 2, mnemonic: "3" }, + { path: "/p/b", line: 0, text: "zero", created: 3 }, + ]); + expect(bookmarkRows(useBookmarks.getState().items).map((r) => r.kind)).toEqual(["file", "mark", "mark", "file", "mark"]); + render(); + act(() => useBookmarkPopups.setState({ list: true })); + const list = screen.getByRole("listbox", { name: "Bookmarks" }); + expect(screen.getByText("five")).toBeInTheDocument(); + fireEvent.keyDown(list, { key: "Delete" }); + expect(useBookmarks.getState().items.map((b) => b.line)).toEqual([5, 0]); + fireEvent.keyDown(list, { key: "F2" }); + expect(useBookmarkPopups.getState().editing).toEqual({ path: "/p/a", line: 5 }); + const input = screen.getByLabelText("Description"); + fireEvent.change(input, { target: { value: "main" } }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(useBookmarks.getState().items[0]!.description).toBe("main"); + }); +}); + +describe("bookmark gutter", () => { + it("⌘/Ctrl-click in the gutter toggles a bookmark before other gutter handlers see it", () => { + const toggle = vi.fn(); + const other = vi.fn(); + const view = new EditorView({ + parent: document.body, + state: EditorState.create({ doc: "a\nb\n", extensions: bookmarkGutter("/p/a", toggle) }), + }); + view.dom.addEventListener("mousedown", other); + const gutter = view.dom.querySelector(".cm-gutters")!; + gutter.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0, metaKey: true, ctrlKey: true })); + expect(toggle).toHaveBeenCalledWith(0); + expect(other).not.toHaveBeenCalled(); + gutter.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 })); + expect(toggle).toHaveBeenCalledTimes(1); + expect(other).toHaveBeenCalledTimes(1); + view.destroy(); + }); + + it("shows the mnemonic instead of the bookmark icon", async () => { + set([{ path: "/p/a", line: 1, text: "b", created: 1, mnemonic: "Q" }]); + const view = new EditorView({ + parent: document.body, + state: EditorState.create({ doc: "a\nb\n", extensions: bookmarkGutter("/p/a", () => {}) }), + }); + await act(() => new Promise((r) => setTimeout(r, 0))); + expect(view.dom.querySelector(".cm-openidea-bookmarkMnemonic")?.textContent).toBe("Q"); + view.destroy(); + }); +}); diff --git a/src/features/search/__tests__/bookmarks.test.ts b/src/features/search/__tests__/bookmarks.test.ts index 7b5c1d47..55ee91d4 100644 --- a/src/features/search/__tests__/bookmarks.test.ts +++ b/src/features/search/__tests__/bookmarks.test.ts @@ -1,7 +1,13 @@ import { beforeEach, describe, expect, it } from "vitest"; import { + assignMnemonic, + bookmarkByMnemonic, + bookmarkLabel, + describeBookmark, linesOf, loadBookmarks, + marksOf, + toggleMnemonic, renameBookmarks, setFileLines, toggleBookmark, @@ -64,3 +70,44 @@ describe("bookmark store", () => { expect(useBookmarks.getState().items.map((b) => b.line)).toEqual([2]); }); }); + +describe("mnemonic bookmarks", () => { + it("assigns a mnemonic, moving it from another bookmark", () => { + let list: Bookmark[] = []; + list = assignMnemonic(list, "/p/a", 1, "one", "A", undefined, 1); + list = assignMnemonic(list, "/p/b", 2, "two", "A", "second", 2); + expect(bookmarkByMnemonic(list, "A")).toMatchObject({ path: "/p/b", line: 2, description: "second" }); + expect(list.find((b) => b.path === "/p/a")?.mnemonic).toBeUndefined(); + list = assignMnemonic(list, "/p/b", 2, "two", null, ""); + expect(list.find((b) => b.path === "/p/b")).toEqual({ path: "/p/b", line: 2, text: "two", created: 2 }); + }); + + it("⌃⇧digit toggles a numbered bookmark", () => { + let list = toggleMnemonic([], "/p/a", 3, "x", "1"); + expect(marksOf(list, "/p/a")).toEqual([{ line: 3, mnemonic: "1" }]); + list = toggleMnemonic(list, "/p/a", 5, "y", "1"); + expect(marksOf(list, "/p/a")).toEqual([ + { line: 3, mnemonic: null }, + { line: 5, mnemonic: "1" }, + ]); + list = toggleMnemonic(list, "/p/a", 5, "y", "1"); + expect(linesOf(list, "/p/a")).toEqual([3]); + }); + + it("keeps mnemonics and descriptions when lines move or storage reloads", () => { + const list: Bookmark[] = [{ path: "/p/a", line: 1, text: "a", created: 1, mnemonic: "7", description: "note" }]; + expect(setFileLines(list, "/p/a", [{ line: 4, text: "a" }])[0]).toMatchObject({ line: 4, mnemonic: "7", description: "note" }); + localStorage.setItem("openidea.bookmarks.v1:/m", JSON.stringify([...list, { path: "/p/b", line: 0, mnemonic: "ab" }])); + loadBookmarks("/m"); + expect(useBookmarks.getState().items.map((b) => b.mnemonic)).toEqual(["7", undefined]); + }); + + it("F3 on a selection uses it as the description; Rename edits it", () => { + let list = toggleBookmark([], "/p/a", 0, "let x = 1;", 1, " important "); + expect(bookmarkLabel(list[0]!)).toBe("important"); + list = describeBookmark(list, "/p/a", 0, "renamed"); + expect(bookmarkLabel(list[0]!)).toBe("renamed"); + list = describeBookmark(list, "/p/a", 0, " "); + expect(bookmarkLabel(list[0]!)).toBe("let x = 1;"); + }); +}); diff --git a/src/features/search/__tests__/codePreview.test.tsx b/src/features/search/__tests__/codePreview.test.tsx new file mode 100644 index 00000000..63988197 --- /dev/null +++ b/src/features/search/__tests__/codePreview.test.tsx @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from "vitest"; +import { act, render } from "@testing-library/react"; +import { Text } from "@codemirror/state"; + +vi.mock("../find/preview", () => ({ + loadPreviewText: async () => "fun a() {}\nval order = 1\norder()\n", + clearPreviewCache: () => {}, +})); + +const { CodePreview, previewDecorations } = await import("../find/parts"); + +describe("CodePreview", () => { + it("marks matches (the current line's as current) and the current line", () => { + const doc = Text.of(["ab", "order order", "x"]); + const set = previewDecorations(doc, 1, [ + { line: 1, ranges: [[0, 5], [6, 11]] }, + { line: 2, ranges: [[0, 9]] }, + { line: 7, ranges: [[0, 1]] }, + ]); + const out: string[] = []; + set.between(0, doc.length, (from, to, d) => { + out.push(`${from}-${to}:${(d.spec as { class: string }).class}`); + }); + expect(out).toEqual([ + "3-3:cm-activeLine", + "3-8:cm-openidea-findMatch cm-openidea-findMatch-current", + "9-14:cm-openidea-findMatch cm-openidea-findMatch-current", + "15-16:cm-openidea-findMatch", + ]); + }); + + it("shows the file read-only in an editor view with the matches highlighted", async () => { + const { container } = render( + , + ); + await act(() => new Promise((r) => setTimeout(r, 150))); + expect(container.querySelector(".cm-content")?.textContent).toContain("val order = 1"); + expect(container.querySelector(".cm-content")?.getAttribute("contenteditable")).not.toBe("true"); + expect(container.querySelector(".cm-openidea-findMatch")?.textContent).toBe("order"); + }); +}); diff --git a/src/features/search/__tests__/everywhereModel.test.ts b/src/features/search/__tests__/everywhereModel.test.ts index 6da17cd3..d52d1577 100644 --- a/src/features/search/__tests__/everywhereModel.test.ts +++ b/src/features/search/__tests__/everywhereModel.test.ts @@ -246,3 +246,29 @@ describe("parseCommand", () => { expect(parseCommand("upg")).toBeNull(); }); }); + +describe("Search Everywhere initial text", () => { + it("each tab starts with its own last query; closing remembers the text", async () => { + const { hideSearchEverywhere, selectSearchEverywhereTab, showSearchEverywhere, useSearchEverywhere } = await import( + "../everywhere/store" + ); + localStorage.clear(); + showSearchEverywhere("files"); + useSearchEverywhere.setState({ query: "Order" }); + hideSearchEverywhere(); + showSearchEverywhere("classes"); + expect(useSearchEverywhere.getState().query).toBe(""); + useSearchEverywhere.setState({ query: "Pizza" }); + selectSearchEverywhereTab("symbols"); + hideSearchEverywhere(); + showSearchEverywhere("files"); + expect(useSearchEverywhere.getState().query).toBe("Order"); + hideSearchEverywhere(); + showSearchEverywhere("symbols", { query: "sel" }); + expect(useSearchEverywhere.getState().query).toBe("sel"); + hideSearchEverywhere(); + showSearchEverywhere("classes"); + expect(useSearchEverywhere.getState().query).toBe(""); + hideSearchEverywhere(); + }); +}); diff --git a/src/features/search/__tests__/fileStructure.test.tsx b/src/features/search/__tests__/fileStructure.test.tsx new file mode 100644 index 00000000..c5cd6892 --- /dev/null +++ b/src/features/search/__tests__/fileStructure.test.tsx @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import type { OutlineItem } from "@/platform/ipc"; + +vi.mock("../structure/store", async (orig) => ({ + ...(await orig()), + useLiveOutline: () => undefined, +})); + +const { FileStructureOverlay, useFileStructurePopup } = await import("../structure/StructureView"); +const { useOutline, useStructureOptions } = await import("../structure/store"); + +const item = (name: string, kind: OutlineItem["kind"], line: number, children: OutlineItem[] = []): OutlineItem => ({ + name, + detail: null, + kind, + range: { start: { line, col: 0 }, end: { line: line + 1, col: 1 } }, + selection_range: { start: { line, col: 4 }, end: { line, col: 4 + name.length } }, + children, +}); + +function open() { + useOutline.setState({ + path: "/p/Order.kt", + doc: 1, + loading: false, + error: null, + items: [item("OrderController", "class", 0, [item("list", "method", 2), item("create", "method", 8)]), item("helper", "function", 22)], + }); + useStructureOptions.setState({ narrowDown: true }); + render(); + act(() => useFileStructurePopup.setState({ open: true })); + return screen.getByLabelText("Filter symbols"); +} + +const rows = () => screen.getAllByRole("treeitem").map((r) => r.textContent); + +afterEach(() => act(() => useFileStructurePopup.setState({ open: false }))); + +describe("File Structure popup", () => { + it("shows the outline as an expanded tree; ←/→ collapse and expand", () => { + const input = open(); + expect(rows()).toEqual(["OrderController", "list", "create", "helper"]); + // The first symbol is preselected; ← collapses it, → expands it again. + fireEvent.keyDown(input, { key: "ArrowLeft" }); + expect(rows()).toEqual(["OrderController", "helper"]); + fireEvent.keyDown(input, { key: "ArrowRight" }); + expect(rows()).toEqual(["OrderController", "list", "create", "helper"]); + }); + + it("narrows down on typing keeping ancestors, or only highlights when that is off", () => { + const input = open(); + fireEvent.change(input, { target: { value: "creat" } }); + expect(rows()).toEqual(["OrderController", "create"]); + fireEvent.click(screen.getByLabelText("Narrow down on typing")); + expect(useStructureOptions.getState().narrowDown).toBe(false); + expect(rows()).toEqual(["OrderController", "list", "create", "helper"]); + }); + + it("Esc clears the filter first, then closes", () => { + const input = open(); + fireEvent.change(input, { target: { value: "help" } }); + fireEvent.keyDown(input, { key: "Escape" }); + expect((input as HTMLInputElement).value).toBe(""); + expect(useFileStructurePopup.getState().open).toBe(true); + fireEvent.keyDown(input, { key: "Escape" }); + expect(useFileStructurePopup.getState().open).toBe(false); + }); +}); diff --git a/src/features/search/__tests__/findPopup.test.tsx b/src/features/search/__tests__/findPopup.test.tsx new file mode 100644 index 00000000..bdcf6711 --- /dev/null +++ b/src/features/search/__tests__/findPopup.test.tsx @@ -0,0 +1,153 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import type { ReplaceRequest } from "../find/replaceFlow"; + +const replaced: ReplaceRequest[] = []; +const confirms: unknown[] = []; + +vi.mock("../find/windowStore", async (orig) => ({ + ...(await orig()), + ipcFindDeps: () => ({ + validate: async () => {}, + start: async (_id: number, _q: unknown, onFile: (f: unknown) => void) => { + onFile({ + path: "/p/a.kt", + relPath: "a.kt", + matches: [ + { line: 1, text: "val order = 1", ranges: [[4, 9]], textOffset: 0 }, + { line: 4, text: "order()", ranges: [[0, 5]], textOffset: 0 }, + ], + }); + onFile({ path: "/p/b.kt", relPath: "b.kt", matches: [{ line: 0, text: "// order", ranges: [[3, 8]], textOffset: 0 }] }); + return null; + }, + cancel: () => {}, + nextRequestId: () => 1, + }), +})); +vi.mock("../find/replaceFlow", async (orig) => ({ + ...(await orig()), + startReplace: (r: ReplaceRequest) => { + replaced.push(r); + const replacements = r.targets.reduce((n, t) => n + (t.only?.length ?? 0), 0); + return { + result: Promise.resolve({ files: r.targets.length, replacements, failed: 0, cancelled: false, elapsedMs: 0 }), + cancel: () => {}, + }; + }, +})); +vi.mock("@/workbench/messages", async (orig) => ({ + ...(await orig()), + confirm: async (opts: unknown) => { + confirms.push(opts); + return true; + }, +})); + +const { FindInFilesOverlay } = await import("../find/FindInFiles"); +const { findHistory, hideFindPopup, rememberFindString, runFindPopupCommand, showFindPopup, useFindPopup } = + await import("../find/store"); + +const settle = () => act(() => new Promise((r) => setTimeout(r, 250))); +const selectedRows = () => + within(screen.getByRole("listbox", { name: "Matches" })) + .getAllByRole("option") + .filter((o) => o.getAttribute("aria-selected") === "true"); +const field = () => screen.getByLabelText("Text to find"); + +beforeEach(() => { + replaced.length = 0; + confirms.length = 0; + localStorage.clear(); + useFindPopup.setState({ pinned: false, maskEnabled: false, preserveCase: false, context: "any", replacement: "" }); +}); + +afterEach(() => { + act(() => hideFindPopup()); +}); + +describe("Find in Files popup", () => { + it("⇧↓ extends the selection; ↵ in Replace mode replaces the selected rows", async () => { + render(); + act(() => showFindPopup(true, { query: "order" })); + await settle(); + fireEvent.change(screen.getByLabelText("Replace with"), { target: { value: "item" } }); + fireEvent.keyDown(field(), { key: "ArrowDown", shiftKey: true }); + expect(selectedRows()).toHaveLength(2); + expect(screen.getByRole("button", { name: "Replace 2 Occurrences" })).toBeEnabled(); + fireEvent.keyDown(field(), { key: "Enter" }); + await settle(); + expect(replaced).toHaveLength(1); + expect(replaced[0]!.targets).toEqual([{ path: "/p/a.kt", only: [{ line: 1, col: 4 }, { line: 4, col: 0 }] }]); + // The next row still to replace is selected. + expect(selectedRows().map((o) => o.textContent)).toEqual([expect.stringContaining("b.kt")]); + }); + + it("⌘-click toggles rows into the selection", async () => { + render(); + act(() => showFindPopup(false, { query: "order" })); + await settle(); + const options = within(screen.getByRole("listbox", { name: "Matches" })).getAllByRole("option"); + fireEvent.click(options[2]!, { metaKey: true, ctrlKey: true, detail: 1 }); + expect(selectedRows()).toHaveLength(2); + fireEvent.click(options[1]!, { detail: 1 }); + expect(selectedRows()).toHaveLength(1); + }); + + it("⇧⌥↵ replaces all, asking first only when several files are affected", async () => { + render(); + act(() => showFindPopup(true, { query: "order" })); + await settle(); + fireEvent.keyDown(field(), { key: "Enter", shiftKey: true, altKey: true }); + await settle(); + expect(confirms).toHaveLength(1); + expect(replaced[0]!.targets.map((t) => t.path)).toEqual(["/p/a.kt", "/p/b.kt"]); + }); + + it("⌥ mnemonics toggle Preserve Case and the file mask", async () => { + render(); + act(() => showFindPopup(true, { query: "order" })); + await settle(); + fireEvent.keyDown(field(), { key: "é", code: "KeyE", altKey: true }); + expect(useFindPopup.getState().preserveCase).toBe(true); + fireEvent.keyDown(field(), { key: "k", code: "KeyK", altKey: true }); + expect(useFindPopup.getState().maskEnabled).toBe(true); + }); + + it("the pin is a remembered toggle that keeps the popup open when a result is opened", async () => { + render(); + act(() => showFindPopup(false, { query: "order" })); + await settle(); + const pin = screen.getByRole("button", { name: "Pin Window" }); + fireEvent.click(pin); + expect(pin).toHaveAttribute("aria-pressed", "true"); + expect(localStorage.getItem("openidea.search.findPinned.v1")).toBe("true"); + fireEvent.keyDown(field(), { key: "Enter" }); + expect(useFindPopup.getState().open).toBe(true); + expect(screen.getByRole("button", { name: "Open in Find Window" })).toBeInTheDocument(); + }); + + it("find-next keys move the selection and ⌥↓ lists recent searches", async () => { + rememberFindString("find", "older"); + render(); + act(() => showFindPopup(false, { query: "order" })); + await settle(); + act(() => void runFindPopupCommand("nextResult")); + act(() => void runFindPopupCommand("nextResult")); + expect(selectedRows()[0]!.textContent).toContain("b.kt"); + act(() => void runFindPopupCommand("previousResult")); + expect(selectedRows()[0]!.textContent).toContain("a.kt"); + act(() => void runFindPopupCommand("recentSearches")); + expect(await screen.findByText("older")).toBeInTheDocument(); + act(() => hideFindPopup()); + }); + + it("closing remembers the search text", async () => { + render(); + act(() => showFindPopup(false, { query: "order" })); + await settle(); + fireEvent.keyDown(document.activeElement ?? document.body, { key: "Escape" }); + expect(useFindPopup.getState().open).toBe(false); + expect(findHistory("find")[0]).toBe("order"); + }); +}); diff --git a/src/features/search/__tests__/findScopes.test.ts b/src/features/search/__tests__/findScopes.test.ts index 4f3cc9a2..9abf9ce0 100644 --- a/src/features/search/__tests__/findScopes.test.ts +++ b/src/features/search/__tests__/findScopes.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { FILE_SET_SCOPES, buildQuery, describeQuery, useFindPopup } from "../find/store"; +import { FILE_SET_SCOPES, buildQuery, describeQuery, findHistory, rememberFindString, useFindPopup } from "../find/store"; +import { selectRow, selectedRows, type RowSelection } from "../find/rowSelection"; describe("Find in Files named scopes", () => { it("defaults to IDEA's Project Files and maps root-based scopes to backend file sets", () => { @@ -13,3 +14,44 @@ describe("Find in Files named scopes", () => { ); }); }); + +describe("Find in Files search context and history", () => { + it("sends the context filter only when it narrows the search", () => { + const base = useFindPopup.getState(); + expect(buildQuery(base, { kind: "project" }, 0)).not.toHaveProperty("context"); + const q = buildQuery({ ...base, pattern: "x", context: "exceptComments" }, { kind: "project" }, 0); + expect(q.context).toBe("exceptComments"); + expect(describeQuery(q, "/p")).toBe("'x' in Project, except comments"); + }); + + it("remembers recent strings per field, newest first and unique", () => { + localStorage.clear(); + rememberFindString("find", "a"); + rememberFindString("find", "b"); + rememberFindString("find", "a"); + rememberFindString("replace", "r"); + rememberFindString("find", " "); + expect(findHistory("find")).toEqual(["a", "b"]); + expect(findHistory("replace")).toEqual(["r"]); + }); +}); + +describe("results selection", () => { + const rows = ["a", "b", "c", "d"].map((key) => ({ key })); + const keys = (sel: RowSelection) => selectedRows(rows, sel).map((r) => r.key); + + it("⇧ extends from the anchor, ⌘ toggles, a plain click selects one", () => { + let sel = selectRow(rows, { lead: 0, anchor: 0, keys: null }, 1); + sel = selectRow(rows, sel, 3, "range"); + expect(keys(sel)).toEqual(["b", "c", "d"]); + sel = selectRow(rows, sel, 0, "range"); + expect(keys(sel)).toEqual(["a", "b"]); + sel = selectRow(rows, sel, 3, "toggle"); + expect(keys(sel)).toEqual(["a", "b", "d"]); + sel = selectRow(rows, sel, 3, "toggle"); + expect(keys(sel)).toEqual(["a", "b"]); + expect(sel.lead).toBe(1); + sel = selectRow(rows, sel, 2); + expect(keys(sel)).toEqual(["c"]); + }); +}); diff --git a/src/features/search/__tests__/findWindow.test.ts b/src/features/search/__tests__/findWindow.test.ts new file mode 100644 index 00000000..5bd3e02c --- /dev/null +++ b/src/features/search/__tests__/findWindow.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it } from "vitest"; +import type { FileMatches, SearchQuery } from "@/platform/ipc"; +import { buildFindTree } from "../find/FindToolWindow"; +import type { FindDeps } from "../find/session"; +import { disposeFindWindow, openInFindWindow, pinFindTab, useFindWindow } from "../find/windowStore"; + +const q = (pattern: string): SearchQuery => ({ + pattern, + isRegex: false, + caseSensitive: false, + wholeWord: false, + include: [], + exclude: [], + scope: { kind: "project" }, + maxMatches: 100, +}); + +const deps: FindDeps = { + validate: async () => {}, + start: async () => null, + cancel: () => {}, + nextRequestId: () => 1, +}; + +const file = (rel: string): FileMatches => ({ + path: `/p/${rel}`, + relPath: rel, + matches: [{ line: 0, text: "foo", ranges: [[0, 3]], textOffset: 0 }], +}); + +afterEach(disposeFindWindow); + +describe("Find tool window tabs", () => { + it("reuses the selected tab unless it is pinned", () => { + const a = openInFindWindow(q("a"), "a", null, deps); + const b = openInFindWindow(q("b"), "b", null, deps); + expect(useFindWindow.getState().tabs.map((t) => t.title)).toEqual(["b"]); + expect(useFindWindow.getState().active).toBe(b); + expect(b).not.toBe(a); + + pinFindTab(b, true); + const c = openInFindWindow(q("c"), "c", null, deps); + expect(useFindWindow.getState().tabs.map((t) => t.title)).toEqual(["b", "c"]); + expect(useFindWindow.getState().active).toBe(c); + + // Selecting the pinned tab again opens the next search beside it. + useFindWindow.setState({ active: b }); + openInFindWindow(q("d"), "d", null, deps); + expect(useFindWindow.getState().tabs.map((t) => t.title)).toEqual(["b", "c", "d"]); + }); +}); + +describe("buildFindTree", () => { + const files = [file("src/b.ts"), file("a.ts"), file("src/a.ts")]; + + it("lists files flat by path by default", () => { + const roots = buildFindTree(files); + expect(roots.map((n) => n.kind)).toEqual(["file", "file", "file"]); + expect(roots.map((n) => n.key)).toEqual(["f:/p/a.ts", "f:/p/src/a.ts", "f:/p/src/b.ts"]); + }); + + it("groups files by directory on request", () => { + const roots = buildFindTree(files, true); + expect(roots.map((n) => (n.kind === "dir" ? [n.label, n.count] : n.kind))).toEqual([ + ["/", 1], + ["src", 2], + ]); + }); +}); diff --git a/src/features/search/__tests__/outline.test.ts b/src/features/search/__tests__/outline.test.ts index 2c90bc90..0b75bfe6 100644 --- a/src/features/search/__tests__/outline.test.ts +++ b/src/features/search/__tests__/outline.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { OutlineItem } from "@/platform/ipc"; -import { allKeys, bestRow, flattenOutline, keyOutline, sortOutline, symbolPathAt } from "../structure/outline"; +import { allKeys, filterOutline, keyOutline, parentKeys, sortOutline, symbolPathAt, visibleCount } from "../structure/outline"; const item = (name: string, kind: OutlineItem["kind"], from: number, to: number, children: OutlineItem[] = []): OutlineItem => ({ name, @@ -39,13 +39,29 @@ describe("outline helpers", () => { expect(sortOutline(nodes, false)).toBe(nodes); }); - it("filters keeping ancestors of matches", () => { - const rows = flattenOutline(nodes, "creat"); - expect(rows.map((r) => [r.node.item.name, r.depth, r.matched])).toEqual([ - ["OrderController", 0, false], - ["create", 1, true], - ]); - expect(bestRow(rows)).toBe(1); - expect(flattenOutline(nodes, "")).toHaveLength(5); + it("narrows to matches and their ancestors", () => { + const f = filterOutline(nodes, "creat", true); + expect(f.nodes.map((n) => [n.item.name, n.children.map((c) => c.item.name)])).toEqual([["OrderController", ["create"]]]); + expect(f.best).toBe(nodes[0]!.children[1]!.key); + expect(f.matches.has(nodes[0]!.key)).toBe(false); + expect(filterOutline(nodes, "", true).nodes).toBe(nodes); + }); + + it("matches word and camel-hump starts, not scattered letters", () => { + const loose = keyOutline([item("items.reduce() callback", "function", 0, 1), item("subtotal", "method", 2, 3)]); + expect(filterOutline(loose, "sub", true).nodes.map((n) => n.item.name)).toEqual(["subtotal"]); + expect(filterOutline(nodes, "OC", true).nodes.map((n) => n.item.name)).toEqual(["OrderController"]); + }); + + it("only highlights matches when not narrowing down", () => { + const f = filterOutline(nodes, "list", false); + expect(f.nodes).toBe(nodes); + expect(f.order).toEqual([nodes[0]!.children[0]!.key, nodes[0]!.children[2]!.key]); + }); + + it("counts visible rows and maps parents", () => { + expect(visibleCount(nodes, new Set())).toBe(5); + expect(visibleCount(nodes, new Set([nodes[0]!.key]))).toBe(2); + expect(parentKeys(nodes).get(nodes[0]!.children[1]!.key)).toBe(nodes[0]!.key); }); }); diff --git a/src/features/search/__tests__/recent.test.ts b/src/features/search/__tests__/recent.test.ts index c1e1c3d1..70ae491a 100644 --- a/src/features/search/__tests__/recent.test.ts +++ b/src/features/search/__tests__/recent.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vitest"; import type { ToolWindowContribution } from "@/workbench/contributions"; -import { fromNavigationHistory, recordLocation, renameLocations, type RecentLocation } from "../common/locations"; +import { + fromNavigationHistory, + isDismissed, + recordLocation, + renameLocations, + type RecentLocation, +} from "../common/locations"; import { filterLocations, filterRecentFiles, @@ -107,3 +113,13 @@ describe("navigation history snapshot", () => { expect(merged[1]!.snippet).toEqual(["code"]); }); }); + +describe("removed recent locations", () => { + it("hide the place until it is visited again", () => { + const dismissed = [{ path: "/a", line: 10, time: 100 }]; + expect(isDismissed(loc("/a", 12, { time: 90 }), dismissed)).toBe(true); + expect(isDismissed(loc("/a", 30, { time: 90 }), dismissed)).toBe(false); + expect(isDismissed(loc("/b", 10, { time: 90 }), dismissed)).toBe(false); + expect(isDismissed(loc("/a", 10, { time: 101 }), dismissed)).toBe(false); + }); +}); diff --git a/src/features/search/bookmarks/BookmarkPopups.module.css b/src/features/search/bookmarks/BookmarkPopups.module.css new file mode 100644 index 00000000..9dde0190 --- /dev/null +++ b/src/features/search/bookmarks/BookmarkPopups.module.css @@ -0,0 +1,131 @@ +.chooser { + display: flex; + flex-direction: column; + gap: 10px; + padding: 10px 14px 12px; + outline: none; +} +.chooserTitle { + color: var(--fg-emphasis); + font-weight: 500; +} +.grids { + display: flex; + gap: 14px; +} +.digits, +.letters { + display: grid; + gap: 4px; + outline: none; +} +.digits { + grid-template-columns: repeat(3, 24px); + align-content: start; +} +.letters { + grid-template-columns: repeat(7, 24px); +} +.key { + width: 24px; + height: 24px; + padding: 0; + border: 1px solid var(--border-strong); + border-radius: var(--radius-small); + background: var(--bg-elev); + color: var(--fg); + font: inherit; + font-size: var(--fs-small); + cursor: pointer; +} +.key:hover { + border-color: var(--accent); +} +.key:focus-visible { + outline: 2px solid var(--focus-ring, var(--accent)); + outline-offset: 1px; +} +.keyAssigned { + background: var(--accent-soft); +} +.keyCurrent { + background: color-mix(in srgb, var(--accent) 45%, transparent); + color: var(--fg-strong); +} +.description { + width: 100%; +} +.legend { + display: flex; + align-items: center; + gap: 6px; + color: var(--fg-subtle); + font-size: var(--fs-small); +} +.dot { + width: 8px; + height: 8px; + border-radius: 50%; +} +.legendGap { + width: 8px; +} + +.listDialog { + max-height: 80vh; +} +.listBody { + display: flex; + height: 420px; + min-height: 0; + border-top: 1px solid var(--border-popup); +} +.listPane { + flex: 0 0 300px; + display: flex; + flex-direction: column; + min-height: 0; + border-right: 1px solid var(--border-popup); +} +.listPane > * { + flex: 1; +} +.previewPane { + flex: 1; + display: flex; + min-width: 0; +} +.fileRow, +.markRow { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} +.fileName { + color: var(--fg-strong); +} +.markText { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: pre; +} +.dim { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + color: var(--fg-subtle); +} +.mnemonic { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + border: 1px solid var(--accent); + border-radius: 2px; + background: var(--accent-soft); + font-size: var(--fs-tiny); + font-weight: 600; +} diff --git a/src/features/search/bookmarks/BookmarkPopups.tsx b/src/features/search/bookmarks/BookmarkPopups.tsx new file mode 100644 index 00000000..54edb1dd --- /dev/null +++ b/src/features/search/bookmarks/BookmarkPopups.tsx @@ -0,0 +1,251 @@ +/** + * Bookmark popups: the mnemonic chooser (⌥F3 / Ctrl+F11), the Bookmarks + * popup (⌘F3: list + preview, Enter jumps) and the Edit Description dialog + * (F2 / Rename). + */ +import { useMemo, useState, type KeyboardEvent, type MouseEvent } from "react"; +import clsx from "clsx"; +import { useProject } from "@/platform/project"; +import { Dialog, Icon, Input, List, Popup, T, fileIconName, type DialogButton } from "@/ui"; +import { CodePreview } from "../find/parts"; +import { basename, openLocation, relPath } from "../common/open"; +import { + MNEMONICS, + assignMnemonic, + bookmarkAt, + bookmarkLabel, + describeBookmark, + removeBookmarks, + updateBookmarks, + useBookmarkPopups, + useBookmarks, + type Bookmark, +} from "./store"; +import styles from "./BookmarkPopups.module.css"; + +export function BookmarkPopupsOverlay() { + const chooser = useBookmarkPopups((s) => s.chooser); + const list = useBookmarkPopups((s) => s.list); + const editing = useBookmarkPopups((s) => s.editing); + return ( + <> + {chooser && } + {list && } + {editing && } + + ); +} + +// --- mnemonic chooser --------------------------------------------------------- + +const DIGITS = MNEMONICS.filter((m) => /\d/.test(m)); +const LETTERS = MNEMONICS.filter((m) => /[A-Z]/.test(m)); + +function MnemonicChooser() { + const target = useBookmarkPopups((s) => s.chooser)!; + const items = useBookmarks((s) => s.items); + const current = bookmarkAt(items, target.path, target.line); + const assigned = useMemo(() => new Set(items.filter((b) => b.mnemonic && b !== current).map((b) => b.mnemonic!)), [items, current]); + const [description, setDescription] = useState(current?.description ?? ""); + const close = () => useBookmarkPopups.setState({ chooser: null }); + const choose = (mnemonic: string) => { + updateBookmarks((list) => assignMnemonic(list, target.path, target.line, target.text, mnemonic, description)); + close(); + }; + const title = !current ? "Add Mnemonic Bookmark" : current.mnemonic ? "Change Mnemonic" : "Assign Mnemonic"; + + const onKeyDown = (e: KeyboardEvent) => { + if (e.metaKey || e.ctrlKey || e.altKey) return; + const inInput = (e.target as HTMLElement).tagName === "INPUT"; + if (e.key === "Enter" && inInput) { + e.preventDefault(); + if (current?.mnemonic) choose(current.mnemonic); + else if (current) { + updateBookmarks((list) => describeBookmark(list, target.path, target.line, description)); + close(); + } + return; + } + if (inInput || e.key.length !== 1) return; + const key = e.key.toUpperCase(); + if (!MNEMONICS.includes(key)) return; + e.preventDefault(); + choose(key); + }; + + const button = (m: string) => ( + + ); + + return ( + +
+
+ {title} +
+
+
+ {DIGITS.map(button)} +
+
{LETTERS.map(button)}
+
+ setDescription(e.target.value)} + /> +
+ + Assigned bookmark + + + Current bookmark +
+
+
+ ); +} + +// --- Bookmarks popup ------------------------------------------------------------ + +type Row = { kind: "file"; key: string; path: string } | { kind: "mark"; key: string; bookmark: Bookmark }; + +export function bookmarkRows(items: readonly Bookmark[]): Row[] { + const rows: Row[] = []; + let path: string | null = null; + for (const b of items) { + if (b.path !== path) { + path = b.path; + rows.push({ kind: "file", key: `f:${b.path}`, path: b.path }); + } + rows.push({ kind: "mark", key: `b:${b.path}:${b.line}`, bookmark: b }); + } + return rows; +} + +const isFileRow = (r: Row) => r.kind === "file"; + +function BookmarksPopup() { + const items = useBookmarks((s) => s.items); + const root = useProject((s) => s.project?.root ?? null); + const rows = useMemo(() => bookmarkRows(items), [items]); + const [index, setIndex] = useState(() => rows.findIndex((r) => r.kind === "mark")); + const active = Math.min(index, rows.length - 1); + const row = rows[active]; + const selected = row?.kind === "mark" ? row.bookmark : null; + const close = () => useBookmarkPopups.setState({ list: false }); + const jump = (b: Bookmark | null) => { + if (!b) return; + close(); + setTimeout(() => void openLocation(b.path, { line: b.line, col: 0 }), 0); + }; + + const onKeyDown = (e: KeyboardEvent) => { + if (!selected) return; + if (e.key === "Delete" || e.key === "Backspace") { + e.preventDefault(); + updateBookmarks((list) => removeBookmarks(list, (b) => b.path === selected.path && b.line === selected.line)); + } else if (e.key === "F2") { + e.preventDefault(); + useBookmarkPopups.setState({ editing: { path: selected.path, line: selected.line } }); + } + }; + + return ( + +
+
+ + items={rows} + getKey={(r) => r.key} + isSection={isFileRow} + activeIndex={active} + onActiveChange={setIndex} + onActivate={(r, _i, e) => { + if (e.type === "click" && (e as MouseEvent).detail < 2) return; + if (r.kind === "mark") jump(r.bookmark); + }} + aria-label="Bookmarks" + rowHeight={22} + sectionHeight={24} + empty={{items.length ? null : No bookmarks}} + renderSection={(r) => + r.kind === "file" ? ( + + + {basename(r.path)} + {root ? relPath(r.path) : r.path} + + ) : null + } + renderItem={(r) => + r.kind === "mark" ? ( + + {r.bookmark.mnemonic ? ( + {r.bookmark.mnemonic} + ) : ( + + )} + {bookmarkLabel(r.bookmark) || "(empty line)"} + {r.bookmark.line + 1} + + ) : null + } + /> +
+
+ +
+
+
+ ); +} + +// --- Edit Description -------------------------------------------------------------- + +function EditDescriptionDialog() { + const editing = useBookmarkPopups((s) => s.editing)!; + const bookmark = useBookmarks((s) => bookmarkAt(s.items, editing.path, editing.line)); + const [text, setText] = useState(bookmark?.description ?? bookmark?.text ?? ""); + const close = () => useBookmarkPopups.setState({ editing: null }); + const save = () => { + updateBookmarks((list) => describeBookmark(list, editing.path, editing.line, text)); + close(); + }; + const buttons: DialogButton[] = [ + { label: "Cancel", role: "cancel" }, + { label: "OK", role: "default", onClick: save }, + ]; + return ( + + setText(e.target.value)} + onFocus={(e) => e.currentTarget.select()} + /> + + ); +} diff --git a/src/features/search/bookmarks/BookmarksView.module.css b/src/features/search/bookmarks/BookmarksView.module.css index f58d0bf5..be5ed9d9 100644 --- a/src/features/search/bookmarks/BookmarksView.module.css +++ b/src/features/search/bookmarks/BookmarksView.module.css @@ -35,3 +35,18 @@ white-space: pre; color: var(--fg-subtle); } +.mnemonic { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + margin: 0 1px; + border: 1px solid var(--accent); + border-radius: 2px; + background: var(--accent-soft); + color: var(--fg-strong); + font-size: var(--fs-tiny); + font-weight: 600; +} diff --git a/src/features/search/bookmarks/BookmarksView.tsx b/src/features/search/bookmarks/BookmarksView.tsx index ebac08be..7ff78eea 100644 --- a/src/features/search/bookmarks/BookmarksView.tsx +++ b/src/features/search/bookmarks/BookmarksView.tsx @@ -3,7 +3,7 @@ import { shortcutLabel } from "@/platform/keymap"; import { useProject } from "@/platform/project"; import { EmptyState, Icon, IconButton, Tree, fileIconName, showContextMenu } from "@/ui"; import { basename, openLocation, relPath } from "../common/open"; -import { removeBookmarks, updateBookmarks, useBookmarks, type Bookmark } from "./store"; +import { bookmarkLabel, removeBookmarks, updateBookmarks, useBookmarkPopups, useBookmarks, type Bookmark } from "./store"; import styles from "./BookmarksView.module.css"; type Node = @@ -76,9 +76,19 @@ export function BookmarksView() { else if (n.kind === "file") void openLocation(n.path); }; + const rename = (b: Bookmark) => useBookmarkPopups.setState({ editing: { path: b.path, line: b.line } }); + const onKeyDown = (e: KeyboardEvent) => { - if (e.key !== "Delete" && e.key !== "Backspace") return; const nodes = [...selection.current].map((k) => byKey.get(k)).filter((n): n is Node => !!n); + if (e.key === "F2" && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) { + const mark = nodes.length === 1 && nodes[0]!.kind === "mark" ? nodes[0]!.bookmark : null; + if (!mark) return; + e.preventDefault(); + e.stopPropagation(); + rename(mark); + return; + } + if (e.key !== "Delete" && e.key !== "Backspace") return; if (!nodes.length) return; e.preventDefault(); e.stopPropagation(); @@ -92,7 +102,7 @@ export function BookmarksView() { aria-label="Bookmarks" roots={roots} getKey={(n) => n.key} - getLabel={(n) => (n.kind === "mark" ? n.bookmark.text : n.name)} + getLabel={(n) => (n.kind === "mark" ? bookmarkLabel(n.bookmark) : n.name)} getChildren={(n) => (n.kind === "mark" ? undefined : n.children)} isExpandable={(n) => n.kind !== "mark"} expanded={expanded} @@ -107,6 +117,9 @@ export function BookmarksView() { e.preventDefault(); showContextMenu(e, [ ...(node && node.kind !== "list" ? [{ label: "Jump to Source", run: () => open(node) }] : []), + ...(node?.kind === "mark" && targets.length === 1 + ? [{ label: "Rename…", shortcut: "F2", run: () => rename(node.bookmark) }] + : []), { kind: "separator" as const }, { label: targets.length > 1 ? `Delete ${targets.length} Bookmarks` : "Delete Bookmark", danger: true, run: () => removeNodes(targets) }, ]); @@ -128,11 +141,28 @@ export function BookmarksView() { ) : ( - - - {basename(n.bookmark.path)}:{n.bookmark.line + 1} - - {n.bookmark.text.trim() || "(empty line)"} + {n.bookmark.mnemonic ? ( + + {n.bookmark.mnemonic} + + ) : ( + + )} + {n.bookmark.description ? ( + <> + {n.bookmark.description} + + {basename(n.bookmark.path)}:{n.bookmark.line + 1} + + + ) : ( + <> + + {basename(n.bookmark.path)}:{n.bookmark.line + 1} + + {n.bookmark.text.trim() || "(empty line)"} + + )} ) } diff --git a/src/features/search/bookmarks/gutter.ts b/src/features/search/bookmarks/gutter.ts index cac40478..32726bd3 100644 --- a/src/features/search/bookmarks/gutter.ts +++ b/src/features/search/bookmarks/gutter.ts @@ -1,16 +1,32 @@ /** * Bookmark gutter: a narrow CodeMirror gutter with a marker per bookmarked - * line. Markers map through edits, and moved lines are written back to the - * store so bookmarks follow the code. + * line (the mnemonic's letter / digit for mnemonic bookmarks). Markers map + * through edits, and moved lines are written back to the store so bookmarks + * follow the code. ⌘-click (Ctrl-click) in the gutter toggles a bookmark, as + * in IDEA. */ import { RangeSet, StateEffect, StateField, type Extension } from "@codemirror/state"; import { EditorView, GutterMarker, ViewPlugin, gutter, type ViewUpdate } from "@codemirror/view"; -import { linesOf, setFileLines, updateBookmarks, useBookmarks } from "./store"; +import { hasPrimaryModifier } from "@/platform/keymap"; +import { marksOf, setFileLines, updateBookmarks, useBookmarks } from "./store"; const SVG_NS = "http://www.w3.org/2000/svg"; class BookmarkMarker extends GutterMarker { - toDOM() { + constructor(readonly mnemonic: string | null) { + super(); + } + override eq(other: GutterMarker): boolean { + return other instanceof BookmarkMarker && other.mnemonic === this.mnemonic; + } + override toDOM() { + if (this.mnemonic) { + const el = document.createElement("span"); + el.className = "cm-openidea-bookmarkMnemonic"; + el.textContent = this.mnemonic; + el.setAttribute("aria-label", `Bookmark ${this.mnemonic}`); + return el; + } const svg = document.createElementNS(SVG_NS, "svg"); svg.setAttribute("viewBox", "0 0 24 24"); svg.setAttribute("width", "11"); @@ -25,7 +41,7 @@ class BookmarkMarker extends GutterMarker { } class SpacerMarker extends GutterMarker { - toDOM() { + override toDOM() { const el = document.createElement("span"); el.style.display = "inline-block"; el.style.width = "11px"; @@ -33,37 +49,49 @@ class SpacerMarker extends GutterMarker { } } -const marker = new BookmarkMarker(); +const plain = new BookmarkMarker(null); const spacer = new SpacerMarker(); -const setLines = StateEffect.define(); +type Mark = { line: number; mnemonic: string | null }; -function build(doc: EditorView["state"]["doc"], lines: readonly number[]): RangeSet { - const ranges = [...new Set(lines)] - .filter((l) => l >= 0 && l < doc.lines) - .sort((a, b) => a - b) - .map((l) => marker.range(doc.line(l + 1).from)); +const setMarks = StateEffect.define(); + +function build(doc: EditorView["state"]["doc"], marks: readonly Mark[]): RangeSet { + const seen = new Set(); + const ranges = [...marks] + .filter((m) => m.line >= 0 && m.line < doc.lines && !seen.has(m.line) && seen.add(m.line)) + .sort((a, b) => a.line - b.line) + .map((m) => (m.mnemonic ? new BookmarkMarker(m.mnemonic) : plain).range(doc.line(m.line + 1).from)); return RangeSet.of(ranges); } -function linesIn(view: EditorView, set: RangeSet): number[] { - const out: number[] = []; +function marksIn(view: EditorView, set: RangeSet): Mark[] { + const out: Mark[] = []; const iter = set.iter(); while (iter.value) { - out.push(view.state.doc.lineAt(iter.from).number - 1); + out.push({ line: view.state.doc.lineAt(iter.from).number - 1, mnemonic: iter.value.mnemonic }); iter.next(); } return out; } -const sameLines = (a: readonly number[], b: readonly number[]) => a.length === b.length && a.every((v, i) => v === b[i]); +const sameMarks = (a: readonly Mark[], b: readonly Mark[]) => + a.length === b.length && a.every((v, i) => v.line === b[i]!.line && v.mnemonic === b[i]!.mnemonic); -export function bookmarkGutter(path: string): Extension { - const field = StateField.define>({ - create: (state) => build(state.doc, linesOf(useBookmarks.getState().items, path)), +/** The 0-based line of a click in the gutter, or null outside it. */ +function gutterLine(view: EditorView, e: MouseEvent): number | null { + const t = e.target as HTMLElement | null; + if (!t?.closest?.(".cm-gutters") || t.closest(".cm-foldGutter")) return null; + const block = view.lineBlockAtHeight(e.clientY - view.documentTop); + return block ? view.state.doc.lineAt(block.from).number - 1 : null; +} + +export function bookmarkGutter(path: string, toggle: (line: number) => void): Extension { + const field = StateField.define>({ + create: (state) => build(state.doc, marksOf(useBookmarks.getState().items, path)), update(value, tr) { let next = tr.docChanged ? value.map(tr.changes) : value; - for (const e of tr.effects) if (e.is(setLines)) next = build(tr.state.doc, e.value); + for (const e of tr.effects) if (e.is(setMarks)) next = build(tr.state.doc, e.value); return next; }, }); @@ -75,17 +103,27 @@ export function bookmarkGutter(path: string): Extension { constructor(private readonly view: EditorView) { this.unsub = useBookmarks.subscribe((s, prev) => { if (s.items === prev.items) return; - const want = linesOf(s.items, path); - if (!sameLines(want, linesIn(this.view, this.view.state.field(field)))) { - this.view.dispatch({ effects: setLines.of(want) }); + const want = marksOf(s.items, path); + if (!sameMarks(want, marksIn(this.view, this.view.state.field(field)))) { + this.view.dispatch({ effects: setMarks.of(want) }); } }); // Bookmarks may have changed while this editor was being created. - const want = linesOf(useBookmarks.getState().items, path); - if (!sameLines(want, linesIn(view, view.state.field(field)))) { - queueMicrotask(() => view.dispatch({ effects: setLines.of(want) })); + const want = marksOf(useBookmarks.getState().items, path); + if (!sameMarks(want, marksIn(view, view.state.field(field)))) { + queueMicrotask(() => view.dispatch({ effects: setMarks.of(want) })); } + // Capture phase: runs before the breakpoint gutter's click handler, which ⌘-click must not reach. + view.dom.addEventListener("mousedown", this.onMouseDown, true); } + onMouseDown = (e: MouseEvent) => { + if (e.button !== 0 || !hasPrimaryModifier(e) || e.shiftKey || e.altKey) return; + const line = gutterLine(this.view, e); + if (line == null) return; + e.preventDefault(); + e.stopPropagation(); + toggle(line); + }; update(u: ViewUpdate) { if (!u.docChanged || this.pending) return; this.pending = true; @@ -94,20 +132,21 @@ export function bookmarkGutter(path: string): Extension { this.pending = false; const view = this.view; if (!view.dom.isConnected) return; - const lines = linesIn(view, view.state.field(field)); - const stored = linesOf(useBookmarks.getState().items, path); - if (sameLines(lines, stored)) return; + const marks = marksIn(view, view.state.field(field)); + const stored = marksOf(useBookmarks.getState().items, path); + if (sameMarks(marks, stored)) return; updateBookmarks((list) => setFileLines( list, path, - lines.map((l) => ({ line: l, text: view.state.doc.line(l + 1).text })), + marks.map((m) => ({ line: m.line, text: view.state.doc.line(m.line + 1).text })), ), ); }); } destroy() { this.unsub(); + this.view.dom.removeEventListener("mousedown", this.onMouseDown, true); } }, ); @@ -137,4 +176,20 @@ const theme = EditorView.baseTheme({ strokeWidth: "2", strokeLinejoin: "round", }, + ".cm-openidea-bookmarkMnemonic": { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + minWidth: "11px", + height: "12px", + padding: "0 1px", + borderRadius: "2px", + border: "1px solid var(--accent)", + color: "var(--fg-strong)", + background: "var(--accent-soft)", + fontFamily: "var(--font-ui)", + fontSize: "9px", + fontWeight: "600", + lineHeight: "1", + }, }); diff --git a/src/features/search/bookmarks/store.ts b/src/features/search/bookmarks/store.ts index b032c04a..fb9c99af 100644 --- a/src/features/search/bookmarks/store.ts +++ b/src/features/search/bookmarks/store.ts @@ -1,4 +1,7 @@ -/** Line bookmarks (F3), persisted per project in localStorage. */ +/** + * Line bookmarks (F3), persisted per project in localStorage. A bookmark may + * carry a mnemonic (0-9 / A-Z, unique: ⌥F3, ⌃⇧digit) and a description. + */ import { create } from "zustand"; import { loadJson, saveJson } from "../common/open"; @@ -9,6 +12,16 @@ export interface Bookmark { /** Line text when the bookmark was set / last moved. */ text: string; created: number; + /** `0`-`9` or `A`-`Z`; at most one bookmark has each. */ + mnemonic?: string; + /** User text shown instead of the line (F3 on a selection, Rename). */ + description?: string; +} + +export const MNEMONICS = [..."1234567890", ..."ABCDEFGHIJKLMNOPQRSTUVWXYZ"]; + +export function isMnemonic(c: unknown): c is string { + return typeof c === "string" && /^[0-9A-Z]$/.test(c); } const KEY_PREFIX = "openidea.bookmarks.v1:"; @@ -23,9 +36,80 @@ export function hasBookmark(list: readonly Bookmark[], path: string, line: numbe return list.some((b) => b.path === path && b.line === line); } -export function toggleBookmark(list: readonly Bookmark[], path: string, line: number, text: string, now = Date.now()): Bookmark[] { +export function bookmarkAt(list: readonly Bookmark[], path: string, line: number): Bookmark | undefined { + return list.find((b) => b.path === path && b.line === line); +} + +const clip = (text: string) => text.trim().slice(0, 200); + +/** F3: removes the line's bookmark or adds one (`description`: the selected text, as IDEA). */ +export function toggleBookmark( + list: readonly Bookmark[], + path: string, + line: number, + text: string, + now = Date.now(), + description?: string | null, +): Bookmark[] { if (hasBookmark(list, path, line)) return list.filter((b) => !(b.path === path && b.line === line)); - return sortBookmarks([...list, { path, line, text: text.trim().slice(0, 200), created: now }]); + const b: Bookmark = { path, line, text: clip(text), created: now }; + if (description?.trim()) b.description = clip(description); + return sortBookmarks([...list, b]); +} + +/** + * Gives the line's bookmark (created if needed) `mnemonic`, taking it from any + * other bookmark; `null` turns it into a plain bookmark. `description` replaces + * the description when given (empty clears it). + */ +export function assignMnemonic( + list: readonly Bookmark[], + path: string, + line: number, + text: string, + mnemonic: string | null, + description?: string, + now = Date.now(), +): Bookmark[] { + const here = (b: Bookmark) => b.path === path && b.line === line; + const out = list.filter((b) => !here(b)).map((b) => (mnemonic && b.mnemonic === mnemonic ? withoutMnemonic(b) : b)); + const b: Bookmark = { ...(list.find(here) ?? { path, line, text: clip(text), created: now }) }; + if (mnemonic) b.mnemonic = mnemonic; + else delete b.mnemonic; + if (description !== undefined) { + if (description.trim()) b.description = clip(description); + else delete b.description; + } + return sortBookmarks([...out, b]); +} + +function withoutMnemonic(b: Bookmark): Bookmark { + const { mnemonic: _, ...rest } = b; + return rest; +} + +/** ⌃⇧digit: the line already has `mnemonic` → remove the bookmark; otherwise assign it. */ +export function toggleMnemonic(list: readonly Bookmark[], path: string, line: number, text: string, mnemonic: string): Bookmark[] { + if (bookmarkAt(list, path, line)?.mnemonic === mnemonic) return list.filter((b) => !(b.path === path && b.line === line)); + return assignMnemonic(list, path, line, text, mnemonic); +} + +export function bookmarkByMnemonic(list: readonly Bookmark[], mnemonic: string): Bookmark | undefined { + return list.find((b) => b.mnemonic === mnemonic); +} + +/** Rename (F2 in the Bookmarks view): an empty description shows the line again. */ +export function describeBookmark(list: readonly Bookmark[], path: string, line: number, description: string): Bookmark[] { + return list.map((b) => { + if (b.path !== path || b.line !== line) return b; + const { description: _, ...rest } = b; + return description.trim() ? { ...rest, description: clip(description) } : rest; + }); +} + +/** What the Bookmarks view and popup show for a bookmark. */ +export function bookmarkLabel(b: Bookmark): string { + return b.description?.trim() || b.text.trim(); } export function removeBookmarks(list: readonly Bookmark[], pred: (b: Bookmark) => boolean): Bookmark[] { @@ -47,7 +131,11 @@ export function setFileLines( lines.forEach((l, i) => { if (seen.has(l.line)) return; seen.add(l.line); - moved.push({ path, line: l.line, text: l.text.trim().slice(0, 200), created: old[i]?.created ?? Date.now() }); + const prev = old[i]; + const b: Bookmark = { path, line: l.line, text: clip(l.text), created: prev?.created ?? Date.now() }; + if (prev?.mnemonic) b.mnemonic = prev.mnemonic; + if (prev?.description) b.description = prev.description; + moved.push(b); }); return sortBookmarks([...list.filter((b) => b.path !== path), ...moved]); } @@ -66,13 +154,26 @@ export function linesOf(list: readonly Bookmark[], path: string): number[] { return list.filter((b) => b.path === path).map((b) => b.line).sort((a, b) => a - b); } +/** Lines of `path`'s bookmarks with their mnemonics (gutter), sorted by line. */ +export function marksOf(list: readonly Bookmark[], path: string): Array<{ line: number; mnemonic: string | null }> { + return list + .filter((b) => b.path === path) + .map((b) => ({ line: b.line, mnemonic: b.mnemonic ?? null })) + .sort((a, b) => a.line - b.line); +} + function sanitize(raw: unknown): Bookmark[] { if (!Array.isArray(raw)) return []; return sortBookmarks( raw.filter( (b): b is Bookmark => !!b && typeof b === "object" && typeof (b as Bookmark).path === "string" && Number.isInteger((b as Bookmark).line), - ).map((b) => ({ path: b.path, line: Math.max(0, b.line), text: String(b.text ?? ""), created: Number(b.created) || 0 })), + ).map((b) => { + const out: Bookmark = { path: b.path, line: Math.max(0, b.line), text: String(b.text ?? ""), created: Number(b.created) || 0 }; + if (isMnemonic(b.mnemonic)) out.mnemonic = b.mnemonic; + if (typeof b.description === "string" && b.description.trim()) out.description = b.description; + return out; + }), ); } @@ -96,3 +197,25 @@ export function updateBookmarks(fn: (list: readonly Bookmark[]) => Bookmark[]) { useBookmarks.setState({ items: next }); if (scope) saveJson(KEY_PREFIX + scope, next); } + +// --- popups ----------------------------------------------------------------- + +export interface MnemonicTarget { + path: string; + line: number; + /** Line text, for a bookmark created by the chooser. */ + text: string; + /** Where to show the chooser (the caret); centred without. */ + at?: { x: number; y: number }; +} + +interface BookmarkPopupsState { + /** ⌥F3: the mnemonic chooser for this line. */ + chooser: MnemonicTarget | null; + /** ⌘F3: the Bookmarks popup. */ + list: boolean; + /** Edit Description dialog (F2 in the Bookmarks view / popup). */ + editing: { path: string; line: number } | null; +} + +export const useBookmarkPopups = create(() => ({ chooser: null, list: false, editing: null })); diff --git a/src/features/search/common/locations.ts b/src/features/search/common/locations.ts index 98f02737..cebf8f46 100644 --- a/src/features/search/common/locations.ts +++ b/src/features/search/common/locations.ts @@ -34,6 +34,17 @@ export function mergeLocations(...lists: ReadonlyArray d.path === l.path && Math.abs(d.line - l.line) <= MERGE_DISTANCE && l.time <= d.time); +} + /** Drops entries pointing at removed files, shifts paths of renamed ones. */ export function renameLocations(list: readonly RecentLocation[], from: string, to: string | null): RecentLocation[] { const out: RecentLocation[] = []; diff --git a/src/features/search/common/tracking.ts b/src/features/search/common/tracking.ts index fb574f4b..71fa1866 100644 --- a/src/features/search/common/tracking.ts +++ b/src/features/search/common/tracking.ts @@ -7,7 +7,13 @@ import { EditorView, type ViewUpdate } from "@codemirror/view"; import { create } from "zustand"; import { activeEditor, isExternalChange, onEditorEvent, registerEditorContribution, useEditorRegistry } from "@/editor/api"; import { call, commands, onIdeEvent } from "@/platform/ipc"; -import { recordLocation, renameLocations, type RecentLocation } from "./locations"; +import { + MERGE_DISTANCE, + recordLocation, + renameLocations, + type DismissedLocation, + type RecentLocation, +} from "./locations"; export interface CaretInfo { path: string; @@ -25,6 +31,8 @@ interface TrackingState { /** Files edited in this session (Recent Files "Show edited only"). */ edited: ReadonlySet; locations: readonly RecentLocation[]; + /** Places removed from Recent Locations (the editor's own history can't forget them). */ + dismissed: readonly DismissedLocation[]; } export const useTracking = create(() => ({ @@ -32,13 +40,23 @@ export const useTracking = create(() => ({ editVersions: new Map(), edited: new Set(), locations: [], + dismissed: [], })); +/** Recent Locations › Delete: forgets the place until it is visited again. */ +export function dismissLocation(l: RecentLocation) { + const near = (x: RecentLocation) => x.path === l.path && Math.abs(x.line - l.line) <= MERGE_DISTANCE; + useTracking.setState((s) => ({ + locations: s.locations.filter((x) => !near(x)), + dismissed: [...s.dismissed.slice(-99), { path: l.path, line: l.line, time: Date.now() }], + })); +} + const docPaths = new Map(); /** The window switched projects: the previous one's places and edits don't apply. */ export function resetTracking() { - useTracking.setState({ caret: null, editVersions: new Map(), edited: new Set(), locations: [] }); + useTracking.setState({ caret: null, editVersions: new Map(), edited: new Set(), locations: [], dismissed: [] }); } function bumpVersion(path: string, userEdit: boolean) { diff --git a/src/features/search/everywhere/store.ts b/src/features/search/everywhere/store.ts index d0efefa2..5c3c48b0 100644 --- a/src/features/search/everywhere/store.ts +++ b/src/features/search/everywhere/store.ts @@ -48,6 +48,8 @@ export function setSearchEverywherePreview(preview: boolean) { /** * Opens the dialog, or while it is open switches to `tab` (the query is kept); * re-invoking the shown tab toggles "include non-project items", like IDEA. + * Without `query` (an editor selection) the field starts with the tab's last + * search, so each tab remembers its own text. */ export function showSearchEverywhere(tab: SeTab, opts: { query?: string } = {}) { const s = useSearchEverywhere.getState(); @@ -59,7 +61,7 @@ export function showSearchEverywhere(tab: SeTab, opts: { query?: string } = {}) useSearchEverywhere.setState({ open: true, tab, - query: opts.query ?? s.query, + query: opts.query ?? searchHistory(tab)[0] ?? "", includeNonProject: false, autoEverywhere: true, notFound: null, @@ -95,7 +97,10 @@ export function onSearchEverywhereQuery(query: string) { useSearchEverywhere.setState({ includeNonProject: false, notFound: null }); } +/** Closing remembers the text for the shown tab (also when nothing was opened). */ export function hideSearchEverywhere() { + const s = useSearchEverywhere.getState(); + if (s.open) pushSearchHistory(s.tab, s.query); useSearchEverywhere.setState({ open: false }); } @@ -126,7 +131,7 @@ function loadHistory(): HistoryEntry[] { : []; } -/** Queries that led somewhere in `tab`, newest first. */ +/** Queries typed in `tab` (opened or left there), newest first. */ export function searchHistory(tab: SeTab): string[] { return loadHistory() .filter((e) => e.tab === tab) diff --git a/src/features/search/find/FindInFiles.tsx b/src/features/search/find/FindInFiles.tsx index f109460a..d7590546 100644 --- a/src/features/search/find/FindInFiles.tsx +++ b/src/features/search/find/FindInFiles.tsx @@ -1,6 +1,8 @@ -import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent } from "react"; import clsx from "clsx"; import { openEditorPaths, useEditorRegistry } from "@/editor/api"; +import { setContext } from "@/platform/context"; +import { useTxTemplate } from "@/platform/i18n"; import { call, commands, type SearchScope } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { @@ -42,16 +44,23 @@ import { type ReplaceRun, type Usage, } from "./replaceFlow"; +import { SINGLE_FIRST, isRowSelected, selectRow, selectedRows, type RowSelection } from "./rowSelection"; import { FindSession, IDLE, matchRows, type FindState, type MatchRow } from "./session"; import { FILE_SET_SCOPES, + SEARCH_CONTEXTS, buildQuery, describeQuery, + findHistory, hideFindPopup, recentMasks, + rememberFindString, rememberMask, + setFindPopupCommandHandler, + setFindPopupPinned, useFindPopup, type FindOptions, + type FindPopupCommand, type NamedScope, type ScopeTab, } from "./store"; @@ -94,13 +103,17 @@ function displayDirectory(dir: string, root: string | null): string { function FindInFilesDialog() { const s = useFindPopup(); + const txt = useTxTemplate(); const root = useProject((p) => p.project?.root ?? null); const editors = useEditorRegistry((r) => r.editors); const [state, setState] = useState(IDLE); const [session] = useState(() => new FindSession(ipcFindDeps(), setState)); - const [index, setIndex] = useState(0); + const [sel, setSel] = useState(SINGLE_FIRST); + const setIndex = (i: number) => setSel({ lead: i, anchor: i, keys: null }); const [recentPaths, setRecentPaths] = useState(null); const [maskMenu, setMaskMenu] = useState(false); + const [historyMenu, setHistoryMenu] = useState<"find" | "replace" | null>(null); + const [contextMenu, setContextMenu] = useState(false); const [previewVersion, setPreviewVersion] = useState(0); const [marks, setMarks] = useState(NO_MARKS); const [running, setRunning] = useState(null); @@ -109,11 +122,21 @@ function FindInFilesDialog() { const [diffPreview, setDiffPreview] = useState<{ key: string; lines: PreviewLine[] } | null>(null); const busy = !!running; const patternRef = useRef(null); + const patternBoxRef = useRef(null); + const replaceBoxRef = useRef(null); const maskRef = useRef(null); + const maskInputRef = useRef(null); + const contextRef = useRef(null); const set = (patch: Partial) => useFindPopup.setState(patch); useEffect(() => () => session.dispose(), [session]); + // Hidden popup actions (⌘G, F4, ⌥↓ …) win over the editor's bindings while this is set. + useEffect(() => { + setContext("findPopupOpen", true); + return () => setContext("findPopupOpen", false); + }, []); + useEffect(() => { patternRef.current?.select(); }, [s.session]); @@ -154,11 +177,12 @@ function FindInFilesDialog() { scopeTab: s.scopeTab, directory: s.directory, namedScope: s.namedScope, + context: s.context, }; const query = useMemo( () => buildQuery(options, scope, POPUP_MAX_MATCHES), // eslint-disable-next-line react-hooks/exhaustive-deps - [s.pattern, s.caseSensitive, s.wholeWord, s.isRegex, s.maskEnabled, s.mask, scope], + [s.pattern, s.caseSensitive, s.wholeWord, s.isRegex, s.maskEnabled, s.mask, s.context, scope], ); const queryKey = JSON.stringify(query); @@ -174,8 +198,9 @@ function FindInFilesDialog() { }, [session, queryKey, rerun]); const rows = useMemo(() => matchRows(state.files), [state.files]); - const active = rows.length ? Math.min(index, rows.length - 1) : -1; + const active = rows.length ? Math.min(sel.lead, rows.length - 1) : -1; const selected = active >= 0 ? rows[active] : undefined; + const chosen = useMemo(() => selectedRows(rows, sel), [rows, sel]); const fileHighlights = useMemo(() => { if (!selected) return []; const f = state.files.find((x) => x.path === selected.path); @@ -186,17 +211,22 @@ function FindInFilesDialog() { }, [selected, state.files]); const close = () => { + rememberFindString("find", s.pattern); + if (s.replace) rememberFindString("replace", s.replacement); clearPreviewCache(); hideFindPopup(); }; + /** Opens a result; a pinned popup stays open (IDEA's pin). */ const openRow = (row: MatchRow | undefined, side = false) => { if (!row) return; + const pos = { line: row.line, col: row.col, endLine: row.line, endCol: row.endCol }; + if (s.pinned) { + rememberFindString("find", s.pattern); + void openLocation(row.path, pos, { side, focus: false }); + return; + } close(); - setTimeout( - () => - void openLocation(row.path, { line: row.line, col: row.col, endLine: row.line, endCol: row.endCol }, { side }), - 0, - ); + setTimeout(() => void openLocation(row.path, pos, { side }), 0); }; const pin = () => { if (!s.pattern) return; @@ -206,15 +236,21 @@ function FindInFilesDialog() { openInFindWindow(pinned, describeQuery(pinned, root), s.replace ? s.replacement : null, undefined, s.preserveCase); }; + /** IDEA asks before Replace All only when the results span more than one file. */ const confirmReplaceAll = async () => { const usages = pendingUsages(rows, marks); if (!usages.length) return; + const counts = countUsages(usages); + if (rows.length <= 1 || counts.files <= 1) { + void replaceAll(usages); + return; + } const ok = await confirm({ title: "Replace All", ok: "Replace", message: ( <> - {confirmText(countUsages(usages), s.pattern, s.replacement || "")} + {confirmText(counts, s.pattern, s.replacement || "")} {marks.excluded.size > 0 && ( <> {" "} @@ -274,16 +310,18 @@ function FindInFilesDialog() { } }; - const replaceCurrent = async () => { - if (!selected) return; - const next = await replaceUsages(pendingUsages([selected], marks)); + /** Replace Selected (↵ in Replace mode): every selected row, then the next row to replace. */ + const replaceSelected = async () => { + if (!chosen.length) return; + const last = rows.indexOf(chosen[chosen.length - 1]!); + const next = await replaceUsages(pendingUsages(chosen, marks)); if (next) { - const i = nextPending(rows, active, next); + const i = nextPending(rows, last, next); if (i >= 0) setIndex(i); } }; const skip = () => { - const i = nextPending(rows, active, marks); + const i = nextPending(rows, rows.indexOf(chosen[chosen.length - 1]!), marks); if (i >= 0) setIndex(i); }; const replaceInFile = async () => { @@ -322,38 +360,103 @@ function FindInFilesDialog() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [previewMode, s.replace, selected?.key, s.replacement, s.preserveCase, queryKey, marks, previewVersion]); + const moveTo = (next: number, extend: boolean) => setSel((cur) => selectRow(rows, cur, next, extend ? "range" : "single")); + const onRowClick = (index: number, e: MouseEvent) => { + const mode = e.shiftKey ? "range" : e.metaKey || e.ctrlKey ? "toggle" : "single"; + setSel((cur) => selectRow(rows, cur, index, mode)); + }; + + const toggleMnemonic = (code: string): boolean => { + const toggle = { KeyC: "caseSensitive", KeyW: "wholeWord", KeyX: "isRegex" }[code] as + | "caseSensitive" + | "wholeWord" + | "isRegex" + | undefined; + if (toggle) set({ [toggle]: !s[toggle] }); + else if (code === "KeyK") { + set({ maskEnabled: !s.maskEnabled }); + if (!s.maskEnabled) setTimeout(() => maskInputRef.current?.select(), 0); + } else if (code === "KeyE" && s.replace) set({ preserveCase: !s.preserveCase }); + else if (code === "KeyA" && s.replace) { + if (rows.length && !busy) void confirmReplaceAll(); + } else return false; + return true; + }; + const onKeyDown = (e: KeyboardEvent) => { const target = e.target as HTMLElement; const inField = target.tagName === "INPUT" && target.closest("[data-find-field]"); - if (e.altKey && !e.metaKey && !e.ctrlKey) { - const toggle = { KeyC: "caseSensitive", KeyW: "wholeWord", KeyX: "isRegex" }[e.code] as - "caseSensitive" | "wholeWord" | "isRegex" | undefined; - if (toggle) { - e.preventDefault(); - set({ [toggle]: !s[toggle] }); - return; - } + if (e.key === "Enter" && e.altKey && e.shiftKey && !e.metaKey && !e.ctrlKey) { + // ⇧⌥↵: Replace All. + e.preventDefault(); + if (s.replace && rows.length && !busy) void confirmReplaceAll(); + return; + } + if (e.altKey && !e.metaKey && !e.ctrlKey && !e.shiftKey && toggleMnemonic(e.code)) { + e.preventDefault(); + return; } if (!inField) return; if (e.key === "Enter") { e.preventDefault(); if (e.metaKey || e.ctrlKey) pin(); - else openRow(selected, e.shiftKey); + else if (s.replace && !e.shiftKey) { + if (!busy) void replaceSelected(); + } else openRow(selected, e.shiftKey); return; } - if (!["ArrowDown", "ArrowUp", "PageDown", "PageUp"].includes(e.key)) return; + if (!["ArrowDown", "ArrowUp", "PageDown", "PageUp"].includes(e.key) || e.altKey) return; const next = listNavigate(e.key, { items: rows, active, pageSize: 9 }); if (next === null) return; e.preventDefault(); - setIndex(next); + moveTo(next, e.shiftKey); }; + // Keys that are global shortcuts too arrive through hidden actions (see store). + const commandRef = useRef<(c: FindPopupCommand) => void>(() => {}); + useLayoutEffect(() => { + commandRef.current = (c) => { + if (c === "nextResult" || c === "previousResult") { + if (!rows.length) return; + const step = c === "nextResult" ? 1 : -1; + moveTo((active + step + rows.length) % rows.length, false); + } else if (c === "openResult") openRow(selected); + else { + const el = document.activeElement; + setHistoryMenu(replaceBoxRef.current?.contains(el) ? "replace" : "find"); + } + }; + }); + useEffect(() => { + setFindPopupCommandHandler((c) => commandRef.current(c)); + return () => setFindPopupCommandHandler(null); + }, []); + const summary = statusText(state); const maskItems: MenuItem[] = recentMasks().map((m) => ({ label: m, checked: m === s.mask ? true : undefined, run: () => set({ mask: m, maskEnabled: true }), })); + const historyItems = (kind: "find" | "replace"): MenuItem[] => { + const h = findHistory(kind); + return h.length + ? [ + { kind: "header", label: kind === "find" ? "Recent Searches" : "Recent Replacements" }, + ...h.map((q) => ({ + label: q, + verbatim: true, + run: () => set(kind === "find" ? { pattern: q } : { replacement: q }), + })), + ] + : [{ label: kind === "find" ? "No recent searches" : "No recent replacements", disabled: true }]; + }; + const contextItems: MenuItem[] = SEARCH_CONTEXTS.map((c) => ({ + label: c.label, + checked: c.value === s.context ? true : undefined, + run: () => set({ context: c.value }), + })); + const pendingSelected = s.replace ? pendingUsages(chosen, marks).length : 0; return ( @@ -378,6 +482,7 @@ function FindInFilesDialog() { set({ maskEnabled: v })} label="File mask:" />
setMaskMenu(true)} />
- + setContextMenu(true)} + /> + setFindPopupPinned(!s.pinned)} + />
-
+
{state.status === "invalid" && state.error &&
{state.error}
} {s.replace && ( -
+
set({ replacement: v })} @@ -450,6 +567,7 @@ function FindInFilesDialog() { id: "preserveCase", icon: "preserve-case", title: "Preserve Case", + shortcut: "⌥E", pressed: s.preserveCase, onToggle: (v) => set({ preserveCase: v }), }, @@ -500,9 +618,9 @@ function FindInFilesDialog() { items={rows} getKey={(r, i) => `${r.key}:${i}`} activeIndex={active} - onActiveChange={setIndex} - onActivate={(r, _i, e) => { - if (e.type === "click" && (e as MouseEvent).detail < 2) return; + isSelected={(r) => isRowSelected(sel, r.key)} + onActivate={(r, i, e) => { + if (e.type === "click" && (e as MouseEvent).detail < 2) return onRowClick(i, e as MouseEvent); openRow(r, e.shiftKey); }} focusable={false} @@ -571,7 +689,11 @@ function FindInFilesDialog() {
- ↵ Open · ⇧↵ Open to the side · ⌘↵ Open in Find Window + {s.replace ? ( + ↵ Replace Selected · ⇧⌥↵ Replace All · ⌘↵ Open in Find Window + ) : ( + ↵ Open · ⇧↵ Open to the side · ⌘↵ Open in Find Window + )} {s.replace && progress && ( @@ -590,14 +712,15 @@ function FindInFilesDialog() { -
); } diff --git a/src/features/search/find/FindToolWindow.module.css b/src/features/search/find/FindToolWindow.module.css index a2c3ca22..e2eb6660 100644 --- a/src/features/search/find/FindToolWindow.module.css +++ b/src/features/search/find/FindToolWindow.module.css @@ -88,3 +88,13 @@ opacity: 0.5; text-decoration: line-through; } +.split { + flex: 1; + min-height: 0; +} +.splitPanel { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; +} diff --git a/src/features/search/find/FindToolWindow.tsx b/src/features/search/find/FindToolWindow.tsx index 15f02cb4..997b14ff 100644 --- a/src/features/search/find/FindToolWindow.tsx +++ b/src/features/search/find/FindToolWindow.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState, type KeyboardEvent } from "react"; +import { useMemo, useRef, useState, type KeyboardEvent } from "react"; import clsx from "clsx"; import { create } from "zustand"; import { executeAction } from "@/platform/actions"; @@ -9,18 +9,22 @@ import { EmptyState, Icon, IconButton, + SplitGroup, + SplitHandle, + SplitPanel, Spinner, Tabs, Tree, fileIconName, showContextMenu, + type TreeHandle, type TreeRowState, T, } from "@/ui"; -import { notifications } from "@/workbench/contributions"; +import { notifications, useOccurrenceNavigator } from "@/workbench/contributions"; import { confirm } from "@/workbench/messages"; import { basename, openLocation, relDir } from "../common/open"; -import { MatchText } from "./parts"; +import { CodePreview, MatchText, type PreviewHighlight } from "./parts"; import { NO_MARKS, confirmText, @@ -38,12 +42,20 @@ import { type UsageGroup, type Usage, } from "./replaceFlow"; -import { closeFindTab, rerunFindTab, stopFindTab, useFindWindow, type FindTab } from "./windowStore"; +import { + closeFindTab, + pinFindTab, + rerunFindTab, + stopFindTab, + useFindViewOptions, + useFindWindow, + type FindTab, +} from "./windowStore"; import styles from "./FindToolWindow.module.css"; type Node = | { kind: "dir"; key: string; label: string; count: number; children: Node[] } - | { kind: "file"; key: string; path: string; name: string; count: number; children: Node[] } + | { kind: "file"; key: string; path: string; name: string; dir: string; count: number; children: Node[] } | { kind: "match"; key: string; @@ -56,13 +68,17 @@ type Node = starts: Array<{ line: number; col: number }>; }; -/** Groups streamed file matches by directory → file → line (IDEA "Group by directory"). */ -export function buildFindTree(files: readonly FileMatches[]): Node[] { +/** + * Streamed file matches as file → line nodes, flat and sorted by path (IDEA's + * default), or under their directory with `groupByDirectory`. + */ +export function buildFindTree(files: readonly FileMatches[], groupByDirectory = false): Node[] { const dirs = new Map(); + const flat: Node[] = []; for (const f of files) { const dir = relDir(f.relPath); let d = dirs.get(dir); - if (!d) { + if (!d && groupByDirectory) { d = { kind: "dir", key: `d:${dir}`, label: dir || "/", count: 0, children: [] }; dirs.set(dir, d); } @@ -84,19 +100,35 @@ export function buildFindTree(files: readonly FileMatches[]): Node[] { starts: m.ranges.map(([s]) => ({ line: m.line, col: s + m.textOffset })), }; }); - d.children.push({ + const file: Node = { kind: "file", key: `f:${f.path}`, path: f.path, name: basename(f.relPath), + dir, count: f.matches.length, children, - }); - d.count += f.matches.length; + }; + if (d) { + d.children.push(file); + d.count += f.matches.length; + } else flat.push(file); + } + if (!groupByDirectory) { + const rel = (n: Node) => (n.kind === "file" ? (n.dir ? `${n.dir}/${n.name}` : n.name) : ""); + return flat.sort((a, b) => rel(a).localeCompare(rel(b))); } return [...dirs.values()].sort((a, b) => a.label.localeCompare(b.label)); } +/** Preview target of a node: a match, or a file's first match. */ +function previewOf(node: Node | null): { path: string; line: number } | null { + if (!node || node.kind === "dir") return null; + if (node.kind === "match") return { path: node.path, line: node.line }; + const first = node.children[0]; + return first?.kind === "match" ? { path: node.path, line: first.line } : null; +} + /** The usages under a node (itself for a match). */ export function nodeGroups(node: Node): UsageGroup[] { if (node.kind === "match") return [{ key: node.key, path: node.path, starts: node.starts }]; @@ -132,10 +164,30 @@ export function FindTabs() { variant="compact" className={styles.tabs} aria-label="Searches" - items={tabs.map((t) => ({ id: String(t.id), label: t.title, closable: true }))} + items={tabs.map((t) => ({ + id: String(t.id), + label: t.title, + closable: true, + icon: t.pinned ? "pin" : undefined, + }))} activeId={String(tab.id)} onSelect={(id) => useFindWindow.setState({ active: Number(id) })} onClose={(id) => closeFindTab(Number(id))} + onContextMenu={(id, e) => { + const t = tabs.find((x) => x.id === Number(id)); + if (!t) return; + showContextMenu(e, [ + { id: "pin", label: t.pinned ? "Unpin Tab" : "Pin Tab", run: () => pinFindTab(t.id, !t.pinned) }, + { kind: "separator" }, + { id: "close", label: "Close Tab", run: () => closeFindTab(t.id) }, + { + id: "closeOthers", + label: "Close Other Tabs", + disabled: tabs.length < 2, + run: () => tabs.forEach((o) => o.id !== t.id && closeFindTab(o.id)), + }, + ]); + }} /> ); } @@ -159,7 +211,11 @@ export function FindToolWindow() { } function FindTabView({ tab }: { tab: FindTab }) { - const roots = useMemo(() => buildFindTree(tab.state.files), [tab.state.files]); + const groupByDirectory = useFindViewOptions((o) => o.groupByDirectory); + const showPreview = useFindViewOptions((o) => o.preview); + const roots = useMemo(() => buildFindTree(tab.state.files, groupByDirectory), [tab.state.files, groupByDirectory]); + const tree = useRef(null); + const [leadNode, setLeadNode] = useState(null); const collapsed = useCollapsed((s) => s.byTab[tab.id]); const expanded = useMemo(() => { const all = allExpandableKeys(roots); @@ -187,9 +243,31 @@ function FindTabView({ tab }: { tab: FindTab }) { const exclude = () => { const groups = selectedGroups(); - if (groups.length) setMarks(tab.id, toggleExcluded(marks, groups)); + if (!groups.length) return; + const next = toggleExcluded(marks, groups); + setMarks(tab.id, next); + // Excluding moves on to the next usage, as in IDEA (including keeps the selection). + if (groups.some((g) => groupState(g, next) === "excluded")) + tree.current?.selectOccurrence(1, { skipSubtree: true }); }; + useOccurrenceNavigator("find", (dir, focus) => { + const step = tree.current?.selectOccurrence(dir); + const n = step?.node as Node | undefined; + if (!step || n?.kind !== "match") return null; + void openLocation(n.path, { line: n.line, col: n.col, endLine: n.line, endCol: n.endCol }, { focus }); + return step; + }); + + const preview = previewOf(leadNode); + const highlights = useMemo(() => { + const f = preview && st.files.find((x) => x.path === preview.path); + return (f?.matches ?? []).map((m) => ({ + line: m.line, + ranges: m.ranges.map(([a, b]) => [a + m.textOffset, b + m.textOffset] as [number, number]), + })); + }, [preview?.path, st.files]); // eslint-disable-line react-hooks/exhaustive-deps + const replace = async (usages: Usage[]) => { if (tab.replacement === null || !usages.length || running) return; const run = startReplace( @@ -225,7 +303,7 @@ function FindTabView({ tab }: { tab: FindTab }) { } }; - const preview = async () => { + const previewReplace = async () => { if (tab.replacement === null) return; const groups = selectedGroups(); const path = groups[0]?.path; @@ -307,7 +385,12 @@ function FindTabView({ tab }: { tab: FindTab }) { Stop )} - + )} void applyStash(s.index, false) }, { label: "Pop Stash", icon: "undo", run: () => void applyStash(s.index, true) }, + { label: "Unstash…", run: () => showUnstashDialog(s.index) }, { kind: "separator" }, { label: "Drop Stash", icon: "trash", run: () => void dropStash(s.index, s.message) }, ]); diff --git a/src/features/vcs/GitLog.tsx b/src/features/vcs/GitLog.tsx index 9a684ddf..3eb4f7f6 100644 --- a/src/features/vcs/GitLog.tsx +++ b/src/features/vcs/GitLog.tsx @@ -38,6 +38,9 @@ import { goToChild, goToParent, loadMore, + ME, + dateRangeLabel, + inDateRange, refsSignature, reloadLog, selectCommit, @@ -52,7 +55,7 @@ import { currentRepo, loadBranches, useVcs } from "./store"; import { checkoutBranch, loadFavoriteBranches, newBranch, useFavoriteBranches } from "./branchOps"; import { branchMenu } from "./branchMenu"; import { branchFolderKeys, branchGroupKeys, buildBranchTree, filterBranchTree, type BranchTreeNode } from "./branches"; -import { promptText } from "./dialogs"; +import { chooseDateRange, chooseMany, promptText } from "./dialogs"; import { buildFileTree, dirKeys, type FileTreeNode } from "./fileTree"; import styles from "./GitLog.module.css"; @@ -167,7 +170,7 @@ function BranchesPanel() { const branchesLoaded = useVcs((s) => s.branchesLoaded); const current = useVcs((s) => s.branch?.head ?? null); const favorites = useFavoriteBranches((s) => s.names); - const filterBranch = useGitLog((s) => s.filters.branch); + const filterBranches = useGitLog((s) => s.filters.branches); const [query, setQuery] = useState(""); useEffect(() => loadFavoriteBranches(), [root]); const allRoots = useMemo(() => buildBranchTree(branches, favorites), [branches, favorites]); @@ -175,8 +178,8 @@ function BranchesPanel() { // While searching every match is shown expanded. const searchExpanded = useMemo(() => (query.trim() ? new Set(branchGroupKeys(roots)) : undefined), [roots, query]); const selected = useMemo( - () => new Set([filterBranch === "HEAD" ? "head" : filterBranch ? `b:${filterBranch}` : ""]), - [filterBranch], + () => new Set(filterBranches.map((b) => (b === "HEAD" ? "head" : `b:${b}`))), + [filterBranches], ); return (
@@ -219,9 +222,10 @@ function BranchesPanel() { } getDetail={branchDetail} selected={selected} - multiSelect={false} - onSelectionChange={(_sel, lead) => { - if (lead?.ref) setLogFilters({ branch: lead.ref === "HEAD" ? "HEAD" : lead.ref }); + onSelectionChange={(sel) => { + // Several selected branches filter the log by all of them. + const refs = [...sel].flatMap((k) => (k === "head" ? ["HEAD"] : k.startsWith("b:") ? [k.slice(2)] : [])); + if (refs.length) setLogFilters({ branches: refs }); }} onActivate={(n) => n.branch && !n.branch.current && void checkoutBranch(n.branch.name)} onContextMenu={(n, _s, e) => { @@ -311,48 +315,80 @@ function FilterBar() { return () => clearTimeout(t); }, [text, filters.text]); + const only = (list: readonly string[], v: string) => list.length === 1 && list[0] === v; + const branchNames = () => branches.filter((b) => !b.name.endsWith("/HEAD")).map((b) => b.name); + const openBranch = (e: ReactMouseEvent) => menuBelow(e, [ { label: "HEAD (Current Branch)", - checked: filters.branch === "HEAD", - run: () => setLogFilters({ branch: "HEAD" }), + checked: only(filters.branches, "HEAD"), + run: () => setLogFilters({ branches: ["HEAD"] }), }, { kind: "separator" }, - ...branches - .filter((b) => !b.name.endsWith("/HEAD")) - .map((b) => ({ - label: b.name, - icon: "branch", - checked: filters.branch === b.name, - run: () => setLogFilters({ branch: b.name }), - })), + ...branchNames().map((name) => ({ + label: name, + icon: "branch", + checked: filters.branches.includes(name), + run: () => setLogFilters({ branches: [name] }), + })), + { kind: "separator" }, + { + label: "Select…", + run: async () => { + const r = await chooseMany({ + title: "Filter by Branch", + options: ["HEAD", ...branchNames()], + initial: filters.branches, + customLabel: "Other revisions (comma-separated):", + }); + if (r) setLogFilters({ branches: r }); + }, + }, ]); const openUser = (e: ReactMouseEvent) => { const authors = [...new Set(commits.map((c) => c.author))].sort((a, b) => a.localeCompare(b)).slice(0, 30); menuBelow(e, [ - ...authors.map((a) => ({ label: a, checked: filters.author === a, run: () => setLogFilters({ author: a }) })), + { label: ME, checked: filters.authors.includes(ME), run: () => setLogFilters({ authors: [ME] }) }, ...(authors.length ? [{ kind: "separator" as const }] : []), + ...authors.map((a) => ({ + label: a, + checked: filters.authors.includes(a), + run: () => setLogFilters({ authors: [a] }), + })), + { kind: "separator" }, { label: "Select…", run: async () => { - const r = await promptText({ title: "Filter by User", label: "User name or email", initial: filters.author }); - if (r) setLogFilters({ author: r.value.trim() }); + const r = await chooseMany({ + title: "Filter by User", + options: [ME, ...authors], + initial: filters.authors, + customLabel: "Other names or emails (comma-separated):", + }); + if (r) setLogFilters({ authors: r }); }, }, ]); }; const openDate = (e: ReactMouseEvent) => - menuBelow( - e, - DATE_OPTIONS.map((o) => ({ + menuBelow(e, [ + ...DATE_OPTIONS.map((o) => ({ label: o.label, - checked: filters.since?.label === o.label, - run: () => setLogFilters({ since: { ms: Date.now() - o.days * DAY, label: o.label } }), + checked: filters.date?.label === o.label, + run: () => setLogFilters({ date: { after: Date.now() - o.days * DAY, before: null, label: o.label } }), })), - ); + { kind: "separator" }, + { + label: "Select…", + run: async () => { + const r = await chooseDateRange(); + if (r) setLogFilters({ date: { ...r, label: dateRangeLabel(r.after, r.before) } }); + }, + }, + ]); const openPaths = (e: ReactMouseEvent) => menuBelow(e, [ @@ -395,21 +431,21 @@ function FilterBar() { /> setLogFilters({ branch: null })} + onClear={() => setLogFilters({ branches: [] })} /> setLogFilters({ author: "" })} + onClear={() => setLogFilters({ authors: [] })} /> setLogFilters({ since: null })} + onClear={() => setLogFilters({ date: null })} /> s.commits); const allRows = useGitLog((s) => s.rows); - const since = useGitLog((s) => s.filters.since); + const date = useGitLog((s) => s.filters.date); // The date filter is applied here; the graph is hidden while it is active (rows are no longer contiguous). - const commits = useMemo(() => (since == null ? all : all.filter((c) => (c.timeMs ?? 0) >= since.ms)), [all, since]); - const rows = since == null ? allRows : []; - const exhausted = since != null && all.length > 0 && (all[all.length - 1]!.timeMs ?? 0) < since.ms; + const commits = useMemo( + () => (date == null ? all : all.filter((c) => inDateRange(c.timeMs ?? 0, date))), + [all, date], + ); + const rows = date == null ? allRows : []; + const exhausted = date?.after != null && all.length > 0 && (all[all.length - 1]!.timeMs ?? 0) < date.after; const loading = useGitLog((s) => s.loading); const done = useGitLog((s) => s.done); const error = useGitLog((s) => s.error); diff --git a/src/features/vcs/MergeEditor.tsx b/src/features/vcs/MergeEditor.tsx index 4b8e64d5..3b6866b7 100644 --- a/src/features/vcs/MergeEditor.tsx +++ b/src/features/vcs/MergeEditor.tsx @@ -1,7 +1,7 @@ /** Three-pane merge conflict editor: Yours | Result | Theirs. */ import { useCallback, useEffect, useRef, useState } from "react"; import clsx from "clsx"; -import { Facet, RangeSetBuilder, StateField, type EditorState, type Extension } from "@codemirror/state"; +import { RangeSetBuilder, StateEffect, StateField, type EditorState, type Extension } from "@codemirror/state"; import { Decoration, EditorView, WidgetType, type DecorationSet } from "@codemirror/view"; import { history, historyKeymap } from "@codemirror/commands"; import { keymap } from "@codemirror/view"; @@ -13,14 +13,7 @@ import { Button, IconButton, Spinner, T } from "@/ui"; import { editorService, type EditorInput } from "@/workbench/contributions"; import { languageFor, viewerBase } from "./cm"; import { confirmDialog } from "./dialogs"; -import { - mergeTitles, - parseConflicts, - resolutionText, - resolveAll, - type ConflictBlock, - type Resolution, -} from "./conflicts"; +import { initialResult, mergeTitles, resolutionText, type Resolution, type ResultConflict } from "./conflicts"; import { baseName } from "./model"; import { errorMessage, readWorkingText, runOp, writeFileText } from "./ops"; import { refreshNow } from "./store"; @@ -29,12 +22,15 @@ import styles from "./MergeEditor.module.css"; import type { MergeData } from "./mergeKind"; export { MERGE_KIND, mergeInput, type MergeData } from "./mergeKind"; -/** The user's commit is the `>>>>>>>` section (a rebase): "Yours" takes it. */ -const reversedSides = Facet.define({ combine: (v) => v.some(Boolean) }); +/** Drops the conflict at an index once a resolution replaced its text. */ +const resolvedConflict = StateEffect.define(); -function resolveBlock(view: EditorView, block: ConflictBlock, how: Resolution) { +function resolveConflict(view: EditorView, index: number, how: Resolution) { + const c = view.state.field(conflictField).conflicts[index]; + if (!c) return; view.dispatch({ - changes: { from: block.from, to: block.to, insert: resolutionText(block, how, view.state.facet(reversedSides)) }, + changes: { from: c.from, to: c.to, insert: resolutionText(c, how) }, + effects: resolvedConflict.of(index), userEvent: "input.resolve", }); } @@ -42,12 +38,12 @@ function resolveBlock(view: EditorView, block: ConflictBlock, how: Resolution) { class ActionsWidget extends WidgetType { constructor( readonly index: number, - readonly block: ConflictBlock, + readonly hasBase: boolean, ) { super(); } eq(o: ActionsWidget) { - return o.index === this.index && o.block.from === this.block.from && o.block.to === this.block.to; + return o.index === this.index && o.hasBase === this.hasBase; } toDOM(view: EditorView) { const el = document.createElement("div"); @@ -60,8 +56,7 @@ class ActionsWidget extends WidgetType { b.onmousedown = (e) => e.preventDefault(); b.onclick = (e) => { e.preventDefault(); - const live = parseConflicts(view.state.doc.toString())[this.index]; - if (live) resolveBlock(view, live, how); + resolveConflict(view, this.index, how); }; el.appendChild(b); }; @@ -71,7 +66,7 @@ class ActionsWidget extends WidgetType { add("Accept Yours", "ours"); add("Accept Theirs", "theirs"); add("Accept Both", "both"); - if (this.block.base !== null) add("Use Base", "base"); + if (this.hasBase) add("Use Base", "base"); return el; } ignoreEvent() { @@ -79,41 +74,38 @@ class ActionsWidget extends WidgetType { } } -const oursLine = Decoration.line({ class: "cm-conflict-ours" }); -const theirsLine = Decoration.line({ class: "cm-conflict-theirs" }); -const markerLine = Decoration.line({ class: "cm-conflict-marker" }); +const conflictLine = Decoration.line({ class: "cm-conflict-marker" }); -function decorate(state: EditorState): { blocks: ConflictBlock[]; deco: DecorationSet } { - const blocks = parseConflicts(state.doc.toString()); +function decorate(state: EditorState, conflicts: readonly ResultConflict[]): DecorationSet { const b = new RangeSetBuilder(); const doc = state.doc; - const [yours, theirs] = state.facet(reversedSides) ? [theirsLine, oursLine] : [oursLine, theirsLine]; - blocks.forEach((blk, i) => { - b.add(blk.from, blk.from, Decoration.widget({ widget: new ActionsWidget(i, blk), block: true, side: -1 })); - const endLine = doc.lineAt(Math.max(blk.from, blk.to - 1)).number; - for (let n = doc.lineAt(blk.from).number; n <= endLine; n++) { - const line = doc.line(n); - const deco = - line.from >= blk.oursFrom && line.from < blk.oursTo - ? yours - : line.from >= blk.theirsFrom && line.from < blk.theirsTo - ? theirs - : markerLine; - b.add(line.from, line.from, deco); - } + conflicts.forEach((c, i) => { + b.add(c.from, c.from, Decoration.widget({ widget: new ActionsWidget(i, c.base !== null), block: true, side: -1 })); + if (c.to <= c.from) return; + const endLine = doc.lineAt(c.to - 1).number; + for (let n = doc.lineAt(c.from).number; n <= endLine; n++) b.add(doc.line(n).from, doc.line(n).from, conflictLine); }); - return { blocks, deco: b.finish() }; + return b.finish(); } -const conflictField = StateField.define<{ blocks: ConflictBlock[]; deco: DecorationSet }>({ - create: decorate, - update: (v, tr) => (tr.docChanged ? decorate(tr.state) : v), +/** + * The result's unresolved conflicts, tracked as ranges holding the base text (no + * markers), as IDEA's merge dialog shows them. Typing at a conflict's edges stays in it. + */ +const conflictField = StateField.define<{ conflicts: ResultConflict[]; deco: DecorationSet }>({ + create: () => ({ conflicts: [], deco: Decoration.none }), + update(v, tr) { + const done = new Set(tr.effects.filter((e) => e.is(resolvedConflict)).map((e) => e.value as number)); + let conflicts = v.conflicts.filter((_, i) => !done.has(i)); + if (tr.docChanged) + conflicts = conflicts.map((c) => ({ ...c, from: tr.changes.mapPos(c.from, -1), to: tr.changes.mapPos(c.to, 1) })); + if (!tr.docChanged && !done.size) return v; + return { conflicts, deco: decorate(tr.state, conflicts) }; + }, provide: (f) => EditorView.decorations.from(f, (v) => v.deco), }); const resultTheme = EditorView.baseTheme({ - ".cm-conflict-ours": { backgroundColor: "color-mix(in srgb, var(--accent) 12%, transparent)" }, - ".cm-conflict-theirs": { backgroundColor: "color-mix(in srgb, var(--vcs-added) 12%, transparent)" }, ".cm-conflict-marker": { backgroundColor: "color-mix(in srgb, var(--vcs-conflict) 14%, transparent)", color: "var(--fg-subtle)", @@ -148,6 +140,7 @@ export function MergeEditor({ input }: { input: EditorInput }) { theirs: string; base: string | null; working: string; + merged: string | null; sides: MergeSides; } | null>(null); const [error, setError] = useState(null); @@ -164,7 +157,14 @@ export function MergeEditor({ input }: { input: EditorInput }) { const load = useCallback(async () => { try { const [v, working] = await Promise.all([call(commands.vcsConflictVersions(path)), readWorkingText(path)]); - setState({ ours: v.ours ?? "", theirs: v.theirs ?? "", base: v.base, working: working ?? "", sides: v.sides }); + setState({ + ours: v.ours ?? "", + theirs: v.theirs ?? "", + base: v.base, + working: working ?? "", + merged: v.merged, + sides: v.sides, + }); setError(null); } catch (e) { setError(errorMessage(e)); @@ -191,24 +191,27 @@ export function MergeEditor({ input }: { input: EditorInput }) { ]; const ours = new EditorView({ doc: state.ours, parent: oursRef.current, extensions: side() }); const theirs = new EditorView({ doc: state.theirs, parent: theirsRef.current, extensions: side() }); + // Like IDEA, start from the three-way merge (non-conflicting changes applied, conflicts + // showing the base) rather than the marker-laden working copy. + const start = + state.merged !== null ? initialResult(state.merged) : initialResult(state.working, state.sides.reversed); const result = new EditorView({ - doc: state.working, + doc: start.text, parent: resultRef.current, extensions: [ viewerBase({ readOnly: false }), history(), keymap.of(historyKeymap), lang, - reversedSides.of(state.sides.reversed), - conflictField, + conflictField.init((s) => ({ conflicts: start.conflicts, deco: decorate(s, start.conflicts) })), resultTheme, EditorView.updateListener.of((u) => { - if (u.docChanged) setLeft(u.state.field(conflictField).blocks.length); + if (u.docChanged) setLeft(u.state.field(conflictField).conflicts.length); }), ], }); resultView.current = result; - setLeft(result.state.field(conflictField).blocks.length); + setLeft(result.state.field(conflictField).conflicts.length); return () => { ours.destroy(); theirs.destroy(); @@ -220,7 +223,7 @@ export function MergeEditor({ input }: { input: EditorInput }) { const goto = (dir: 1 | -1) => { const v = resultView.current; if (!v) return; - const blocks = v.state.field(conflictField).blocks; + const blocks = v.state.field(conflictField).conflicts; if (!blocks.length) return; const head = v.state.selection.main.head; const target = @@ -237,9 +240,10 @@ export function MergeEditor({ input }: { input: EditorInput }) { const acceptAll = (how: Resolution) => { const v = resultView.current; if (!v) return; - const text = v.state.doc.toString(); + const conflicts = v.state.field(conflictField).conflicts; v.dispatch({ - changes: { from: 0, to: text.length, insert: resolveAll(text, how, v.state.facet(reversedSides)) }, + changes: conflicts.map((c) => ({ from: c.from, to: c.to, insert: resolutionText(c, how) })), + effects: conflicts.map((_, i) => resolvedConflict.of(i)), userEvent: "input.resolve", }); }; @@ -247,12 +251,12 @@ export function MergeEditor({ input }: { input: EditorInput }) { const apply = async () => { const v = resultView.current; if (!v) return; - const remaining = v.state.field(conflictField).blocks.length; + const remaining = v.state.field(conflictField).conflicts.length; if ( remaining > 0 && !(await confirmDialog({ title: "Unresolved Conflicts", - message: `${remaining} conflict${remaining === 1 ? "" : "s"} still contain conflict markers. Save and mark the file as resolved anyway?`, + message: `${remaining} conflict${remaining === 1 ? " is" : "s are"} unresolved and keep the base text. Save and mark the file as resolved anyway?`, okLabel: "Mark Resolved", })) ) @@ -263,12 +267,15 @@ export function MergeEditor({ input }: { input: EditorInput }) { async () => { await writeFileText(path, text, { save: true }); await call(commands.vcsStage([path])); + return true; }, { success: `${baseName(path)}: conflicts resolved` }, ); - if (ok !== undefined) { + if (ok) { setResolved(true); await refreshNow(); + // Like IDEA, applying the result closes the merge editor. + editorService().closeInput?.(input.key); } }; diff --git a/src/features/vcs/__tests__/branches.test.ts b/src/features/vcs/__tests__/branches.test.ts index 1b1a3ffc..a47acf42 100644 --- a/src/features/vcs/__tests__/branches.test.ts +++ b/src/features/vcs/__tests__/branches.test.ts @@ -40,7 +40,15 @@ const actions = [ ]; const summary = (rows: BranchRow[]) => - rows.map((r) => (r.type === "section" ? `# ${r.label}` : r.type === "action" ? `> ${r.action.label}` : `${r.group}:${r.branch.name}`)); + rows.map((r) => + r.type === "section" + ? `# ${r.label}` + : r.type === "action" + ? `> ${r.action.label}` + : r.type === "tag" + ? `tag:${r.tag}` + : `${r.group}:${r.branch.name}${r.favorite ? " ★" : ""}`, + ); describe("buildBranchRows", () => { it("groups actions, recent, local (current first, favourites next) and remote", () => { @@ -51,25 +59,50 @@ describe("buildBranchRows", () => { "recent:bugfix", "# Local", "local:feature/order-paging", - "local:main", + "local:main ★", "local:bugfix", "local:zeta", "# Remote", - "remote:origin/main", + "remote:origin/main ★", "remote:origin/feature/order-paging", ]); }); it("filters branches and actions by fuzzy query and drops the Recent group", () => { const rows = buildBranchRows(branches, ["main"], "main", actions); - expect(summary(rows)).toEqual(["# Local", "local:main", "# Remote", "remote:origin/main"]); + expect(summary(rows)).toEqual(["# Local", "local:main ★", "# Remote", "remote:origin/main ★"]); const hit = rows[1]!; expect(hit.type === "branch" && hit.matches).toEqual([0, 1, 2, 3]); expect(summary(buildBranchRows(branches, [], "push", actions))).toEqual(["> Push…"]); }); it("omits empty groups", () => { - expect(summary(buildBranchRows([b("main", { current: true })], [], "", []))).toEqual(["# Local", "local:main"]); + expect(summary(buildBranchRows([b("main", { current: true })], [], "", []))).toEqual(["# Local", "local:main ★"]); + }); + + it("puts the user's favorites right after the current branch", () => { + const rows = buildBranchRows(branches, [], "", [], ["zeta", "origin/feature/order-paging"]); + expect(summary(rows)).toEqual([ + "# Local", + "local:feature/order-paging", + "local:zeta ★", + "local:bugfix", + "local:main", + "# Remote", + "remote:origin/feature/order-paging ★", + "remote:origin/main", + ]); + }); + + it("lists tags last, newest version first, and filters them by the query", () => { + const tags = ["v1.2", "v1.10", "release"]; + expect(summary(buildBranchRows([], [], "", [], null, tags))).toEqual([ + "# Tags", + "tag:v1.10", + "tag:v1.2", + "tag:release", + ]); + expect(summary(buildBranchRows(branches, [], "v1.1", [], null, tags))).toEqual(["# Tags", "tag:v1.10"]); }); }); @@ -89,7 +122,17 @@ describe("buildBranchTree", () => { it("groups names into folders with favorites first", () => { const tree = buildBranchTree( - [b("zeta"), b("feat/b"), b("feat/a"), b("main"), b("fix/deep/x"), b("dev", { current: true }), b("origin/main"), b("origin/feat/a"), b("origin/HEAD")], + [ + b("zeta"), + b("feat/b"), + b("feat/a"), + b("main"), + b("fix/deep/x"), + b("dev", { current: true }), + b("origin/main"), + b("origin/feat/a"), + b("origin/HEAD"), + ], null, ); expect(outline(tree)).toEqual([ diff --git a/src/features/vcs/__tests__/conflicts.test.ts b/src/features/vcs/__tests__/conflicts.test.ts index fc32ac02..cf41d834 100644 --- a/src/features/vcs/__tests__/conflicts.test.ts +++ b/src/features/vcs/__tests__/conflicts.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { blockAt, mergeTitles, parseConflicts, resolveAll, resolutionText } from "../conflicts"; +import { blockAt, initialResult, mergeTitles, parseConflicts, resolveAll, resolutionText } from "../conflicts"; const text = [ "keep\n", @@ -71,6 +71,30 @@ describe("mergeTitles", () => { yours: "Local Changes", theirs: "Changes from cherry-pick abc12345", }); + expect(mergeTitles(sides("unstash"))).toEqual({ + yours: "Your uncommitted changes", + theirs: "Changes from feature", + }); expect(mergeTitles(null)).toEqual({ yours: "Your version", theirs: "Theirs (incoming)" }); }); }); + +describe("initialResult", () => { + const merged = "a\n<<<<<<< yours\nmine\n||||||| base\nold\n=======\nhers\n>>>>>>> theirs\nz\n"; + + it("replaces each conflict with its base text, without markers", () => { + const { text, conflicts } = initialResult(merged); + expect(text).toBe("a\nold\nz\n"); + expect(conflicts).toEqual([{ from: 2, to: 6, ours: "mine\n", base: "old\n", theirs: "hers\n" }]); + expect(resolutionText(conflicts[0]!, "ours")).toBe("mine\n"); + }); + + it("swaps marker sides written in git's order during a rebase", () => { + const { conflicts } = initialResult(merged, true); + expect(conflicts[0]).toMatchObject({ ours: "hers\n", theirs: "mine\n" }); + }); + + it("keeps text without conflicts as is", () => { + expect(initialResult("x\n")).toEqual({ text: "x\n", conflicts: [] }); + }); +}); diff --git a/src/features/vcs/__tests__/logFilters.test.ts b/src/features/vcs/__tests__/logFilters.test.ts new file mode 100644 index 00000000..5b6a7267 --- /dev/null +++ b/src/features/vcs/__tests__/logFilters.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const vcsLog = vi.fn(); +const vcsConfigGet = vi.fn(); + +vi.mock("@/platform/ipc", async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + commands: { + ...real.commands, + vcsLog: (repo: unknown, query: unknown) => vcsLog(repo, query), + vcsConfigGet: (repo: unknown, key: string) => vcsConfigGet(repo, key), + }, + }; +}); + +import { parseDay, splitList } from "../dialogs"; +import { unstashTitles } from "../changeOps"; +import { + ME, + dateRangeLabel, + emptyFilters, + hasFilters, + inDateRange, + reloadLog, + resolveAuthors, + useGitLog, +} from "../logStore"; + +describe("Git Log filters", () => { + it("resolves the 'me' user to the current Git user and drops duplicates", () => { + expect(resolveAuthors([ME, "alice", " alice "], "me@corp.test")).toEqual(["me@corp.test", "alice"]); + expect(resolveAuthors([ME], null)).toEqual([]); + }); + + it("matches a custom date range with an exclusive end", () => { + const d = { after: 100, before: 200, label: "" }; + expect([99, 100, 199, 200].map((t) => inDateRange(t, d))).toEqual([false, true, true, false]); + expect(inDateRange(5, { after: null, before: 10, label: "" })).toBe(true); + expect(inDateRange(5, null)).toBe(true); + }); + + it("labels a date range by its first and last day", () => { + const jan1 = parseDay("2024-01-01")!; + const feb1 = parseDay("2024-02-01")!; + expect(dateRangeLabel(jan1, feb1)).toBe("2024-01-01 – 2024-01-31"); + expect(dateRangeLabel(jan1, null)).toBe("after 2024-01-01"); + expect(dateRangeLabel(null, feb1)).toBe("until 2024-01-31"); + expect(parseDay("nope")).toBeNull(); + }); + + it("counts multi-value filters as filters", () => { + expect(hasFilters(emptyFilters())).toBe(false); + expect(hasFilters({ ...emptyFilters(), branches: ["main", "dev"] })).toBe(true); + expect(hasFilters({ ...emptyFilters(), authors: [ME] })).toBe(true); + }); + + it("splits comma-separated custom values", () => { + expect(splitList(" a, ,b ,")).toEqual(["a", "b"]); + }); +}); + +describe("loading the log", () => { + beforeEach(() => { + vcsLog.mockReset().mockResolvedValue({ status: "ok", data: [] }); + vcsConfigGet.mockReset().mockImplementation(async (_repo: unknown, key: string) => ({ + status: "ok", + data: key === "user.email" ? "me@corp.test" : null, + })); + }); + + it("sends every selected branch and user, with 'me' resolved", async () => { + useGitLog.setState({ filters: { ...emptyFilters(), branches: ["main", "dev"], authors: [ME, "bob"] } }); + await reloadLog(); + const queries = vcsLog.mock.calls.map((c) => c[1] as { branches?: string[]; authors?: string[]; range: unknown }); + const page = queries.find((q) => q.range === null)!; + expect(page.branches).toEqual(["main", "dev"]); + expect(page.authors).toEqual(["me@corp.test", "bob"]); + }); +}); + +describe("unstash", () => { + it("names the operation after what it does", () => { + const base = { index: 2, pop: false, reinstateIndex: true, branch: null }; + expect(unstashTitles(base).title).toBe("Apply stash@{2}"); + expect(unstashTitles({ ...base, pop: true }).success).toBe("Unstashed stash@{2}"); + expect(unstashTitles({ ...base, branch: "fix" }).title).toBe("Unstash stash@{2} as branch fix"); + }); +}); diff --git a/src/features/vcs/__tests__/projectSwitch.test.ts b/src/features/vcs/__tests__/projectSwitch.test.ts index c6fe7845..935cb0cb 100644 --- a/src/features/vcs/__tests__/projectSwitch.test.ts +++ b/src/features/vcs/__tests__/projectSwitch.test.ts @@ -13,14 +13,14 @@ describe("switching the window to another repository", () => { disposers.push(installPreparedMessage(), installGitLog()); useCommit.setState({ message: "wip", amend: true, selection: ["/a/x"] }); useGitLog.setState({ commits: [{ id: "c1" } as never], selected: "c1", selection: ["c1"] }); - useGitLog.setState((s) => ({ filters: { ...s.filters, author: "me" } })); + useGitLog.setState((s) => ({ filters: { ...s.filters, authors: ["me"] } })); logConsole("cmd", "git status"); useVcs.setState({ root: "/b" }); expect(useCommit.getState()).toMatchObject({ message: "", amend: false, selection: [] }); expect(useGitLog.getState()).toMatchObject({ commits: [], selected: null, selection: [] }); - expect(useGitLog.getState().filters.author).toBe(""); + expect(useGitLog.getState().filters.authors).toEqual([]); expect(useVcsConsole.getState().lines).toEqual([]); }); }); diff --git a/src/features/vcs/actions.tsx b/src/features/vcs/actions.tsx index 8c6dba50..57c370ea 100644 --- a/src/features/vcs/actions.tsx +++ b/src/features/vcs/actions.tsx @@ -14,6 +14,7 @@ import { rollbackCandidates, showRollbackDialog, stashChanges, + showUnstashDialog, } from "./changeOps"; import { setAmend, useCommit } from "./commitStore"; import { gotoConflict, resolveAtCaret, showConflictsDialog } from "./conflictsUi"; @@ -174,7 +175,14 @@ export function registerVcsActions(): () => void { keywords: ["clone", "checkout", "git"], run: (c) => showCloneDialog(typeof c.arg === "string" ? c.arg : ""), }, - { id: "vcs.push", title: "Push…", category: "Git", icon: "git-push", when: VCS, run: ({ arg }) => void showPushDialog(arg && typeof arg === "object" ? (arg as PushTarget) : {}) }, + { + id: "vcs.push", + title: "Push…", + category: "Git", + icon: "git-push", + when: VCS, + run: ({ arg }) => void showPushDialog(arg && typeof arg === "object" ? (arg as PushTarget) : {}), + }, { id: "vcs.update", title: "Update Project…", @@ -354,7 +362,7 @@ export function registerVcsActions(): () => void { run: ({ arg }) => { const a = (arg ?? {}) as { branch?: string; path?: string }; useGitLog.setState({ tab: "log" }); - setLogFilters({ ...emptyFilters(), branch: a.branch ?? null, path: a.path ?? null }); + setLogFilters({ ...emptyFilters(), branches: a.branch ? [a.branch] : [], path: a.path ?? null }); showGitWindow(); }, }, @@ -408,7 +416,7 @@ export function registerVcsActions(): () => void { title: "Unstash Changes…", category: "Git", when: VCS, - run: () => showCommitWindow("stashes"), + run: () => showUnstashDialog(), }, { id: "vcs.resolveConflicts", diff --git a/src/features/vcs/branchMenu.ts b/src/features/vcs/branchMenu.ts index 14b24392..44609e92 100644 --- a/src/features/vcs/branchMenu.ts +++ b/src/features/vcs/branchMenu.ts @@ -6,6 +6,7 @@ import { isFavoriteBranch } from "./branches"; import { checkoutAndRebase, checkoutBranch, + checkoutDetached, deleteBranch, deleteRemoteBranch, loadFavoriteBranches, @@ -53,9 +54,7 @@ export function branchMenu(b: Branch, current: string | null, done: () => void = const items: MenuItem[] = [ { label: "Checkout", run: run(() => checkoutBranch(b.name)) }, { label: `New Branch from '${b.name}'…`, icon: "branch", run: run(() => newBranch(b.name)) }, - ...(current - ? [{ label: `Checkout and Rebase onto '${cur}'`, run: run(() => checkoutAndRebase(b.name)) }] - : []), + ...(current ? [{ label: `Checkout and Rebase onto '${cur}'`, run: run(() => checkoutAndRebase(b.name)) }] : []), { kind: "separator" }, { label: `Compare with '${cur}'`, icon: "diff", run: run(() => showCompareBranches(b.name)) }, { label: "Show Diff with Working Tree", run: run(() => showDiffWithWorkingTree(b.name)) }, @@ -83,3 +82,19 @@ export function branchMenu(b: Branch, current: string | null, done: () => void = ); return items; } + +/** Tag entries of the Branches popup. */ +export function tagMenu(tag: string, done: () => void = () => {}): MenuItem[] { + const run = (fn: () => unknown) => () => { + done(); + void fn(); + }; + return [ + { label: "Checkout", run: run(() => checkoutDetached(tag, tag)) }, + { label: `New Branch from '${tag}'…`, icon: "branch", run: run(() => newBranch(tag)) }, + { kind: "separator" }, + { label: "Compare with Current", icon: "diff", run: run(() => showCompareBranches(tag)) }, + { label: "Show Diff with Working Tree", run: run(() => showDiffWithWorkingTree(tag)) }, + { label: "Show History", icon: "history", run: run(() => executeAction("vcs.showLog", { branch: tag })) }, + ]; +} diff --git a/src/features/vcs/branchOps.ts b/src/features/vcs/branchOps.ts index 36392ea9..3f4802ef 100644 --- a/src/features/vcs/branchOps.ts +++ b/src/features/vcs/branchOps.ts @@ -75,6 +75,8 @@ async function performCheckout(rev: string, success: () => string): Promise call(commands.vcsCheckout(currentRepo(), rev, false, opts))); if (!outcome) return false; + // `success` may name the branch now checked out. + if (outcome.kind === "done" || outcome.kind === "conflicts") await refreshNow(); switch (outcome.kind) { case "done": notifications().notify({ group: "vcs", level: "info", title: success() }); diff --git a/src/features/vcs/branches.ts b/src/features/vcs/branches.ts index 8d3b4b06..6561e4ac 100644 --- a/src/features/vcs/branches.ts +++ b/src/features/vcs/branches.ts @@ -15,31 +15,28 @@ export type BranchGroup = "recent" | "local" | "remote"; export type BranchRow = | { type: "section"; key: string; label: string } | { type: "action"; key: string; action: PopupAction; matches: number[] | null } - | { type: "branch"; key: string; branch: Branch; group: BranchGroup; matches: number[] | null }; + | { + type: "branch"; + key: string; + branch: Branch; + group: BranchGroup; + matches: number[] | null; + favorite: boolean; + } + | { type: "tag"; key: string; tag: string; matches: number[] | null }; -const FAVORITES = ["main", "master", "develop", "trunk"]; - -export function sortLocal(branches: readonly Branch[]): Branch[] { - return branches - .filter((b) => !b.remote) - .sort((a, b) => { - if (a.current !== b.current) return a.current ? -1 : 1; - const fa = FAVORITES.indexOf(a.name); - const fb = FAVORITES.indexOf(b.name); - if (fa !== fb) return (fa < 0 ? 99 : fa) - (fb < 0 ? 99 : fb); - return a.name.localeCompare(b.name); - }); +/** Current branch first, then favorites, then the rest; by name within each. */ +function byRank(favorites: readonly string[] | null) { + const rank = (b: Branch) => (b.current ? 0 : isFavoriteBranch(b, favorites) ? 1 : 2); + return (a: Branch, b: Branch) => rank(a) - rank(b) || a.name.localeCompare(b.name); } -export function sortRemote(branches: readonly Branch[]): Branch[] { - return branches - .filter((b) => b.remote && !b.name.endsWith("/HEAD")) - .sort((a, b) => { - const fa = FAVORITES.indexOf(shortRemoteName(a.name)); - const fb = FAVORITES.indexOf(shortRemoteName(b.name)); - if (fa !== fb) return (fa < 0 ? 99 : fa) - (fb < 0 ? 99 : fb); - return a.name.localeCompare(b.name); - }); +export function sortLocal(branches: readonly Branch[], favorites: readonly string[] | null = null): Branch[] { + return branches.filter((b) => !b.remote).sort(byRank(favorites)); +} + +export function sortRemote(branches: readonly Branch[], favorites: readonly string[] | null = null): Branch[] { + return branches.filter((b) => b.remote && !b.name.endsWith("/HEAD")).sort(byRank(favorites)); } /** `origin/feature/x` → `feature/x`. */ @@ -52,15 +49,17 @@ export const MAX_RECENT = 5; /** * Rows for the Branches popup. Without a query: actions, Recent (recently - * checked-out local branches other than the current one), Local, Remote. - * With a query: matching actions and branches; the Recent group is dropped - * so each branch appears once. + * checked-out local branches other than the current one), Local, Remote, Tags. + * With a query: matching actions, branches and tags; the Recent group is dropped + * so each branch appears once. Favorites (★) come right after the current branch. */ export function buildBranchRows( branches: readonly Branch[], recent: readonly string[], query: string, actions: readonly PopupAction[], + favorites: readonly string[] | null = null, + tags: readonly string[] = [], ): BranchRow[] { const rows: BranchRow[] = []; const q = query.trim(); @@ -75,13 +74,20 @@ export function buildBranchRows( rows.push({ type: "action", key: `action:${a.id}`, action: a, matches: m }); } - const local = sortLocal(branches); + const local = sortLocal(branches, favorites); const pushGroup = (group: BranchGroup, label: string, list: readonly Branch[]) => { const items: BranchRow[] = []; for (const b of list) { const m = match(b.name); if (m === false) continue; - items.push({ type: "branch", key: `${group}:${b.name}`, branch: b, group, matches: m }); + items.push({ + type: "branch", + key: `${group}:${b.name}`, + branch: b, + group, + matches: m, + favorite: isFavoriteBranch(b, favorites), + }); } if (!items.length) return; rows.push({ type: "section", key: `section:${group}`, label }, ...items); @@ -96,7 +102,13 @@ export function buildBranchRows( pushGroup("recent", "Recent", rec); } pushGroup("local", "Local", local); - pushGroup("remote", "Remote", sortRemote(branches)); + pushGroup("remote", "Remote", sortRemote(branches, favorites)); + const tagRows: BranchRow[] = []; + for (const t of [...tags].sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))) { + const m = match(t); + if (m !== false) tagRows.push({ type: "tag", key: `tag:${t}`, tag: t, matches: m }); + } + if (tagRows.length) rows.push({ type: "section", key: "section:tags", label: "Tags" }, ...tagRows); return rows; } @@ -176,7 +188,10 @@ export function buildBranchTree(branches: readonly Branch[], favorites: readonly label: "Local", kind: "group", ref: null, - children: level(local.map((b) => ({ b, rest: b.name })), "f:local"), + children: level( + local.map((b) => ({ b, rest: b.name })), + "f:local", + ), }); if (byRemote.size) out.push({ @@ -189,7 +204,10 @@ export function buildBranchTree(branches: readonly Branch[], favorites: readonly label: r, kind: "remote", ref: null, - children: level(list.map((b) => ({ b, rest: b.name.slice(r.length + 1) })), `f:r:${r}`), + children: level( + list.map((b) => ({ b, rest: b.name.slice(r.length + 1) })), + `f:r:${r}`, + ), })), }); return out; diff --git a/src/features/vcs/changeOps.tsx b/src/features/vcs/changeOps.tsx index 831034cc..8b86eba8 100644 --- a/src/features/vcs/changeOps.tsx +++ b/src/features/vcs/changeOps.tsx @@ -2,13 +2,13 @@ import { useState } from "react"; import type { FileStatus, PartialCommitFile } from "@/bindings"; import { call, commands } from "@/platform/ipc"; -import { Checkbox, Dialog, Icon, fileIconName } from "@/ui"; +import { Checkbox, Dialog, Icon, Input, fileIconName } from "@/ui"; import { notifications } from "@/workbench/contributions"; import { commitRepos, groupOf, planCommit, renamedFromPath } from "./changes"; import { useChangelists, withListDefaults } from "./changelists"; import { runCommitChecks } from "./commitChecks"; import { forgetAmendDraft, rememberMessage, useCommit } from "./commitStore"; -import { confirmDialog, openDialog, promptText } from "./dialogs"; +import { confirmDialog, openDialog } from "./dialogs"; import { hunkKeys } from "./hunks"; import { KIND_COLOR, baseName, dirName, effectiveKind, relTo, rootOf, stripSlash } from "./model"; import { logConsole, runOp, saveAll } from "./ops"; @@ -16,7 +16,12 @@ import { currentRepo, loadStashes, refreshNow, useVcs } from "./store"; import styles from "./common.module.css"; export function firstLine(message: string): string { - return message.split("\n").find((l) => l.trim())?.trim() ?? ""; + return ( + message + .split("\n") + .find((l) => l.trim()) + ?.trim() ?? "" + ); } /** Stages the included changes and commits them. Returns the new commit id. */ @@ -29,7 +34,12 @@ export async function commitChanges(): Promise { await saveAll(); await refreshNow(); const status = useVcs.getState().status; - const checks = withListDefaults(status, useVcs.getState().root ?? "", useChangelists.getState().state, useCommit.getState().checks); + const checks = withListDefaults( + status, + useVcs.getState().root ?? "", + useChangelists.getState().state, + useCommit.getState().checks, + ); const plan = planCommit(status, checks); // Concluding a merge needs no further changes (e.g. every conflict resolved as "yours"). const merging = useVcs.getState().branch?.operation === "merge"; @@ -150,7 +160,10 @@ export function isNestedRepoEntry(path: string, roots: readonly string[] = useVc * sides (the old file is restored); added files become unversioned after * unstaging and are only deleted when asked to. */ -export function rollbackPlan(files: readonly FileStatus[], deleteAdded: boolean): { unstage: string[]; discard: string[] } { +export function rollbackPlan( + files: readonly FileStatus[], + deleteAdded: boolean, +): { unstage: string[]; discard: string[] } { const unstage: string[] = []; const discard: string[] = []; for (const f of files) { @@ -260,7 +273,11 @@ export function addToVcsTargets(paths: readonly string[], status: readonly FileS export async function addToVcs(paths: readonly string[]) { const targets = addToVcsTargets(paths, useVcs.getState().status); if (!targets.length) { - notifications().notify({ group: "vcs", level: "info", title: "Nothing to add: the files are already under version control" }); + notifications().notify({ + group: "vcs", + level: "info", + title: "Nothing to add: the files are already under version control", + }); return; } await runOp(`Add ${targets.length} file${targets.length === 1 ? "" : "s"} to Git`, () => @@ -268,31 +285,173 @@ export async function addToVcs(paths: readonly string[]) { ); } -export async function stashChanges() { - const r = await promptText({ - title: "Stash Changes", - label: "Message:", - placeholder: "WIP", - okLabel: "Create Stash", - validate: () => null, - checkbox: { label: "Include untracked files", initial: false }, +/** Stash Changes dialog: message, Keep index (staged changes stay), include untracked files. */ +export function stashChanges(): Promise { + return new Promise((resolve) => { + const close = openDialog(() => ( + { + close(); + if (r) { + await saveAll(); + await runOp( + "Stash changes", + () => call(commands.vcsStashPush(currentRepo(), r.message || null, r.includeUntracked, r.keepIndex)), + { success: "Changes stashed" }, + ); + void loadStashes().catch(() => undefined); + } + resolve(); + }} + /> + )); }); - if (!r) return; - await saveAll(); - await runOp("Stash changes", () => call(commands.vcsStashPush(currentRepo(), r.value || null, r.checked)), { - success: "Changes stashed", - }); - void loadStashes().catch(() => undefined); } -export async function applyStash(index: number, pop: boolean) { +function StashDialog({ + onDone, +}: { + onDone: (r: { message: string; keepIndex: boolean; includeUntracked: boolean } | null) => void; +}) { + const [message, setMessage] = useState(""); + const [keepIndex, setKeepIndex] = useState(false); + const [includeUntracked, setIncludeUntracked] = useState(false); + const branch = useVcs((s) => s.branch?.head ?? null); + const submit = () => onDone({ message: message.trim(), keepIndex, includeUntracked }); + return ( + onDone(null)} + width={440} + buttons={[ + { label: "Create Stash", role: "default", onClick: submit }, + { label: "Cancel", role: "cancel" }, + ]} + > + { + e.preventDefault(); + submit(); + }} + > + {branch &&
Current branch: {branch}
} + + setMessage(e.target.value)} + /> + + + +
+ ); +} + +export interface UnstashOptions { + index: number; + /** Drop the stash after applying it. */ + pop: boolean; + /** Also restore what was staged. */ + reinstateIndex: boolean; + /** Apply on a new branch created at the stash's base commit (implies pop and reinstate index). */ + branch: string | null; +} + +/** What an unstash does, for its progress title and success message. */ +export function unstashTitles(o: UnstashOptions): { title: string; success: string } { + const ref = `stash@{${o.index}}`; + if (o.branch) + return { title: `Unstash ${ref} as branch ${o.branch}`, success: `Unstashed ${ref} on branch ${o.branch}` }; + return o.pop + ? { title: `Pop ${ref}`, success: `Unstashed ${ref}` } + : { title: `Apply ${ref}`, success: `Applied ${ref}` }; +} + +export async function unstash(o: UnstashOptions) { await saveAll(); - await runOp(`${pop ? "Pop" : "Apply"} stash@{${index}}`, () => call(commands.vcsStashApply(currentRepo(), index, pop)), { - success: pop ? `Unstashed stash@{${index}}` : `Applied stash@{${index}}`, + const { title, success } = unstashTitles(o); + await runOp(title, () => call(commands.vcsStashApply(currentRepo(), o.index, o.pop, o.reinstateIndex, o.branch)), { + success, }); void loadStashes().catch((e) => logConsole("err", String(e))); } +export function applyStash(index: number, pop: boolean) { + return unstash({ index, pop, reinstateIndex: false, branch: null }); +} + +/** Unstash Changes dialog: pick a stash; Pop, Reinstate index, or As new branch. */ +export function showUnstashDialog(index = 0) { + void loadStashes().catch(() => undefined); + openDialog((close) => ); +} + +function UnstashDialog({ initial, onClose }: { initial: number; onClose: () => void }) { + const stashes = useVcs((s) => s.stashes); + const [index, setIndex] = useState(initial); + const [pop, setPop] = useState(false); + const [reinstateIndex, setReinstateIndex] = useState(false); + const [branch, setBranch] = useState(""); + const asBranch = branch.trim(); + const chosen = stashes.find((s) => s.index === index) ?? stashes[0]; + const submit = () => { + if (!chosen) return; + onClose(); + void unstash({ index: chosen.index, pop, reinstateIndex, branch: asBranch || null }); + }; + return ( + +
{ + e.preventDefault(); + submit(); + }} + > +
+ {stashes.length === 0 &&
No stashes
} + {stashes.map((s) => ( + + ))} +
+ + + + setBranch(e.target.value)} /> + +
+ ); +} + export async function dropStash(index: number, message: string) { const ok = await confirmDialog({ title: "Drop Stash", diff --git a/src/features/vcs/common.module.css b/src/features/vcs/common.module.css index 5f152961..2014098e 100644 --- a/src/features/vcs/common.module.css +++ b/src/features/vcs/common.module.css @@ -220,3 +220,9 @@ width: 220px; margin-left: 6px; } +.choiceList { + max-height: 240px; + overflow: auto; + border: 1px solid var(--border); + border-radius: var(--radius-small); +} diff --git a/src/features/vcs/conflicts.ts b/src/features/vcs/conflicts.ts index 6591743a..f51688bb 100644 --- a/src/features/vcs/conflicts.ts +++ b/src/features/vcs/conflicts.ts @@ -83,7 +83,11 @@ export type Resolution = "ours" | "theirs" | "both" | "base"; * Text replacing `block`. With `reversed` (a rebase, where git writes the upstream as * the `<<<<<<<` section and the user's commit as `>>>>>>>`) the sides swap, as IDEA does. */ -export function resolutionText(block: ConflictBlock, how: Resolution, reversed = false): string { +export function resolutionText( + block: Pick, + how: Resolution, + reversed = false, +): string { const yours = reversed ? block.theirs : block.ours; const theirs = reversed ? block.ours : block.theirs; switch (how) { @@ -133,6 +137,11 @@ export function mergeTitles(sides: MergeSides | null | undefined): { yours: stri yours: branch ? `Changes from ${branch}` : "Your version", theirs: other ? `Changes from ${other}` : commit ? `Changes from ${commit}` : "Changes from diverging branches", }; + case "unstash": + return { + yours: "Your uncommitted changes", + theirs: branch ? `Changes from ${branch}` : "Changes from remote", + }; case "cherry-pick": return { yours: "Local Changes", @@ -145,3 +154,39 @@ export function mergeTitles(sides: MergeSides | null | undefined): { yours: stri }; } } + +/** An unresolved conflict in the merge editor's result: `from`–`to` holds the base text. */ +export interface ResultConflict { + from: number; + to: number; + /** The user's side ("Accept Yours"). */ + ours: string; + base: string | null; + theirs: string; +} + +/** + * The merge editor's starting result, as IDEA shows it: the merged text with every + * conflict block replaced by its base section, so no markers appear. `reversed` swaps + * the sides of marker sections written in git's order (a rebase). + */ +export function initialResult(merged: string, reversed = false): { text: string; conflicts: ResultConflict[] } { + const conflicts: ResultConflict[] = []; + let text = ""; + let pos = 0; + for (const b of parseConflicts(merged)) { + text += merged.slice(pos, b.from); + const base = b.base ?? ""; + const from = text.length; + text += base; + conflicts.push({ + from, + to: text.length, + ours: reversed ? b.theirs : b.ours, + base: b.base, + theirs: reversed ? b.ours : b.theirs, + }); + pos = b.to; + } + return { text: text + merged.slice(pos), conflicts }; +} diff --git a/src/features/vcs/dialogs.tsx b/src/features/vcs/dialogs.tsx index 29305138..2c5d1545 100644 --- a/src/features/vcs/dialogs.tsx +++ b/src/features/vcs/dialogs.tsx @@ -129,3 +129,162 @@ export function confirmDialog(opts: ConfirmOptions): Promise { icon: opts.danger ? "warning" : "question", }); } + +export interface ChooseManyOptions { + title: string; + /** Values offered as checkboxes. */ + options: readonly string[]; + initial: readonly string[]; + /** Label of a free-text field for values not in `options` (comma-separated). */ + customLabel?: string; + okLabel?: string; +} + +/** Splits a comma-separated list, trimming blanks. */ +export function splitList(text: string): string[] { + return text + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +} + +/** Multi-select chooser (Log Branch / User filters › Select…); null when cancelled. */ +export function chooseMany(opts: ChooseManyOptions): Promise { + return new Promise((resolve) => { + const close = openDialog(() => ( + { + close(); + resolve(r); + }} + /> + )); + }); +} + +function ChooseManyDialog(props: ChooseManyOptions & { onDone: (r: string[] | null) => void }) { + const { title, options, initial, customLabel, okLabel = "OK", onDone } = props; + const [query, setQuery] = useState(""); + const [checked, setChecked] = useState(() => new Set(initial.filter((v) => options.includes(v)))); + const [custom, setCustom] = useState(() => initial.filter((v) => !options.includes(v)).join(", ")); + const q = query.trim().toLowerCase(); + const shown = q ? options.filter((o) => o.toLowerCase().includes(q)) : options; + const submit = () => onDone([...options.filter((o) => checked.has(o)), ...(customLabel ? splitList(custom) : [])]); + return ( + onDone(null)} + width={420} + buttons={[ + { label: okLabel, role: "default", onClick: submit }, + { label: "Cancel", role: "cancel" }, + ]} + > +
{ + e.preventDefault(); + submit(); + }} + > + setQuery(e.target.value)} + /> +
+ {shown.map((o) => ( +
+ + setChecked((s) => { + const n = new Set(s); + if (v) n.add(o); + else n.delete(o); + return n; + }) + } + /> +
+ ))} +
+ {customLabel && ( + <> + + setCustom(e.target.value)} /> + + )} +
+
+ ); +} + +/** Local date of an `` value (`2024-05-01`) as epoch ms at local midnight. */ +export function parseDay(value: string): number | null { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim()); + if (!m) return null; + return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])).getTime(); +} + +/** Date filter › Select…: `after` inclusive, `before` the day after the chosen "To" date. */ +export function chooseDateRange(): Promise<{ after: number | null; before: number | null } | null> { + return new Promise((resolve) => { + const close = openDialog(() => ( + { + close(); + resolve(r); + }} + /> + )); + }); +} + +function DateRangeDialog({ onDone }: { onDone: (r: { after: number | null; before: number | null } | null) => void }) { + const [from, setFrom] = useState(""); + const [to, setTo] = useState(""); + const after = parseDay(from); + const toDay = parseDay(to); + const before = toDay == null ? null : new Date(toDay).setDate(new Date(toDay).getDate() + 1); + const error = + after == null && before == null + ? "Choose a start or end date" + : after != null && before != null && after >= before + ? "The start date is after the end date" + : null; + const submit = () => { + if (!error) onDone({ after, before }); + }; + return ( + onDone(null)} + width={360} + buttons={[ + { label: "OK", role: "default", disabled: !!error, onClick: submit }, + { label: "Cancel", role: "cancel" }, + ]} + > +
{ + e.preventDefault(); + submit(); + }} + > + + setFrom(e.target.value)} /> + + setTo(e.target.value)} /> + {(from || to) && error &&
{error}
} +
+
+ ); +} diff --git a/src/features/vcs/logStore.ts b/src/features/vcs/logStore.ts index beb575d6..9fc58704 100644 --- a/src/features/vcs/logStore.ts +++ b/src/features/vcs/logStore.ts @@ -7,17 +7,28 @@ import { computeGraph, emptyGraphState, type GraphRow, type GraphState } from ". import { clearConsole, errorMessage } from "./ops"; import { currentRepo, repoOf, selectRepo, useVcs } from "./store"; +/** Date filter: commits made after / before the given times (epoch ms); applied client-side. */ +export interface LogDateFilter { + after: number | null; + before: number | null; + label: string; +} + +/** User filter entry standing for the current Git user (`user.name` / `user.email`). */ +export const ME = "me"; + export interface LogFilters { - branch: string | null; - author: string; + /** Branches / revisions to show; empty = all. */ + branches: string[]; + /** Author names or emails (any matches); `ME` = the current user. */ + authors: string[]; text: string; /** `text` is a regular expression / matches case-sensitively. */ regex: boolean; matchCase: boolean; /** Absolute path (file or directory). */ path: string | null; - /** Only commits newer than `ms` (epoch); applied client-side. */ - since: { ms: number; label: string } | null; + date: LogDateFilter | null; } interface LogState { @@ -43,15 +54,57 @@ interface LogState { export const PAGE = 300; export const emptyFilters = (): LogFilters => ({ - branch: null, - author: "", + branches: [], + authors: [], text: "", regex: false, matchCase: false, path: null, - since: null, + date: null, }); +/** Any filter other than the text field's toggles is set. */ +export function hasFilters(f: LogFilters): boolean { + return !!(f.branches.length || f.authors.length || f.text || f.path || f.date); +} + +/** `ME` replaced by the current user's email (or name); duplicates and blanks dropped. */ +export function resolveAuthors(authors: readonly string[], me: string | null): string[] { + const out = authors.map((a) => (a === ME ? (me ?? "") : a).trim()).filter(Boolean); + return [...new Set(out)]; +} + +/** The commit's time falls inside the date filter. */ +export function inDateRange(timeMs: number, d: LogDateFilter | null): boolean { + if (!d) return true; + return (d.after == null || timeMs >= d.after) && (d.before == null || timeMs < d.before); +} + +/** Label of a custom date range: `after 2024-01-01`, `until …`, or `a – b` (`before` is exclusive). */ +export function dateRangeLabel(after: number | null, before: number | null): string { + const day = (ms: number) => { + const d = new Date(ms); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + }; + const lastDay = (ms: number) => day(new Date(ms).setDate(new Date(ms).getDate() - 1)); + if (after != null && before != null) return `${day(after)} – ${lastDay(before)}`; + if (after != null) return `after ${day(after)}`; + if (before != null) return `until ${lastDay(before)}`; + return ""; +} + +let me: { repo: string | null; value: Promise } | null = null; + +/** The current Git user for the "me" filter: `user.email`, else `user.name`. */ +function currentUser(): Promise { + const repo = currentRepo(); + if (me?.repo !== repo) { + const get = (key: string) => call(commands.vcsConfigGet(repo, key)).catch(() => null); + me = { repo, value: get("user.email").then(async (email) => email || (await get("user.name"))) }; + } + return me.value; +} + const initialLog = (): Omit => ({ filters: emptyFilters(), commits: [], @@ -158,11 +211,18 @@ export function loadMore(): Promise { useGitLog.setState({ loading: true }); const p = (async () => { try { + const authors = resolveAuthors(f.authors, f.authors.includes(ME) ? await currentUser() : null); + if (f.authors.length && !authors.length) { + if (useGitLog.getState().generation === gen) useGitLog.setState({ done: true }); + return; + } const page = await call( commands.vcsLog(currentRepo(), { - branch: f.branch, + branch: null, + branches: f.branches, + authors, path: f.path, - author: f.author.trim() || null, + author: null, text: f.text.trim() || null, textRegex: f.regex, textMatchCase: f.matchCase, @@ -247,7 +307,7 @@ const MAX_SEARCH_PAGES = 30; export async function revealCommit(id: string) { useGitLog.setState({ tab: "log" }); const f = useGitLog.getState().filters; - const filtered = f.branch || f.author || f.text || f.path || f.since; + const filtered = hasFilters(f); const find = () => useGitLog.getState().commits.find((c) => c.id === id || c.id.startsWith(id)); if (filtered) { useGitLog.setState({ filters: emptyFilters() }); diff --git a/src/platform/editorSettings.ts b/src/platform/editorSettings.ts index b06fb007..1545d945 100644 --- a/src/platform/editorSettings.ts +++ b/src/platform/editorSettings.ts @@ -60,7 +60,7 @@ export const EDITOR_EXTRA_DEFAULTS = { caretScroll: "minimize", softWrapFiles: "*.md; *.txt; *.rst; *.adoc", softWrapIndent: true, - scrollPastEnd: true, + scrollPastEnd: false, stripTrailingScope: "modified", keepTrailingOnCaretLine: true, removeTrailingBlankLines: false, @@ -112,7 +112,7 @@ export const EDITOR_EXTRA_DEFAULTS = { pairQuotes: true, surroundSelection: true, tabOutOfPair: true, - reformatOnPaste: "indentBlock", + reformatOnPaste: "indentEachLine", smartIndent: true, backspaceUnindent: "autoindent", }, diff --git a/src/platform/keymap.test.ts b/src/platform/keymap.test.ts index 58b9fe45..82b12c24 100644 --- a/src/platform/keymap.test.ts +++ b/src/platform/keymap.test.ts @@ -1,5 +1,14 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { formatBinding, installKeyboardDispatcher, isAltGrTyping, normalizeBinding, strokeOf, useKeymap } from "./keymap"; +import { + formatBinding, + installKeyboardDispatcher, + isAltGrTyping, + isValidBinding, + normalizeBinding, + parseDoubleModifier, + strokeOf, + useKeymap, +} from "./keymap"; import { executeAction, registerAction } from "./actions"; import { usePlatform } from "./platformInfo"; @@ -167,6 +176,68 @@ describe("keymap", () => { }); }); + describe("double modifier with a key", () => { + let uninstall = () => {}; + afterEach(() => uninstall()); + + it("parses, validates and formats the gesture", () => { + expect(parseDoubleModifier("alt alt+up")).toEqual({ modifier: "Alt", key: "up" }); + expect(parseDoubleModifier("Ctrl Ctrl")).toEqual({ modifier: "Ctrl", key: null }); + expect(parseDoubleModifier("Alt Ctrl+Up")).toBeNull(); + expect(isValidBinding("Alt Alt+Up")).toBe(true); + expect(normalizeBinding("alt ALT+Down")).toBe("Alt Alt+Down"); + expect(formatBinding("Alt Alt+Up", true)).toBe("⌥⌥↑"); + expect(formatBinding("Ctrl Ctrl+Up", false)).toBe("Ctrl, Ctrl+Up"); + }); + + function setup() { + const above = vi.fn(); + const anything = vi.fn(); + registerAction({ id: "t.cloneAbove", title: "clone above", run: above }); + registerAction({ id: "t.ctrlCtrl", title: "ctrl ctrl", run: anything }); + useKeymap.setState({ overrides: { "t.cloneAbove": ["Alt Alt+Up"], "t.ctrlCtrl": ["Ctrl Ctrl"] } }); + let t = 1000; + uninstall = installKeyboardDispatcher(window, () => t); + const mod = (type: "keydown" | "keyup", key: "Alt" | "Control", ms: number) => { + t = ms; + const held = type === "keydown"; + window.dispatchEvent(new KeyboardEvent(type, { key, code: `${key}Left`, altKey: key === "Alt" && held, ctrlKey: key === "Control" && held })); + }; + const up = (ms: number) => { + t = ms; + const e = new KeyboardEvent("keydown", { key: "ArrowUp", code: "ArrowUp", altKey: true, cancelable: true }); + window.dispatchEvent(e); + return e; + }; + return { above, anything, mod, up }; + } + + it("runs while the second press is held, once per key press", async () => { + const { above, mod, up } = setup(); + mod("keydown", "Alt", 1000); + mod("keyup", "Alt", 1100); + mod("keydown", "Alt", 1200); + expect(up(1500).defaultPrevented).toBe(true); + up(1700); + mod("keyup", "Alt", 1800); + await Promise.resolve(); + expect(above).toHaveBeenCalledTimes(2); + // A single press is plain Alt+Up again. + mod("keydown", "Alt", 3000); + expect(up(3100).defaultPrevented).toBe(false); + }); + + it("fires a bare double tap of another modifier on its second release", async () => { + const { anything, mod } = setup(); + mod("keydown", "Control", 1000); + mod("keyup", "Control", 1050); + mod("keydown", "Control", 1150); + mod("keyup", "Control", 1200); + await Promise.resolve(); + expect(anything).toHaveBeenCalledOnce(); + }); + }); + describe("AltGr", () => { let uninstall = () => {}; afterEach(() => { diff --git a/src/platform/keymap.ts b/src/platform/keymap.ts index 81caa76f..a5aa7acb 100644 --- a/src/platform/keymap.ts +++ b/src/platform/keymap.ts @@ -3,7 +3,8 @@ * * Binding syntax: modifiers `Meta Ctrl Alt Shift` joined with `+` to a key, e.g. * `Meta+Shift+O`, `Alt+F7`, `F8`. Space separates chord strokes - * (`Meta+K Meta+C`). The special binding `Shift Shift` is IDEA's double-Shift. + * (`Meta+K Meta+C`). A modifier pressed twice is `Shift Shift` (IDEA's double + * Shift, `Ctrl Ctrl`…); pressed twice and held, then a key, `Alt Alt+Up`. * Keys are matched by physical position (`KeyboardEvent.code`), so ⌥-letter * combos work on macOS where `event.key` would be a symbol. */ @@ -66,11 +67,24 @@ export const useKeymap = create(() => ({ preset: defaultPreset(), o const STROKE_RE = /^(?:(?:Ctrl|Alt|Shift|Meta)\+)*[^\s+]+$|^(?:(?:Ctrl|Alt|Shift|Meta)\+)*\+$/i; -/** Syntactic check: one or two strokes of `Mod+…+Key`, or `Shift Shift`. */ +const MODIFIERS = ["Ctrl", "Alt", "Shift", "Meta"] as const; +export type Modifier = (typeof MODIFIERS)[number]; +const DOUBLE_RE = /^(Ctrl|Alt|Shift|Meta) (Ctrl|Alt|Shift|Meta)(?:\+([^\s+]+|\+))?$/i; +const modifierNamed = (name: string) => MODIFIERS.find((m) => m.toLowerCase() === name.toLowerCase())!; + +/** A double-modifier gesture: `Shift Shift` (key null) or `Alt Alt+Up`. */ +export function parseDoubleModifier(binding: string): { modifier: Modifier; key: string | null } | null { + const m = DOUBLE_RE.exec(binding.trim()); + if (!m || m[1]!.toLowerCase() !== m[2]!.toLowerCase()) return null; + const key = m[3] ?? null; + return { modifier: modifierNamed(m[1]!), key: key && key.length === 1 ? key.toUpperCase() : key }; +} + +/** Syntactic check: one or two strokes of `Mod+…+Key`, or a double-modifier gesture. */ export function isValidBinding(binding: unknown): binding is string { if (typeof binding !== "string") return false; const b = binding.trim(); - if (b === "Shift Shift") return true; + if (parseDoubleModifier(b)) return true; const strokes = b.split(/\s+/); return strokes.length >= 1 && strokes.length <= 2 && strokes.every((s) => STROKE_RE.test(s)); } @@ -131,15 +145,16 @@ export function effectiveBindings(): Array<[string, string]> { return Object.entries(merged).flatMap(([id, keys]) => keys.map((k) => [normalizeBinding(k), id] as [string, string])); } -const MOD_ORDER = ["Ctrl", "Alt", "Shift", "Meta"] as const; +const MOD_ORDER = MODIFIERS; /** Canonical form: modifiers in fixed order, so `Shift+Meta+o` == `Meta+Shift+O`. */ export function normalizeBinding(binding: string): string { + const double = parseDoubleModifier(binding); + if (double) return `${double.modifier} ${double.modifier}${double.key ? `+${double.key}` : ""}`; return binding .trim() .split(/\s+/) .map((stroke) => { - if (stroke === "Shift" || stroke === "Shift Shift") return stroke; const parts = stroke.split("+"); const key = parts.pop()!; const mods = MOD_ORDER.filter((m) => parts.some((p) => p.toLowerCase() === m.toLowerCase())); @@ -220,12 +235,13 @@ const SYMBOLS: Record = { }; /** - * Human display. macOS: `Meta+Shift+O` → `⇧⌘O`, `Shift Shift` → `⇧⇧`. - * Windows/Linux: `Ctrl+Shift+N`, chords as `Ctrl+K, Ctrl+I`, `Shift, Shift`. + * Human display. macOS: `Meta+Shift+O` → `⇧⌘O`, `Shift Shift` → `⇧⇧`, `Alt Alt+Up` → `⌥⌥↑`. + * Windows/Linux: `Ctrl+Shift+N`, chords as `Ctrl+K, Ctrl+I`, `Shift, Shift`, `Ctrl, Ctrl+Up`. */ export function formatBinding(binding: string, mac = isMacHost()): string { - if (!mac) return binding === "Shift Shift" ? "Shift, Shift" : normalizeBinding(binding).split(" ").join(", "); - if (binding === "Shift Shift") return "⇧⇧"; + if (!mac) return normalizeBinding(binding).split(" ").join(", "); + const double = parseDoubleModifier(binding); + if (double) return SYMBOLS[double.modifier]!.repeat(2) + (double.key ? (SYMBOLS[double.key] ?? double.key) : ""); return normalizeBinding(binding) .split(" ") .map((stroke) => { @@ -249,9 +265,18 @@ export function shortcutLabel(actionId: string): string | undefined { const CHORD_TIMEOUT = 2000; /** IDEA's double-modifier timing: each press/release step. */ const DOUBLE_TAP_STEP_MS = 300; -/** A Shift tap this soon after typing another key is part of typing, not a gesture. */ +/** A modifier tap this soon after typing another key is part of typing, not a gesture. */ const DOUBLE_TAP_TYPING_GUARD_MS = 100; +const KEY_MODIFIER: Record = { Shift: "Shift", Control: "Ctrl", Alt: "Alt", Meta: "Meta" }; + +function modifierHeld(e: Pick, m: Modifier): boolean { + return m === "Ctrl" ? e.ctrlKey : m === "Alt" ? e.altKey : m === "Shift" ? e.shiftKey : e.metaKey; +} + +const otherModifiers = (e: Pick, m: Modifier) => + MODIFIERS.some((x) => x !== m && modifierHeld(e, x)); + function isPlainTextField(target: EventTarget | null): boolean { const el = target as HTMLElement | null; if (!el || !el.tagName) return false; @@ -293,44 +318,67 @@ function runIfEnabled(id: string): boolean { /** * Installs the global keydown dispatcher (capture phase, so IDE shortcuts win * over CodeMirror defaults). Returns an uninstall function. `now` is the clock - * for the double-Shift gesture. + * for the double-modifier gestures. */ export function installKeyboardDispatcher(target: Window = window, now: () => number = () => performance.now()): () => void { let pending: string[] = []; let pendingTimer: ReturnType | undefined; - // Double Shift: 0 idle, 1 pressed, 2 released, 3 pressed again; fires on the second release. - let shiftStep = 0; - let shiftAt = 0; + /** + * Double modifier: step 1 pressed, 2 released, 3 pressed again. A bare + * gesture (`Shift Shift`) fires on the second release; while the second + * press is held, keys fire their `Alt Alt+Up` gestures. + */ + let tap: { modifier: Modifier; step: 1 | 2 | 3; at: number; typed: boolean; keyUsed: boolean } | null = null; let otherKeyAt = -Infinity; const reset = () => { pending = []; clearTimeout(pendingTimer); }; - const resetShift = () => { - shiftStep = 0; + const resetTap = () => { + tap = null; }; - const onShiftDown = (e: KeyboardEvent) => { + const onModifierDown = (e: KeyboardEvent, modifier: Modifier) => { const t = now(); - if (e.repeat || e.metaKey || e.ctrlKey || e.altKey || t - otherKeyAt < DOUBLE_TAP_TYPING_GUARD_MS) return resetShift(); - if (shiftStep !== 0 && t - shiftAt > DOUBLE_TAP_STEP_MS) resetShift(); - if (shiftStep === 0 || shiftStep === 2) { - shiftStep++; - shiftAt = t; - } else { - resetShift(); + if (e.repeat && tap?.step === 3 && tap.modifier === modifier) return; + if (e.repeat || otherModifiers(e, modifier)) return resetTap(); + const typed = t - otherKeyAt < DOUBLE_TAP_TYPING_GUARD_MS; + if (tap && (tap.modifier !== modifier || t - tap.at > DOUBLE_TAP_STEP_MS)) resetTap(); + if (!tap) tap = { modifier, step: 1, at: t, typed, keyUsed: false }; + else if (tap.step === 2) tap = { ...tap, step: 3, at: t, typed: tap.typed || typed }; + else resetTap(); + }; + + /** A key pressed while the second press of a modifier is held: its `Alt Alt+Up` gesture. */ + const runHeldGesture = (e: KeyboardEvent): boolean => { + if (tap?.step !== 3 || !modifierHeld(e, tap.modifier) || otherModifiers(e, tap.modifier)) return false; + const key = strokeOf(e)?.split("+").pop(); + if (!key) return false; + const binding = normalizeBinding(`${tap.modifier} ${tap.modifier}+${key}`); + const ids = effectiveBindings().filter(([b]) => b === binding).map(([, id]) => id); + for (const id of ids.reverse()) { + const action = getAction(id); + if (!action || !isActionKeyEnabled(action)) continue; + e.preventDefault(); + e.stopPropagation(); + tap.keyUsed = true; + void executeAction(id); + return true; } + return false; }; const onKeyDown = (e: KeyboardEvent) => { if (suspended > 0) { reset(); - resetShift(); + resetTap(); return; } - if (e.key === "Shift") return onShiftDown(e); - resetShift(); + const modifier = KEY_MODIFIER[e.key]; + if (modifier) return onModifierDown(e, modifier); + if (runHeldGesture(e)) return reset(); + resetTap(); otherKeyAt = e.key === "Escape" || e.key === "Tab" ? -Infinity : now(); if (e.isComposing) return; const stroke = strokeOf(e); @@ -374,27 +422,27 @@ export function installKeyboardDispatcher(target: Window = window, now: () => nu }; const onKeyUp = (e: KeyboardEvent) => { - if (suspended > 0 || e.key !== "Shift" || shiftStep === 0) return; + const modifier = KEY_MODIFIER[e.key]; + if (suspended > 0 || !modifier || !tap || tap.modifier !== modifier) return; const t = now(); - if (e.metaKey || e.ctrlKey || e.altKey || t - shiftAt > DOUBLE_TAP_STEP_MS) return resetShift(); - if (shiftStep === 1) { - shiftStep = 2; - shiftAt = t; + if (otherModifiers(e, modifier) || t - tap.at > DOUBLE_TAP_STEP_MS) return resetTap(); + if (tap.step === 1) { + tap = { ...tap, step: 2, at: t }; return; } - const fire = shiftStep === 3; - resetShift(); + const fire = tap.step === 3 && !tap.keyUsed && !tap.typed; + resetTap(); if (!fire) return; - const ids = effectiveBindings().filter(([b]) => b === "Shift Shift").map(([, id]) => id); + const ids = effectiveBindings().filter(([b]) => b === `${modifier} ${modifier}`).map(([, id]) => id); for (const id of ids.reverse()) if (runIfEnabled(id)) return; }; // Shift+click (extend a selection) is not half of a double Shift. - const onMouseDown = () => resetShift(); + const onMouseDown = () => resetTap(); const onBlur = () => { reset(); - resetShift(); + resetTap(); }; target.addEventListener("keydown", onKeyDown, true); diff --git a/src/platform/keymaps/ideaParity.test.ts b/src/platform/keymaps/ideaParity.test.ts index f60c61ee..5faf6f26 100644 --- a/src/platform/keymaps/ideaParity.test.ts +++ b/src/platform/keymaps/ideaParity.test.ts @@ -36,6 +36,17 @@ describe("IDEA keymap parity", () => { ["project.copy", ["Meta+C"], ["Ctrl+C", "Ctrl+Insert"]], ["project.cut", ["Meta+X"], ["Ctrl+X", "Shift+Delete"]], ["project.paste", ["Meta+V"], ["Ctrl+V", "Shift+Insert"]], + ["editor.matchBrace", ["Ctrl+M"], ["Ctrl+Shift+M"]], + ["code.collapseRecursively", ["Meta+Alt+-"], ["Ctrl+Alt+-"]], + ["code.expandRecursively", ["Meta+Alt+="], ["Ctrl+Alt+="]], + ["editor.cloneCaretAbove", ["Alt Alt+Up"], ["Ctrl Ctrl+Up"]], + ["editor.cloneCaretBelow", ["Alt Alt+Down"], ["Ctrl Ctrl+Down"]], + ["editor.toggleInsertState", ["Insert"], ["Insert"]], + ["navigate.nextOccurrence", ["Meta+Alt+Down"], ["Ctrl+Alt+Down"]], + ["navigate.prevOccurrence", ["Meta+Alt+Up"], ["Ctrl+Alt+Up"]], + // Rerun in the Find / Find Usages tool windows uses the Rerun shortcut. + ["find.rerun", ["Meta+R"], ["Ctrl+F5"]], + ["usages.rerun", ["Meta+R"], ["Ctrl+F5"]], ])("%s", (id, mac, win) => { expect(intellijMacos[id]).toEqual(mac); expect(intellijWindows[id]).toEqual(win); @@ -56,5 +67,6 @@ describe("IDEA keymap parity", () => { order(km, "code.fold", "project.collapseAll"); order(km, "navigate.nextError", "vcs.changelist.edit"); order(km, "debug.setValue", "vcs.changelist.edit"); + order(km, "find.replaceInFile", "find.rerun"); }); }); diff --git a/src/platform/keymaps/intellij-macos.ts b/src/platform/keymaps/intellij-macos.ts index a6f60897..dd377dbc 100644 --- a/src/platform/keymaps/intellij-macos.ts +++ b/src/platform/keymaps/intellij-macos.ts @@ -28,6 +28,27 @@ export const intellijMacos: Record = { "navigate.typeHierarchy": ["Ctrl+H"], "navigate.toggleBookmark": ["F3"], "navigate.showBookmarks": ["Meta+F3"], + "navigate.toggleBookmarkWithMnemonic": ["Alt+F3"], + "navigate.toggleBookmark0": ["Ctrl+Shift+0"], + "navigate.toggleBookmark1": ["Ctrl+Shift+1"], + "navigate.toggleBookmark2": ["Ctrl+Shift+2"], + "navigate.toggleBookmark3": ["Ctrl+Shift+3"], + "navigate.toggleBookmark4": ["Ctrl+Shift+4"], + "navigate.toggleBookmark5": ["Ctrl+Shift+5"], + "navigate.toggleBookmark6": ["Ctrl+Shift+6"], + "navigate.toggleBookmark7": ["Ctrl+Shift+7"], + "navigate.toggleBookmark8": ["Ctrl+Shift+8"], + "navigate.toggleBookmark9": ["Ctrl+Shift+9"], + "navigate.gotoBookmark0": ["Ctrl+0"], + "navigate.gotoBookmark1": ["Ctrl+1"], + "navigate.gotoBookmark2": ["Ctrl+2"], + "navigate.gotoBookmark3": ["Ctrl+3"], + "navigate.gotoBookmark4": ["Ctrl+4"], + "navigate.gotoBookmark5": ["Ctrl+5"], + "navigate.gotoBookmark6": ["Ctrl+6"], + "navigate.gotoBookmark7": ["Ctrl+7"], + "navigate.gotoBookmark8": ["Ctrl+8"], + "navigate.gotoBookmark9": ["Ctrl+9"], "navigate.selectIn": ["Alt+F1"], "navigate.lastEditLocation": ["Meta+Shift+Backspace"], "navigate.nextEditLocation": [], @@ -35,6 +56,8 @@ export const intellijMacos: Record = { "navigate.superMethod": ["Meta+U"], "navigate.nextMethod": ["Ctrl+Shift+Down"], "navigate.previousMethod": ["Ctrl+Shift+Up"], + "navigate.nextOccurrence": ["Meta+Alt+Down"], + "navigate.prevOccurrence": ["Meta+Alt+Up"], // Find "find.inFile": ["Meta+F"], @@ -54,6 +77,7 @@ export const intellijMacos: Record = { "code.quickDefinition": ["Alt+Space", "Meta+Y"], "code.showIntentions": ["Alt+Enter"], "code.reformat": ["Meta+Alt+L"], + "code.reformatDialog": ["Meta+Alt+Shift+L"], "code.optimizeImports": ["Ctrl+Alt+O"], "code.commentLine": ["Meta+/"], "code.commentBlock": ["Meta+Alt+/", "Ctrl+Shift+/"], @@ -72,6 +96,8 @@ export const intellijMacos: Record = { "code.unfold": ["Meta+="], "code.foldAll": ["Meta+Shift+-"], "code.unfoldAll": ["Meta+Shift+="], + "code.collapseRecursively": ["Meta+Alt+-"], + "code.expandRecursively": ["Meta+Alt+="], "code.addCaretNextOccurrence": ["Ctrl+G"], "code.selectAllOccurrences": ["Ctrl+Meta+G"], "code.unselectOccurrence": ["Ctrl+Shift+G"], @@ -83,9 +109,11 @@ export const intellijMacos: Record = { "editor.scrollToCenter": ["Ctrl+L"], "editor.codeBlockStart": ["Meta+Alt+["], "editor.codeBlockEnd": ["Meta+Alt+]"], - // IDEA clones carets with ⌥⌥↑ / ⌥⌥↓ (double modifier); no single-stroke default. - "editor.cloneCaretAbove": [], - "editor.cloneCaretBelow": [], + // ⌥ pressed twice and held, then ↑ / ↓. + "editor.cloneCaretAbove": ["Alt Alt+Up"], + "editor.cloneCaretBelow": ["Alt Alt+Down"], + "editor.matchBrace": ["Ctrl+M"], + "editor.toggleInsertState": ["Insert"], "code.joinLines": ["Ctrl+Shift+J"], "code.toggleCase": ["Meta+Shift+U"], "code.pasteFromHistory": ["Meta+Shift+V"], @@ -184,6 +212,9 @@ export const intellijMacos: Record = { // Rerun in the Run / Debug tool window only (when / keyWhen); Meta+R stays with the editor elsewhere. "run.rerun": ["Meta+R"], "debug.rerun": ["Meta+R"], + // Rerun in the Find / Find Usages tool windows only (when). + "find.rerun": ["Meta+R"], + "usages.rerun": ["Meta+R"], "run.chooseConfig": ["Ctrl+Alt+R"], "run.editConfigs": ["Ctrl+Alt+Shift+R"], "debug.toggleBreakpoint": ["Meta+F8"], @@ -283,4 +314,10 @@ export const intellijMacos: Record = { "terminal.new": ["Meta+T"], // Last: later bindings win, so ⌘N is Generate in the editor and New in tool windows. "code.generate": ["Meta+N"], + // Find in Files popup (findPopupOpen): after the editor's Find Next, Jump to Source and + // selection keys, so these win while the popup is open. + "find.popup.nextResult": ["Meta+G"], + "find.popup.previousResult": ["Meta+Shift+G"], + "find.popup.openResult": ["F4", "Meta+Down"], + "find.popup.recentSearches": ["Alt+Down"], }; diff --git a/src/platform/keymaps/intellij-windows.ts b/src/platform/keymaps/intellij-windows.ts index cef0c102..3a923c87 100644 --- a/src/platform/keymaps/intellij-windows.ts +++ b/src/platform/keymaps/intellij-windows.ts @@ -29,6 +29,27 @@ export const intellijWindows: Record = { "navigate.typeHierarchy": ["Ctrl+H"], "navigate.toggleBookmark": ["F11"], "navigate.showBookmarks": ["Shift+F11"], + "navigate.toggleBookmarkWithMnemonic": ["Ctrl+F11"], + "navigate.toggleBookmark0": ["Ctrl+Shift+0"], + "navigate.toggleBookmark1": ["Ctrl+Shift+1"], + "navigate.toggleBookmark2": ["Ctrl+Shift+2"], + "navigate.toggleBookmark3": ["Ctrl+Shift+3"], + "navigate.toggleBookmark4": ["Ctrl+Shift+4"], + "navigate.toggleBookmark5": ["Ctrl+Shift+5"], + "navigate.toggleBookmark6": ["Ctrl+Shift+6"], + "navigate.toggleBookmark7": ["Ctrl+Shift+7"], + "navigate.toggleBookmark8": ["Ctrl+Shift+8"], + "navigate.toggleBookmark9": ["Ctrl+Shift+9"], + "navigate.gotoBookmark0": ["Ctrl+0"], + "navigate.gotoBookmark1": ["Ctrl+1"], + "navigate.gotoBookmark2": ["Ctrl+2"], + "navigate.gotoBookmark3": ["Ctrl+3"], + "navigate.gotoBookmark4": ["Ctrl+4"], + "navigate.gotoBookmark5": ["Ctrl+5"], + "navigate.gotoBookmark6": ["Ctrl+6"], + "navigate.gotoBookmark7": ["Ctrl+7"], + "navigate.gotoBookmark8": ["Ctrl+8"], + "navigate.gotoBookmark9": ["Ctrl+9"], "navigate.selectIn": ["Alt+F1"], "navigate.lastEditLocation": ["Ctrl+Shift+Backspace"], "navigate.nextEditLocation": [], @@ -36,6 +57,8 @@ export const intellijWindows: Record = { "navigate.superMethod": ["Ctrl+U"], "navigate.nextMethod": ["Alt+Down"], "navigate.previousMethod": ["Alt+Up"], + "navigate.nextOccurrence": ["Ctrl+Alt+Down"], + "navigate.prevOccurrence": ["Ctrl+Alt+Up"], // Find "find.inFile": ["Ctrl+F"], @@ -55,6 +78,7 @@ export const intellijWindows: Record = { "code.quickDefinition": ["Ctrl+Shift+I"], "code.showIntentions": ["Alt+Enter"], "code.reformat": ["Ctrl+Alt+L"], + "code.reformatDialog": ["Ctrl+Alt+Shift+L"], "code.optimizeImports": ["Ctrl+Alt+O"], "code.commentLine": ["Ctrl+/"], "code.commentBlock": ["Ctrl+Shift+/"], @@ -73,6 +97,8 @@ export const intellijWindows: Record = { "code.unfold": ["Ctrl+="], "code.foldAll": ["Ctrl+Shift+-"], "code.unfoldAll": ["Ctrl+Shift+="], + "code.collapseRecursively": ["Ctrl+Alt+-"], + "code.expandRecursively": ["Ctrl+Alt+="], "code.addCaretNextOccurrence": ["Alt+J"], "code.selectAllOccurrences": ["Ctrl+Alt+Shift+J"], "code.unselectOccurrence": ["Alt+Shift+J"], @@ -84,9 +110,11 @@ export const intellijWindows: Record = { "editor.scrollToCenter": ["Ctrl+M"], "editor.codeBlockStart": ["Ctrl+["], "editor.codeBlockEnd": ["Ctrl+]"], - // IDEA clones carets with Ctrl, Ctrl+Up / Down (double modifier); no single-stroke default. - "editor.cloneCaretAbove": [], - "editor.cloneCaretBelow": [], + // Ctrl pressed twice and held, then Up / Down. + "editor.cloneCaretAbove": ["Ctrl Ctrl+Up"], + "editor.cloneCaretBelow": ["Ctrl Ctrl+Down"], + "editor.matchBrace": ["Ctrl+Shift+M"], + "editor.toggleInsertState": ["Insert"], "code.joinLines": ["Ctrl+Shift+J"], "code.toggleCase": ["Ctrl+Shift+U"], "code.pasteFromHistory": ["Ctrl+Shift+V"], @@ -182,6 +210,9 @@ export const intellijWindows: Record = { // Rerun in the Run / Debug tool window only (when / keyWhen); Ctrl+F5 stays with the editor elsewhere. "run.rerun": ["Ctrl+F5"], "debug.rerun": ["Ctrl+F5"], + // Rerun in the Find / Find Usages tool windows only (when). + "find.rerun": ["Ctrl+F5"], + "usages.rerun": ["Ctrl+F5"], "run.chooseConfig": ["Alt+Shift+F10"], "run.editConfigs": [], "debug.toggleBreakpoint": ["Ctrl+F8"], @@ -280,4 +311,10 @@ export const intellijWindows: Record = { "terminal.new": ["Ctrl+Shift+T"], // Last: later bindings win, so Alt+Insert is Generate in the editor and New in tool windows. "code.generate": ["Alt+Insert"], + // Find in Files popup (findPopupOpen): after the editor's Find Next, Jump to Source and + // selection keys, so these win while the popup is open. + "find.popup.nextResult": ["F3", "Ctrl+L"], + "find.popup.previousResult": ["Shift+F3", "Ctrl+Shift+L"], + "find.popup.openResult": ["F4"], + "find.popup.recentSearches": ["Alt+Down"], }; diff --git a/src/platform/keymaps/vscode-macos.ts b/src/platform/keymaps/vscode-macos.ts index 4f5df335..86e61b64 100644 --- a/src/platform/keymaps/vscode-macos.ts +++ b/src/platform/keymaps/vscode-macos.ts @@ -29,6 +29,27 @@ export const vscodeMacos: Record = { "navigate.typeHierarchy": [], "navigate.toggleBookmark": [], "navigate.showBookmarks": [], + "navigate.toggleBookmarkWithMnemonic": [], + "navigate.toggleBookmark0": [], + "navigate.toggleBookmark1": [], + "navigate.toggleBookmark2": [], + "navigate.toggleBookmark3": [], + "navigate.toggleBookmark4": [], + "navigate.toggleBookmark5": [], + "navigate.toggleBookmark6": [], + "navigate.toggleBookmark7": [], + "navigate.toggleBookmark8": [], + "navigate.toggleBookmark9": [], + "navigate.gotoBookmark0": [], + "navigate.gotoBookmark1": [], + "navigate.gotoBookmark2": [], + "navigate.gotoBookmark3": [], + "navigate.gotoBookmark4": [], + "navigate.gotoBookmark5": [], + "navigate.gotoBookmark6": [], + "navigate.gotoBookmark7": [], + "navigate.gotoBookmark8": [], + "navigate.gotoBookmark9": [], "navigate.selectIn": [], "navigate.lastEditLocation": [], "navigate.nextEditLocation": [], @@ -36,6 +57,8 @@ export const vscodeMacos: Record = { "navigate.superMethod": [], "navigate.nextMethod": [], "navigate.previousMethod": [], + "navigate.nextOccurrence": [], + "navigate.prevOccurrence": [], // Find "find.inFile": ["Meta+F"], @@ -56,6 +79,7 @@ export const vscodeMacos: Record = { "code.showIntentions": ["Meta+."], "code.generate": [], "code.reformat": ["Alt+Shift+F"], + "code.reformatDialog": [], "code.optimizeImports": ["Alt+Shift+O"], "code.commentLine": ["Meta+/"], "code.commentBlock": ["Alt+Shift+A"], @@ -73,6 +97,8 @@ export const vscodeMacos: Record = { "code.fold": ["Meta+Alt+["], "code.unfold": ["Meta+Alt+]"], "code.foldAll": ["Meta+K Meta+0"], + "code.collapseRecursively": ["Meta+K Meta+["], + "code.expandRecursively": ["Meta+K Meta+]"], "code.unfoldAll": ["Meta+K Meta+J"], "code.addCaretNextOccurrence": ["Meta+D"], "code.selectAllOccurrences": ["Meta+Shift+L"], @@ -128,6 +154,8 @@ export const vscodeMacos: Record = { "editor.codeBlockEnd": [], "editor.cloneCaretAbove": ["Meta+Alt+Up"], "editor.cloneCaretBelow": ["Meta+Alt+Down"], + "editor.matchBrace": ["Meta+Shift+\\"], + "editor.toggleInsertState": [], // Explorer (when: projectViewFocus) "project.newFile": [], @@ -200,6 +228,8 @@ export const vscodeMacos: Record = { "debug.jumpToSource": [], "debug.evaluate": [], "debug.rerun": [], + "find.rerun": [], + "usages.rerun": [], // VCS "vcs.operations": [], @@ -274,4 +304,9 @@ export const vscodeMacos: Record = { "db.sqlScript": [], "db.goToReferencedTable": [], "db.collapseAll": [], + // Find in Files popup (findPopupOpen): after the editor's keys, so these win while it is open. + "find.popup.nextResult": ["Meta+G"], + "find.popup.previousResult": ["Meta+Shift+G"], + "find.popup.openResult": [], + "find.popup.recentSearches": ["Alt+Down"], }; diff --git a/src/platform/keymaps/vscode-windows.ts b/src/platform/keymaps/vscode-windows.ts index 931d15bd..18d46284 100644 --- a/src/platform/keymaps/vscode-windows.ts +++ b/src/platform/keymaps/vscode-windows.ts @@ -25,6 +25,8 @@ export const vscodeWindows: Record = { "find.replaceInFile": ["Ctrl+H"], "find.next": ["F3"], "find.previous": ["Shift+F3"], + "find.popup.nextResult": ["F3"], + "find.popup.previousResult": ["Shift+F3"], "code.extendSelection": ["Alt+Shift+Right"], "code.shrinkSelection": ["Alt+Shift+Left"], "code.fold": ["Ctrl+Shift+["], diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index ad97b4aa..d38d056a 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -109,6 +109,7 @@ "Approve…": "批准…", "Arguments": "实参", "Arrow function": "箭头函数", + "As new branch:": "作为新分支:", "As typed": "按输入", "Ask": "询问", "Asks for a value when the tool runs": "工具运行时询问一个值", @@ -130,6 +131,7 @@ "Auto-update if push of the current branch was rejected": "当前分支推送被拒绝时自动更新", "Autosave": "自动保存", "Autoscroll from Source": "从源代码自动滚动", + "Autoscroll to Source": "自动滚动到源代码", "Available": "可用", "Available plugins": "可用插件", "Back": "后退", @@ -260,7 +262,9 @@ "Children": "子项", "Choose Content to Paste": "选择要粘贴的内容", "Choose a data source for this console": "为此控制台选择数据源", + "Choose a start or end date": "请选择开始或结束日期", "Choose configuration to debug": "选择要调试的配置", + "Choose configuration to run": "选择要运行的配置", "Choose the Statement to Unwrap/Remove": "选择要解包/移除的语句", "Choose which call on the current line to step into": "选择要步入当前行中的哪个调用", "Class": "类", @@ -354,6 +358,7 @@ "Compare with Branch or Revision…": "与分支或修订比较…", "Compare with Branch…": "与分支比较…", "Compare with Clipboard": "与剪贴板比较", + "Compare with Current": "与当前比较", "Compare with Local": "与本地比较", "Compilation failed: fix the errors and reload again.": "编译失败:请修复错误后重新加载。", "Compiles and runs the project: the default jdtls runtime, Run/Debug, and JAVA_HOME for Maven and Gradle.": "用于编译和运行项目:jdtls 的默认运行时、运行/调试,以及 Maven 和 Gradle 的 JAVA_HOME。", @@ -365,6 +370,7 @@ "Configurations": "配置", "Configure Editor Tabs…": "配置编辑器标签页…", "Configure Indents…": "配置缩进…", + "Configure Keymap…": "配置按键映射…", "Configure…": "配置…", "Confirm Drop": "确认删除", "Confirm before exiting the IDE": "退出 IDE 前确认", @@ -405,6 +411,7 @@ "Copy Path": "复制路径", "Copy Path from Project Root": "复制项目根目录起的路径", "Copy Path/Reference…": "复制路径/引用…", + "Copy Problem Description": "复制问题描述", "Copy Reference": "复制引用", "Copy Revision Number": "复制修订号", "Copy Revision Numbers": "复制修订号", @@ -437,6 +444,7 @@ "Coverage needs the desktop app": "覆盖率需要桌面应用", "Coverage suites": "覆盖率套件", "Create": "创建", + "Create Branch": "创建分支", "Create Draft": "创建草稿", "Create New Branch": "创建新分支", "Create New Test…": "创建新测试…", @@ -720,6 +728,8 @@ "Filter": "过滤", "Filter TODO Items": "过滤 TODO 项", "Filter branches": "过滤分支", + "Filter by Branch": "按分支过滤", + "Filter by Date": "按日期过滤", "Filter by Path": "按路径过滤", "Filter by User": "按用户过滤", "Filter by message": "按消息过滤", @@ -782,6 +792,7 @@ "Frames": "帧", "From Template…": "从模板…", "From settings.xml, else ~/.m2/repository": "取自 settings.xml,否则为 ~/.m2/repository", + "From:": "从:", "Function": "函数", "Function component": "函数组件", "Function with return type": "带返回类型的函数", @@ -824,6 +835,7 @@ "Greyscale": "灰度", "Group": "组", "Group by Directory": "按目录分组", + "Group by Severity": "按严重性分组", "Groups": "组", "Gutter Icons": "装订区域图标", "HACK comment": "HACK 注释", @@ -864,6 +876,7 @@ "HotSwap: compile and reload the changed classes into the running Java debug session": "HotSwap:编译已更改的类并重新加载到正在运行的 Java 调试会话中", "Hovering a variable while suspended evaluates it in the selected frame.": "挂起时悬停变量会在所选帧中计算它。", "IDE Updates": "IDE 更新", + "IDE and Project Settings": "IDE 和项目设置", "IDE font": "IDE 字体", "IDE font size": "IDE 字体大小", "Installed {name}": "已安装 {name}", @@ -890,6 +903,11 @@ "Incoming commits": "传入的提交", "Indent": "缩进", "Indent Block": "缩进块", + "Indent Each Line": "缩进每一行", + "Collapse Recursively": "递归折叠", + "Expand Recursively": "递归展开", + "Move Caret to Matching Brace": "将文本光标移至匹配的大括号", + "Toggle Insert/Overwrite": "切换插入/改写", "Indentation": "缩进", "Indents Detection": "缩进检测", "Index": "索引", @@ -959,6 +977,7 @@ "Just Restart": "仅重启", "Keep": "保留", "Keep Column": "保留列", + "Keep index": "保留索引", "Keep local history for": "本地历史记录保留时长", "Keep trailing spaces on caret line": "保留光标所在行的尾随空格", "Key pair": "密钥对", @@ -1117,6 +1136,7 @@ "Name:": "名称:", "Named scope": "命名范围", "Names (e.g. .DS_Store, *.pyc) hidden from the project tree, navigation bar, search, indexing and Git changes.": "从项目树、导航栏、搜索、索引和 Git 更改中隐藏的名称(例如 .DS_Store、*.pyc)。", + "Narrow down on typing": "输入时缩小列表范围", "Navigate": "导航", "Navigation": "导航", "Navigation Bar": "导航栏", @@ -1226,6 +1246,7 @@ "No run or test configuration of the project matches this file.": "项目中没有与此文件匹配的运行或测试配置。", "No search results": "没有搜索结果", "No special permissions.": "没有特殊权限。", + "No stashes": "没有储藏", "No structure to show": "没有可显示的结构", "No surround templates for this language": "此语言没有包围模板", "No tools match.": "没有匹配的工具。", @@ -1258,6 +1279,7 @@ "Offset 0x": "偏移 0x", "Older output is discarded": "较早的输出将被丢弃", "Only in comments": "仅在注释中", + "Only one usage in Project Files": "项目文件中只有一个用法", "Open": "打开", "Open Diff": "打开差异", "Open File": "打开文件", @@ -1309,6 +1331,8 @@ "Other": "其他", "Other File Types": "其他文件类型", "Other hints (chaining, closing labels…)": "其他提示(链式调用、结束标签…)", + "Other names or emails (comma-separated):": "其他名称或电子邮件(逗号分隔):", + "Other revisions (comma-separated):": "其他修订(逗号分隔):", "Otherwise line breakpoints can only be set in files of languages that have a debugger.": "否则只能在有调试器的语言文件中设置行断点。", "Outgoing commits": "传出的提交", "Output": "输出", @@ -1334,6 +1358,10 @@ "Passed as -s (mirrors, servers, proxies).": "以 -s 传入(镜像、服务器、代理)。", "Passed to rustfmt when reformatting, e.g. --edition 2024": "重新格式化时传递给 rustfmt,例如 --edition 2024", "Passed to the JVM running jdtls (java.jdt.ls.vmargs), e.g. -Xmx2g. A Lombok agent is added automatically when the project uses Lombok.": "传递给运行 jdtls 的 JVM (java.jdt.ls.vmargs),例如 -Xmx2g。项目使用 Lombok 时会自动添加 Lombok 代理。", + "Pop stash": "弹出储藏", + "Preview Source": "预览源代码", + "Reinstate index": "恢复索引", + "Rerun Search": "重新运行搜索", "Right Bottom": "右下", "Right Top": "右上", "Runs jdtls only, like IDEA's JDK for importer; the project keeps compiling and running with the Project JDK. jdtls needs JDK 21 or newer (recent releases 25). The jdtls executable itself is set in Language Servers.": "仅用于运行 jdtls(类似 IDEA 的「导入器 JDK」);项目仍使用项目 JDK 编译和运行。jdtls 需要 JDK 21 或更高版本(较新版本需要 25)。jdtls 可执行文件本身在「语言服务器」中设置。", @@ -1642,6 +1670,16 @@ "Restore Size": "恢复大小", "Restore detected roots": "恢复检测到的根", "Result": "结果", + "Select a problem to see its details": "选择一个问题以查看详情", + "Select an item to preview": "选择一项以预览", + "Severity": "严重性", + "Show Quick Fixes": "显示快速修复", + "Sort By": "排序依据", + "Tags": "标签", + "The start date is after the end date": "开始日期晚于结束日期", + "To:": "到:", + "Unstash Changes": "取消储藏更改", + "Unstash…": "取消储藏…", "result": "个结果", "results": "个结果", "Results": "结果", @@ -1855,6 +1893,7 @@ "Show Log": "显示日志", "Show Memory Indicator Details": "显示内存指示器详细信息", "Show Passed": "显示已通过", + "Show Preview": "显示预览", "Show Raw": "显示原始内容", "Show Replace": "显示替换", "Show Run/Debug window when a process is started": "进程启动时显示运行/调试窗口", @@ -2353,6 +2392,7 @@ "with NO BOM": "不带 BOM", "with statement": "with 语句", "{count} rows exported": "已导出 {count} 行", + "{index} of {total}": "第 {index} 个,共 {total} 个", "{name} failed": "{name}失败", "{name} has unsaved changes": "{name} 有未保存的更改", "{name} installed": "{name} 已安装", @@ -2451,5 +2491,32 @@ "Show Commit Message History": "显示提交消息历史记录", "Don't warn about this target": "不再对此目标发出警告", "Target branch": "目标分支", - "Checked out {name}": "已签出 {name}" + "Checked out {name}": "已签出 {name}", + "Pin Window": "固定窗口", + "Search Context": "搜索上下文", + "Anywhere": "任意位置", + "In Comments": "在注释中", + "In String Literals": "在字符串字面量中", + "Except Comments": "注释除外", + "Except String Literals": "字符串字面量除外", + "Except Comments and String Literals": "注释和字符串字面量除外", + "Recent Replacements": "最近的替换", + "No recent replacements": "没有最近的替换", + "↵ Replace Selected · ⇧⌥↵ Replace All · ⌘↵ Open in Find Window": "↵ 替换所选 · ⇧⌥↵ 全部替换 · ⌘↵ 在查找窗口中打开", + "Replace {count} Occurrences": "替换 {count} 处", + "Add Mnemonic Bookmark": "添加助记符书签", + "Change Mnemonic": "更改助记符", + "Assign Mnemonic": "分配助记符", + "Assigned bookmark": "已分配的书签", + "Current bookmark": "当前书签", + "No bookmarks": "没有书签", + "Edit Bookmark Description": "编辑书签描述", + "Toggle Bookmark with Mnemonic": "使用助记符切换书签", + "Toggle Bookmark {n}": "切换书签 {n}", + "Go to Bookmark {n}": "转到书签 {n}", + "Select In": "选择位置", + "Select In…": "选择位置…", + "Next Result": "下一个结果", + "Previous Result": "上一个结果", + "Open Result": "打开结果" } diff --git a/src/platform/workflowSettings.ts b/src/platform/workflowSettings.ts index 0c0760c0..e606396c 100644 --- a/src/platform/workflowSettings.ts +++ b/src/platform/workflowSettings.ts @@ -73,6 +73,7 @@ export interface DebuggerSettingsValid { export interface RunSettingsValid { confirmRerun: boolean; + confirmTerminate: boolean; showOnStart: boolean; terminationTimeoutMs: number; } @@ -211,7 +212,7 @@ export const DEFAULT_WORKFLOW: WorkflowSettings = { skipLibraryCode: true, python: { attachSubprocess: true, gevent: false }, }, - run: { confirmRerun: true, showOnStart: true, terminationTimeoutMs: 3000 }, + run: { confirmRerun: true, confirmTerminate: true, showOnStart: true, terminationTimeoutMs: 3000 }, terminal: { startDirectory: "", env: {}, @@ -341,6 +342,7 @@ export function validateRun(raw: unknown): RunSettingsValid { const v = obj(raw); return { confirmRerun: bool(v.confirmRerun, d.confirmRerun), + confirmTerminate: bool(v.confirmTerminate, d.confirmTerminate), showOnStart: bool(v.showOnStart, d.showOnStart), terminationTimeoutMs: num(v.terminationTimeoutMs, d.terminationTimeoutMs, WORKFLOW_LIMITS.terminationTimeoutMs, true), }; diff --git a/src/ui/List.tsx b/src/ui/List.tsx index 6b70219e..d8ef96ff 100644 --- a/src/ui/List.tsx +++ b/src/ui/List.tsx @@ -23,6 +23,8 @@ export interface ListProps { activeIndex?: number; defaultActiveIndex?: number; onActiveChange?: (index: number) => void; + /** Rows selected besides the active one (multi-selection); drawn like the active row. */ + isSelected?: (item: T, index: number) => boolean; onActivate?: (item: T, index: number, e: KeyboardEvent | MouseEvent) => void; onContextMenu?: (item: T, index: number, e: MouseEvent) => void; rowHeight?: number; @@ -102,6 +104,7 @@ export function List({ activeIndex, defaultActiveIndex, onActiveChange, + isSelected, onActivate, onContextMenu, rowHeight = 24, @@ -187,15 +190,16 @@ export function List({ ); } const isActive = v.index === active; + const selected = isActive || (isSelected?.(item, v.index) ?? false); return (
setActive(v.index) : undefined} onMouseDown={(e) => { diff --git a/src/ui/SourcePreview.module.css b/src/ui/SourcePreview.module.css new file mode 100644 index 00000000..dfb99aab --- /dev/null +++ b/src/ui/SourcePreview.module.css @@ -0,0 +1,55 @@ +.preview { + /* offsetParent for the scroll-to-line math. */ + position: relative; + flex: 1; + min-height: 0; + height: 100%; + overflow: auto; + background: var(--bg-app); + font-family: var(--font-mono); + font-size: var(--fs-ui); + line-height: 19px; + color: var(--fg); +} +.empty { + display: flex; + align-items: center; + justify-content: center; + color: var(--fg-faint); + font-family: var(--font-ui); +} +.pre { + margin: 0; + padding: 4px 0; + font: inherit; + min-width: max-content; +} +.codeLine { + display: flex; + white-space: pre; +} +.currentLine { + background: var(--bg-current-line); +} +.gutter { + flex: none; + position: sticky; + left: 0; + width: 48px; + padding-right: 12px; + text-align: right; + color: var(--fg-gutter); + background-color: var(--bg-app); +} +.currentLine .gutter { + color: var(--fg-muted); + background-color: var(--bg-current-line); +} +.code { + padding-right: 16px; +} +.match { + background: var(--accent-soft); + color: var(--fg-strong); + border-radius: 2px; +} diff --git a/src/ui/SourcePreview.tsx b/src/ui/SourcePreview.tsx new file mode 100644 index 00000000..15d0d2a7 --- /dev/null +++ b/src/ui/SourcePreview.tsx @@ -0,0 +1,111 @@ +/** + * Read-only source view around a line, with line numbers and highlighted + * ranges: the preview pane beside result trees (TODO, Problems, Inspection + * Results…). The caller supplies the text loader (open buffer or disk). + */ +import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import clsx from "clsx"; +import { T } from "./T"; +import styles from "./SourcePreview.module.css"; + +export interface SourceHighlight { + line: number; + /** UTF-16 column ranges in the line. */ + ranges: ReadonlyArray; +} + +/** Lines rendered on each side of the current one. */ +const WINDOW = 300; + +export interface SourcePreviewProps { + path: string | null; + /** 0-based line to centre and mark as current. */ + line: number; + highlights?: readonly SourceHighlight[]; + /** Stable (module-level) loader; a new function reloads. */ + load: (path: string) => Promise; + /** Bump to reload the same path. */ + version?: number; + className?: string; +} + +export function SourcePreview({ path, line, highlights = [], load, version = 0, className }: SourcePreviewProps) { + const [text, setText] = useState<{ path: string; lines: string[] } | null>(null); + const [error, setError] = useState(null); + const scrollRef = useRef(null); + const current = text && text.path === path ? text : null; + + useEffect(() => { + if (!path) return; + let alive = true; + // Debounced so arrowing through results doesn't load every file. + const t = setTimeout(() => { + load(path).then( + (content) => { + if (!alive) return; + setText({ path, lines: content.split(/\r\n|\r|\n/) }); + setError(null); + }, + (e) => alive && setError(String((e as Error)?.message ?? e)), + ); + }, 80); + return () => { + alive = false; + clearTimeout(t); + }; + }, [path, version, load]); + + useLayoutEffect(() => { + const box = scrollRef.current; + const el = box?.querySelector(`[data-line="${line}"]`); + if (box && el) box.scrollTop = el.offsetTop - box.clientHeight / 2 + el.offsetHeight / 2; + }, [current, line]); + + if (!path) + return ( +
+ Select an item to preview +
+ ); + if (error && !current) return
{error}
; + if (!current) return
; + + const byLine = new Map(highlights.map((h) => [h.line, h.ranges])); + const start = Math.max(0, line - WINDOW); + const end = Math.min(current.lines.length, line + WINDOW); + const rows: ReactNode[] = []; + for (let n = start; n < end; n++) { + const content = current.lines[n] ?? ""; + const ranges = byLine.get(n); + rows.push( +
+ {n + 1} + {ranges ? highlight(content, ranges) : content || " "} +
, + ); + } + return ( +
+
{rows}
+
+ ); +} + +function highlight(text: string, ranges: ReadonlyArray): ReactNode[] { + const parts: ReactNode[] = []; + let pos = 0; + [...ranges] + .sort((a, b) => a[0] - b[0]) + .forEach(([s, e], i) => { + if (s < pos || e <= s) return; + if (s > pos) parts.push(text.slice(pos, s)); + parts.push( + + {text.slice(s, e)} + , + ); + pos = e; + }); + if (pos < text.length) parts.push(text.slice(pos)); + return parts; +} diff --git a/src/ui/Tree.test.tsx b/src/ui/Tree.test.tsx index be257f57..bbfca7a4 100644 --- a/src/ui/Tree.test.tsx +++ b/src/ui/Tree.test.tsx @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; -import { fireEvent, render, screen, within } from "@testing-library/react"; -import { Tree } from "./Tree"; +import { createRef } from "react"; +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { Tree, type TreeHandle } from "./Tree"; interface N { id: string; @@ -78,6 +79,16 @@ describe("Tree keyboard navigation", () => { expect(onActivate).toHaveBeenCalledWith(expect.objectContaining({ id: "README.md" }), expect.anything()); }); + it("F4 and ⌘↓ activate like Enter (Jump to Source)", () => { + const { key, onActivate } = setup(); + key("End"); + key("F4"); + expect(onActivate).toHaveBeenCalledTimes(1); + key("ArrowDown", { metaKey: true }); + expect(onActivate).toHaveBeenCalledTimes(2); + expect(onActivate).toHaveBeenLastCalledWith(expect.objectContaining({ id: "README.md" }), expect.anything()); + }); + it("extends the selection with Shift+Arrow", () => { const onSelectionChange = vi.fn(); const { key } = setup({ defaultExpanded: ["src"], onSelectionChange }); @@ -121,6 +132,22 @@ describe("Tree speed search", () => { expect(tree.querySelectorAll("mark")).toHaveLength(0); }); + it("Home / End jump to the first / last match; Left closes the search and collapses", () => { + const { tree, lead, key } = setup({ defaultExpanded: ["src", "main"] }); + key("ArrowDown"); + key("t"); + key("s"); + // "ts" matches App.tsx, index.ts and util.ts. + key("End"); + expect(lead()).toBe("util.ts"); + key("Home"); + expect(lead()).toBe("App.tsx"); + key("ArrowLeft"); + expect(screen.queryByRole("status")).toBeNull(); + expect(lead()).toBe("main"); + expect(labels(tree)).toContain("App.tsx"); + }); + it("filter mode hides non-matching rows but keeps ancestors", () => { const { tree, key } = setup({ defaultExpanded: ["src", "main"], speedSearchMode: "filter" }); key("ArrowDown"); @@ -129,16 +156,56 @@ describe("Tree speed search", () => { }); }); +describe("Tree occurrences", () => { + it("selects the next / previous leaf, expanding its ancestors", () => { + const handle = createRef(); + const onSelectionChange = vi.fn(); + const { tree, lead } = setup({ handleRef: handle, onSelectionChange }); + let step: ReturnType = null; + act(() => void (step = handle.current!.selectOccurrence(1))); + expect(step).toMatchObject({ key: "App.tsx", index: 1, total: 4 }); + expect(labels(tree)).toEqual(["src", "main", "App.tsx", "index.ts", "util.ts", "README.md"]); + expect(lead()).toBe("App.tsx"); + expect(onSelectionChange).toHaveBeenLastCalledWith( + new Set(["App.tsx"]), + expect.objectContaining({ id: "App.tsx" }), + ); + act(() => void (step = handle.current!.selectOccurrence(1))); + expect(step).toMatchObject({ key: "index.ts", index: 2 }); + act(() => void (step = handle.current!.selectOccurrence(-1))); + expect(step).toMatchObject({ key: "App.tsx", index: 1 }); + act(() => void (step = handle.current!.selectOccurrence(-1))); + expect(step).toBeNull(); + expect(lead()).toBe("App.tsx"); + }); + + it("starts from the selected group and honours isOccurrence", () => { + const handle = createRef(); + const { key } = setup({ handleRef: handle, isOccurrence: (n) => n.id !== "src" }); + key("ArrowDown"); // src + let step: ReturnType = null; + act(() => void (step = handle.current!.selectOccurrence(1))); + expect(step).toMatchObject({ key: "main", index: 1, total: 5 }); + act(() => void (step = handle.current!.selectOccurrence(-1))); + expect(step).toBeNull(); + }); + + it("skips the lead's subtree on request", () => { + const handle = createRef(); + const { key } = setup({ handleRef: handle, defaultExpanded: ["src"] }); + key("ArrowDown"); + key("ArrowDown"); // main + let step: ReturnType = null; + act(() => void (step = handle.current!.selectOccurrence(1, { skipSubtree: true }))); + expect(step).toMatchObject({ key: "util.ts", index: 3 }); + }); +}); + describe("siblingPositions", () => { it("numbers rows among siblings", async () => { const { siblingPositions } = await import("./Tree"); expect( - siblingPositions([ - { parentKey: null }, - { parentKey: "a" }, - { parentKey: "a" }, - { parentKey: null }, - ]), + siblingPositions([{ parentKey: null }, { parentKey: "a" }, { parentKey: "a" }, { parentKey: null }]), ).toEqual([ [1, 2], [1, 2], diff --git a/src/ui/Tree.tsx b/src/ui/Tree.tsx index 73d0f436..a38096b5 100644 --- a/src/ui/Tree.tsx +++ b/src/ui/Tree.tsx @@ -1,9 +1,10 @@ /** * Virtualized tree with lazy children, multi-selection, IDEA keyboard model - * (↑↓←→, Enter, Home/End, PageUp/PageDown, Shift-extend) and speed search: - * typing while the tree is focused highlights matches and jumps to them - * (↑/↓ cycle matches, Esc clears); `speedSearchMode="filter"` hides rows - * that neither match nor contain a match. + * (↑↓←→, Enter / F4 / ⌘↓, Home/End, PageUp/PageDown, Shift-extend) and speed + * search: typing while the tree is focused highlights matches and jumps to them + * (↑/↓ cycle matches, Home/End first / last match, Esc clears; ←/→, Enter and + * PageUp/PageDown close it and act as usual); `speedSearchMode="filter"` hides + * rows that neither match nor contain a match. */ import { useCallback, @@ -39,6 +40,14 @@ export interface TreeRowState { matches: number[] | null; } +/** Where Next / Previous Occurrence landed: the selected node and its 1-based position among all occurrences. */ +export interface TreeOccurrence { + key: string; + node: unknown; + index: number; + total: number; +} + export interface TreeHandle { focus(): void; /** Selects `key` (must be visible, i.e. ancestors expanded) and scrolls to it. */ @@ -46,6 +55,13 @@ export interface TreeHandle { leadKey(): string | null; /** Selects the next (1) / previous (-1) row, for a filter field that keeps the focus (Settings search). */ moveSelection(dir: 1 | -1): void; + /** + * Next / Previous Occurrence: selects the next (1) / previous (-1) node that + * `isOccurrence` accepts (default: leaves) after the lead, in tree order over + * the loaded nodes, expanding its collapsed ancestors. `null` when there is none. + * `skipSubtree`: forward, ignore the lead's descendants (after excluding a group). + */ + selectOccurrence(dir: 1 | -1, options?: { skipSubtree?: boolean }): TreeOccurrence | null; } export interface TreeProps { @@ -77,6 +93,8 @@ export interface TreeProps { /** Enter / double-click on a leaf (or on nodes `activateExpandable` accepts). */ onActivate?: (node: T, e: KeyboardEvent | MouseEvent) => void; activateExpandable?: boolean | ((node: T) => boolean); + /** Nodes Next / Previous Occurrence stops at (`TreeHandle.selectOccurrence`); default: leaves. */ + isOccurrence?: (node: T) => boolean; onContextMenu?: (node: T | null, selection: Set, e: MouseEvent) => void; speedSearch?: boolean; @@ -273,6 +291,32 @@ export function Tree(props: TreeProps) { selectIndex(i); virtualizer.scrollToIndex(i); }, + selectOccurrence: (dir: 1 | -1, options?: { skipSubtree?: boolean }) => { + const found = findOccurrence( + roots, + lead, + dir, + { getKey, getChildren, isExpandable, isOccurrence: props.isOccurrence }, + options?.skipSubtree, + ); + if (!found) return null; + const missing = found.ancestors.filter((a) => !expanded.has(getKey(a))); + if (missing.length) { + const next = new Set(expanded); + for (const a of missing) { + next.add(getKey(a)); + props.onExpandedChange?.(new Set(next), a, true); + } + if (!expandedControlled) setExpandedInner(next); + } + const key = getKey(found.node); + const sel = new Set([key]); + setAnchor(key); + if (!selectedControlled) setSelectedInner(sel); + setLead(key); + props.onSelectionChange?.(sel, found.node); + return { key, node: found.node, index: found.index, total: found.total }; + }, })); const findMatch = (from: number, dir: 1 | -1, inclusive: boolean, q = query, wrap = true): number => { @@ -318,6 +362,15 @@ export function Tree(props: TreeProps) { return; } const range = e.shiftKey && multiSelect; + // Jump to Source (F4 / ⌘↓) activates like Enter. + if (e.key === "F4" || (e.key === "ArrowDown" && e.metaKey && !e.shiftKey && !e.altKey)) { + if (!row) return; + setQuery(""); + activate(row, e); + e.preventDefault(); + e.stopPropagation(); + return; + } switch (e.key) { case "Escape": if (!query) return; @@ -341,12 +394,14 @@ export function Tree(props: TreeProps) { break; } case "ArrowRight": + setQuery(""); if (!row) return; if (row.expandable && !row.expanded) setExpanded(row, true); else if (row.expanded && rows[leadIndex + 1]?.parentKey === row.key && !rows[leadIndex + 1]!.loading) selectIndex(leadIndex + 1); break; case "ArrowLeft": + setQuery(""); if (!row) return; if (row.expanded) setExpanded(row, false); else if (row.parentKey != null) { @@ -355,15 +410,20 @@ export function Tree(props: TreeProps) { } break; case "Home": - selectIndex(step(-1, 1), range ? "range" : "single"); - break; - case "End": - selectIndex(step(rows.length, -1), range ? "range" : "single"); + case "End": { + const first = e.key === "Home"; + if (query) { + const hit = findMatch(-1, first ? 1 : -1, true, query, false); + if (hit >= 0) selectIndex(hit); + } else selectIndex(first ? step(-1, 1) : step(rows.length, -1), range ? "range" : "single"); break; + } case "PageDown": + setQuery(""); selectIndex(Math.min(rows.length - 1, Math.max(0, leadIndex) + pageSize), range ? "range" : "single"); break; case "PageUp": + setQuery(""); selectIndex(Math.max(0, leadIndex - pageSize), range ? "range" : "single"); break; case "Enter": @@ -542,3 +602,42 @@ export function siblingPositions( rows.forEach((r, i) => (out[i]![1] = counts.get(r.parentKey)!)); return out; } + +/** The occurrence after (`dir` 1) / before (-1) `lead` in tree order over loaded nodes, with its ancestors. */ +export function findOccurrence( + roots: readonly T[], + lead: string | null, + dir: 1 | -1, + model: { + getKey: (node: T) => string; + getChildren: (node: T) => readonly T[] | undefined; + isExpandable: (node: T) => boolean; + isOccurrence?: (node: T) => boolean; + }, + skipSubtree = false, +): { node: T; ancestors: T[]; index: number; total: number } | null { + const occurrences: Array<{ node: T; ancestors: T[]; at: number }> = []; + let leadAt = -1; + let leadEnd = -1; + let at = 0; + const walk = (nodes: readonly T[], ancestors: T[]) => { + for (const node of nodes) { + const here = at++; + const isLead = model.getKey(node) === lead; + if (isLead) leadAt = here; + const expandable = model.isExpandable(node); + if (model.isOccurrence ? model.isOccurrence(node) : !expandable) occurrences.push({ node, ancestors, at: here }); + const children = expandable ? model.getChildren(node) : undefined; + if (children?.length) walk(children, [...ancestors, node]); + if (isLead) leadEnd = at - 1; + } + }; + walk(roots, []); + const after = skipSubtree ? leadEnd : leadAt; + let i: number; + if (leadAt < 0) i = dir === 1 ? 0 : occurrences.length - 1; + else if (dir === 1) i = occurrences.findIndex((o) => o.at > after); + else i = occurrences.findLastIndex((o) => o.at < leadAt); + const hit = occurrences[i]; + return hit ? { node: hit.node, ancestors: hit.ancestors, index: i + 1, total: occurrences.length } : null; +} diff --git a/src/ui/expui/general/autoscrollFromSource.svg b/src/ui/expui/general/autoscrollFromSource.svg new file mode 100644 index 00000000..696ff7f2 --- /dev/null +++ b/src/ui/expui/general/autoscrollFromSource.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/expui/general/autoscrollFromSource_dark.svg b/src/ui/expui/general/autoscrollFromSource_dark.svg new file mode 100644 index 00000000..1cc5df1b --- /dev/null +++ b/src/ui/expui/general/autoscrollFromSource_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/expui/general/autoscrollToSource.svg b/src/ui/expui/general/autoscrollToSource.svg new file mode 100644 index 00000000..26c894c7 --- /dev/null +++ b/src/ui/expui/general/autoscrollToSource.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/expui/general/autoscrollToSource_dark.svg b/src/ui/expui/general/autoscrollToSource_dark.svg new file mode 100644 index 00000000..9c49d403 --- /dev/null +++ b/src/ui/expui/general/autoscrollToSource_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/expui/gutter/runError.svg b/src/ui/expui/gutter/runError.svg new file mode 100644 index 00000000..631fccf1 --- /dev/null +++ b/src/ui/expui/gutter/runError.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/ui/expui/gutter/runError_dark.svg b/src/ui/expui/gutter/runError_dark.svg new file mode 100644 index 00000000..bdb0cae7 --- /dev/null +++ b/src/ui/expui/gutter/runError_dark.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/ui/expui/gutter/runSuccess.svg b/src/ui/expui/gutter/runSuccess.svg new file mode 100644 index 00000000..22f00a39 --- /dev/null +++ b/src/ui/expui/gutter/runSuccess.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/ui/expui/gutter/runSuccess_dark.svg b/src/ui/expui/gutter/runSuccess_dark.svg new file mode 100644 index 00000000..558a95f1 --- /dev/null +++ b/src/ui/expui/gutter/runSuccess_dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index 40651ed3..9e7b66f9 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -209,6 +209,8 @@ const ICONS = { "collapse-all": "general/collapseAll", "expand-all": "general/expandAll", locate: "general/locate", + "autoscroll-to-source": "general/autoscrollToSource", + "autoscroll-from-source": "general/autoscrollFromSource", sort: "objectBrowser/sortAlphabetically", copy: "general/copy", paste: "general/paste", @@ -280,6 +282,8 @@ const ICONS = { "toolwindow-profiler": "toolwindows/profiler", "gutter-run": "gutter/run", "gutter-run-all": "actions/runAll", + "gutter-run-passed": "gutter/runSuccess", + "gutter-run-failed": "gutter/runError", stop: "run/stop", rerun: "run/rerun", "rerun-filled": "run/rerun_stroke", @@ -343,6 +347,8 @@ const ICONS = { changelist: "vcs/changelist", stash: "openidea/vcs/stash", star: "openidea/vcs/favorite", + "star-outline": "openidea/vcs/notFavorite", + tag: "openidea/vcs/tag", "current-branch": "openidea/vcs/currentBranch", // Symbols (also used by `symbol-`) class: "nodes/class", diff --git a/src/ui/index.ts b/src/ui/index.ts index c9505044..4604e292 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -12,6 +12,7 @@ export * from "./Popup"; export * from "./Menu"; export * from "./Dialog"; export * from "./Splitter"; +export * from "./SourcePreview"; export * from "./Feedback"; export * from "./HighlightedText"; export { fuzzyMatch, indexRuns, wordMatch } from "./match"; diff --git a/src/ui/openidea/vcs/notFavorite.svg b/src/ui/openidea/vcs/notFavorite.svg new file mode 100644 index 00000000..2d082407 --- /dev/null +++ b/src/ui/openidea/vcs/notFavorite.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/openidea/vcs/notFavorite_dark.svg b/src/ui/openidea/vcs/notFavorite_dark.svg new file mode 100644 index 00000000..198a9ba6 --- /dev/null +++ b/src/ui/openidea/vcs/notFavorite_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/openidea/vcs/tag.svg b/src/ui/openidea/vcs/tag.svg new file mode 100644 index 00000000..d67da6a0 --- /dev/null +++ b/src/ui/openidea/vcs/tag.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/openidea/vcs/tag_dark.svg b/src/ui/openidea/vcs/tag_dark.svg new file mode 100644 index 00000000..490cd408 --- /dev/null +++ b/src/ui/openidea/vcs/tag_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/workbench/Notifications.tsx b/src/workbench/Notifications.tsx index 0a2d6bcc..437f4a9a 100644 --- a/src/workbench/Notifications.tsx +++ b/src/workbench/Notifications.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { useTxTemplate } from "@/platform/i18n"; import { EmptyState, IconButton, Toast, T } from "@/ui"; import { - BALLOON_TIMEOUT, + balloonTimeout, clearNotifications, dismissBalloon, markAllNotificationsRead, @@ -39,10 +39,10 @@ function Balloon({ n }: { n: IdeNotification }) { const active = useWindowActive(); const remaining = useRef(null); const txt = useTxTemplate(); + const ms = balloonTimeout(n); // IDEA's smart fade-out: the clock runs only while the window is active and the balloon not hovered. useEffect(() => { - const ms = BALLOON_TIMEOUT[n.level]; - if (n.sticky || ms === null || hover || !active) return; + if (ms === null || hover || !active) return; const left = remaining.current ?? ms; const start = Date.now(); const t = setTimeout(() => dismissBalloon(n.id), left); @@ -50,7 +50,7 @@ function Balloon({ n }: { n: IdeNotification }) { clearTimeout(t); remaining.current = Math.max(1, left - (Date.now() - start)); }; - }, [n.id, n.level, n.sticky, hover, active]); + }, [n.id, ms, hover, active]); return ( s.text); + if (!text) return null; + return ( + + {text} + + ); +} + /** Built-in: background task progress; click lists all running tasks. */ export function TaskStatus() { const tasks = useTasks((s) => s.tasks); diff --git a/src/workbench/TitleBar.test.tsx b/src/workbench/TitleBar.test.tsx new file mode 100644 index 00000000..d33f1e89 --- /dev/null +++ b/src/workbench/TitleBar.test.tsx @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { registerActions } from "@/platform/actions"; +import { useThemeOverride } from "./theme"; +import { gearMenuItems, TitleBar } from "./TitleBar"; + +let disposers: Array<() => void> = []; +afterEach(() => { + disposers.forEach((d) => d()); + disposers = []; + useThemeOverride.setState({ theme: null }); +}); + +describe("title bar gear menu", () => { + it("lists Settings, Plugins, Theme and Keymap switches and Check for Updates", () => { + const items = gearMenuItems("dark", "intellij-macos", ["Mine"]); + expect(items.map((i) => ("action" in i ? i.action : "label" in i ? i.label : i.kind))).toEqual([ + "file.settings", + "plugins.settings", + "separator", + "Theme", + "Keymap", + "separator", + "app.checkForUpdates", + ]); + const theme = (items[3] as { submenu: Array<{ label?: string; checked?: boolean }> }).submenu; + expect(theme.filter((i) => i.checked).map((i) => i.label)).toEqual(["Islands Dark"]); + const keymap = (items[4] as { submenu: Array<{ label?: string; checked?: boolean }> }).submenu; + expect(keymap.map((i) => i.label)).toContain("Mine"); + expect(keymap.filter((i) => i.checked).map((i) => i.label)).toEqual(["macOS"]); + }); + + it("opens a menu instead of Settings directly, and switches the theme", async () => { + let opened = 0; + disposers.push( + registerActions([{ id: "file.settings", title: "Settings…", category: "File", run: () => void opened++ }]), + ); + render(); + fireEvent.click(screen.getByRole("button", { name: "IDE and Project Settings" })); + expect(opened).toBe(0); + expect(await screen.findByText("Settings…")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Theme")); + fireEvent.click(await screen.findByText("High Contrast")); + expect(useThemeOverride.getState().theme).toBe("dark-hc"); + }); +}); diff --git a/src/workbench/TitleBar.tsx b/src/workbench/TitleBar.tsx index 6ccfbea2..8035a3db 100644 --- a/src/workbench/TitleBar.tsx +++ b/src/workbench/TitleBar.tsx @@ -1,7 +1,8 @@ import { useMemo, useRef, useState } from "react"; import clsx from "clsx"; import { useActions } from "@/platform/actions"; -import { shortcutLabel } from "@/platform/keymap"; +import { call, commands, isTauri } from "@/platform/ipc"; +import { PRESET_TITLES, shortcutLabel, useKeymap } from "@/platform/keymap"; import { useProject } from "@/platform/project"; import { BarButton, Icon, IconButton, Menu, type MenuItem, T } from "@/ui"; import { useContributions, useWorkbenchSlots, type TitleItemContribution } from "./contributions"; @@ -10,6 +11,7 @@ import { MainMenu } from "./MainMenu"; import { WindowControls } from "./WindowControls"; import { usePlatform } from "@/platform/platformInfo"; import { projectInitials } from "./projectColor"; +import { currentThemeId, setTheme, THEME_CHOICES, useThemeOverride } from "./theme"; import styles from "./TitleBar.module.css"; export function orderTitleItems(items: Iterable) { @@ -104,6 +106,91 @@ function VcsFallback() { ); } +async function selectKeymap(id: string) { + if (!isTauri()) return; + try { + useProject.setState({ settings: await call(commands.settingsSet("global", "keymap", JSON.stringify(id))) }); + } catch (e) { + console.warn("cannot switch keymap", e); + } +} + +/** IDEA's main toolbar gear: Settings, Plugins, quick Theme / Keymap switches, Check for Updates. */ +export function gearMenuItems(themeId: string, keymapId: string, customKeymaps: string[]): MenuItem[] { + return [ + { kind: "action", action: "file.settings" }, + { kind: "action", action: "plugins.settings" }, + { kind: "separator" }, + { + id: "gear.theme", + label: "Theme", + submenu: [ + ...THEME_CHOICES.map((c) => ({ + id: `theme.${c.id}`, + label: c.label, + checked: c.id === themeId, + run: () => void setTheme(c.id), + })), + { kind: "separator" }, + { kind: "action", action: "settings.appearance" }, + ], + }, + { + id: "gear.keymap", + label: "Keymap", + submenu: [ + ...[...Object.keys(PRESET_TITLES), ...customKeymaps].map((id) => ({ + id: `keymap.${id}`, + label: PRESET_TITLES[id] ?? id, + verbatim: !PRESET_TITLES[id], + checked: id === keymapId, + run: () => void selectKeymap(id), + })), + { kind: "separator" }, + { kind: "action", action: "settings.keymap", label: "Configure Keymap…" }, + ], + }, + { kind: "separator" }, + { kind: "action", action: "app.checkForUpdates" }, + ]; +} + +function GearButton() { + const [open, setOpen] = useState(false); + const ref = useRef(null); + const settings = useProject((s) => s.settings); + useThemeOverride((s) => s.theme); + useActions((s) => s.actions); + const preset = useKeymap((s) => s.preset); + const customs = settings?.customKeymaps; + const items = gearMenuItems( + currentThemeId(), + typeof settings?.keymap === "string" ? settings.keymap : preset, + customs && typeof customs === "object" ? Object.keys(customs) : [], + ); + return ( + <> + setOpen((v) => !v)} + /> + setOpen(false)} + anchor={ref} + items={items} + aria-label="IDE and Project Settings" + /> + + ); +} + /** * 40px main toolbar. macOS: native traffic lights overlay its left edge. * Windows: the window is frameless, so the bar carries IDEA's main menu (☰) and @@ -166,7 +253,7 @@ export function TitleBar({ iconSize={18} className={styles.tool} /> - +
)} diff --git a/src/workbench/Workbench.test.tsx b/src/workbench/Workbench.test.tsx index 69eb3777..c17902eb 100644 --- a/src/workbench/Workbench.test.tsx +++ b/src/workbench/Workbench.test.tsx @@ -9,6 +9,7 @@ import { BALLOON_TIMEOUT, clearNotifications, installNotifyEvents, + STICKY_BALLOON_TIMEOUT, notify, useNotifications, } from "./notificationStore"; @@ -129,6 +130,18 @@ describe("notifications", () => { expect(useNotifications.getState().items).toHaveLength(2); }); + it("sticky balloons fade into the tool window after 5 minutes, staying unread", () => { + vi.useFakeTimers(); + vi.spyOn(document, "hasFocus").mockReturnValue(true); + render(); + act(() => void notify({ level: "error", title: "Sync failed" })); + act(() => vi.advanceTimersByTime(STICKY_BALLOON_TIMEOUT - 1000)); + expect(screen.getByText("Sync failed")).toBeInTheDocument(); + act(() => vi.advanceTimersByTime(1100)); + expect(screen.queryByText("Sync failed")).toBeNull(); + expect(useNotifications.getState().items[0]).toMatchObject({ title: "Sync failed", read: false, balloon: false }); + }); + it("pauses the balloon timeout while the app window is inactive", () => { vi.useFakeTimers(); const hasFocus = vi.spyOn(document, "hasFocus").mockReturnValue(true); diff --git a/src/workbench/activate.ts b/src/workbench/activate.ts index 19941749..d8a58b9e 100644 --- a/src/workbench/activate.ts +++ b/src/workbench/activate.ts @@ -22,7 +22,7 @@ import { } from "./Notifications"; import { loadPlatformInfo } from "@/platform/platformInfo"; import { registerShellActions } from "./shellActions"; -import { TaskStatus } from "./StatusBar"; +import { StatusInfo, TaskStatus } from "./StatusBar"; import { installTaskEvents } from "./tasks"; import { installToolWindowActions } from "./toolwindows/actions"; import { @@ -61,6 +61,7 @@ export function activateWorkbench(): () => void { installNotifyEvents(), installBrowserGuards(), registerStatusItem({ id: "workbench.tasks", align: "right", priority: 0, component: TaskStatus }), + registerStatusItem({ id: "workbench.info", align: "left", priority: 5, component: StatusInfo }), registerToolWindow({ id: NOTIFICATIONS_TOOL_WINDOW, title: "Notifications", diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index 9d43af87..91f97b1c 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -8,7 +8,7 @@ * The workbench renders whatever is registered; the action * `toolwindow.` toggles it (registered automatically). */ -import type { ComponentType, ReactNode, RefObject } from "react"; +import { useEffect, useRef, type ComponentType, type ReactNode, type RefObject } from "react"; import type { RunConfig } from "@/bindings"; import type { MenuItem } from "@/ui/Menu"; import { create } from "zustand"; @@ -311,6 +311,8 @@ export interface EditorService { openPaths?(): string[]; /** Closes every tab showing `path`. */ closePath?(path: string): void; + /** Closes the tab of the input with this `key` (e.g. a merge editor once applied). */ + closeInput?(key: string): void; } export interface ToolWindowService { @@ -522,8 +524,16 @@ export function projectViewRoots(map: ReadonlyMap vo export function runExecutors(map = useRunExecutors.getState().executors): RunExecutorContribution[] { return [...map.values()].sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.id.localeCompare(b.id)); } + +// --------------------------------------------------------------------------- +// Occurrence navigators: result views (Find, Usages, Problems, TODO…) let +// Next / Previous Occurrence step through their results from anywhere, the +// editor included. The actions ask the focused, else the last active tool +// window's navigator; the latest registration per tool window wins. + +/** 1-based position of the occurrence navigated to, among `total`. */ +export interface OccurrenceStep { + index: number; + total: number; +} + +export interface OccurrenceNavigator { + /** + * Selects the next (1) / previous (-1) occurrence and opens it in the editor, + * focusing the editor only when `focusEditor` (the action ran outside the tool + * window). `null` when there is none in that direction. + */ + go(dir: 1 | -1, focusEditor: boolean): OccurrenceStep | null; +} + +const occurrenceNavigators = new Map(); + +export function registerOccurrenceNavigator(toolWindow: string, nav: OccurrenceNavigator): () => void { + occurrenceNavigators.set(toolWindow, [...(occurrenceNavigators.get(toolWindow) ?? []), nav]); + return () => { + const rest = (occurrenceNavigators.get(toolWindow) ?? []).filter((n) => n !== nav); + if (rest.length) occurrenceNavigators.set(toolWindow, rest); + else occurrenceNavigators.delete(toolWindow); + }; +} + +export function occurrenceNavigator(toolWindow: string): OccurrenceNavigator | null { + return occurrenceNavigators.get(toolWindow)?.at(-1) ?? null; +} + +/** Registers `nav` (the latest render's) for `toolWindow` while the calling view is mounted. */ +export function useOccurrenceNavigator(toolWindow: string, nav: OccurrenceNavigator["go"]) { + const latest = useRef(nav); + useEffect(() => { + latest.current = nav; + }); + useEffect( + () => registerOccurrenceNavigator(toolWindow, { go: (dir, focus) => latest.current(dir, focus) }), + [toolWindow], + ); +} + +// --------------------------------------------------------------------------- +// Status bar info text (IDEA's status bar message, e.g. "3 of 12" after Next +// Occurrence). Cleared by the next message or after a while. + +export const useStatusInfo = create<{ text: string | null; seq: number }>(() => ({ text: null, seq: 0 })); + +let statusInfoTimer: ReturnType | undefined; + +export function showStatusInfo(text: string, ms = 8000) { + clearTimeout(statusInfoTimer); + const seq = useStatusInfo.getState().seq + 1; + useStatusInfo.setState({ text, seq }); + statusInfoTimer = setTimeout(() => { + if (useStatusInfo.getState().seq === seq) useStatusInfo.setState({ text: null }); + }, ms); +} diff --git a/src/workbench/distractionFree.test.tsx b/src/workbench/distractionFree.test.tsx index e56c05f4..88bd3be4 100644 --- a/src/workbench/distractionFree.test.tsx +++ b/src/workbench/distractionFree.test.tsx @@ -79,7 +79,7 @@ describe("distraction free mode", () => { setDistractionFree(true); showToolWindow("structure"); expect(savedVisible()).toMatchObject({ left: "project", bottom: "terminal", right: null }); - // An app restart (no session) loads the normal layout. + // Leaving the mode (now or after a restart) loads the normal layout. resetDistractionFree(); resetToolWindowLayout(); loadToolWindowLayout("/work/df"); @@ -95,10 +95,11 @@ describe("distraction free mode", () => { expect(isToolWindowVisible("project")).toBe(true); }); - it("survives a reload of the window (sessionStorage) and re-applies to a newly loaded project", () => { + it("survives an app restart and re-applies to a newly loaded project", () => { setDistractionFree(true); - expect(sessionStorage.getItem("openidea.distractionFree")).toBe("1"); - // Reload: fresh module and layout state, same session. + expect(localStorage.getItem("openidea.distractionFree")).toBe("1"); + // Restart: fresh module, layout and session state; only localStorage remains. + sessionStorage.clear(); useDistractionFree.setState({ active: false }); resetToolWindowLayout(); disposers.push(installDistractionFree()); @@ -107,7 +108,7 @@ describe("distraction free mode", () => { expect(isToolWindowVisible("project")).toBe(false); setDistractionFree(false); expect(isToolWindowVisible("project")).toBe(true); - expect(sessionStorage.getItem("openidea.distractionFree")).toBeNull(); + expect(localStorage.getItem("openidea.distractionFree")).toBeNull(); }); it("is unbound in the IntelliJ keymap (⌃⌘D is macOS Look Up) and Escape does not exit", () => { diff --git a/src/workbench/distractionFree.ts b/src/workbench/distractionFree.ts index 5d2ee9e4..e34bd023 100644 --- a/src/workbench/distractionFree.ts +++ b/src/workbench/distractionFree.ts @@ -3,9 +3,11 @@ * stripes, main toolbar contents, status bar, editor tabs, breadcrumbs and * the editor gutter are hidden and the text column is centred at Settings › * Editor › General › Appearance "Distraction free mode text width". The same - * action enters and exits; Esc does not exit. Kept per window across reloads - * (sessionStorage), not across restarts. Published as the `distractionFree` - * context key; components read `useDistractionFree`. + * action enters and exits; Esc does not exit. Like IDEA the mode is an + * application-wide preference that survives restarts (localStorage, next to + * the saved tool window layouts, which keep the layout to restore on exit). + * Published as the `distractionFree` context key; components read + * `useDistractionFree`. */ import { create } from "zustand"; import { registerAction, type Action } from "@/platform/actions"; @@ -16,7 +18,7 @@ import { enterDistractionFreeLayout, exitDistractionFreeLayout, useToolWindowLay export const DISTRACTION_FREE_ACTION = "editor.toggleZen"; export const DISTRACTION_FREE_CONTEXT = "distractionFree"; export const DEFAULT_DISTRACTION_FREE_WIDTH = 80; -const SESSION_KEY = "openidea.distractionFree"; +const STORAGE_KEY = "openidea.distractionFree"; export const useDistractionFree = create<{ active: boolean }>(() => ({ active: false })); @@ -33,18 +35,18 @@ export function distractionFreeTitle(active: boolean): string { return active ? "Exit Distraction Free Mode" : "Enter Distraction Free Mode"; } -function writeSession(active: boolean) { +function writeStored(active: boolean) { try { - if (active) sessionStorage.setItem(SESSION_KEY, "1"); - else sessionStorage.removeItem(SESSION_KEY); + if (active) localStorage.setItem(STORAGE_KEY, "1"); + else localStorage.removeItem(STORAGE_KEY); } catch { /* storage unavailable */ } } -function readSession(): boolean { +function readStored(): boolean { try { - return sessionStorage.getItem(SESSION_KEY) === "1"; + return localStorage.getItem(STORAGE_KEY) === "1"; } catch { return false; } @@ -54,7 +56,7 @@ export function setDistractionFree(active: boolean) { if (active === isDistractionFree()) return; useDistractionFree.setState({ active }); setContext(DISTRACTION_FREE_CONTEXT, active); - writeSession(active); + writeStored(active); if (active) enterDistractionFreeLayout(); else exitDistractionFreeLayout(); } @@ -75,7 +77,7 @@ function action(active: boolean): Action { /** * Registers the action (its title follows the mode, like IDEA's - * Enter / Exit Distraction Free Mode), restores the mode of this window + * Enter / Exit Distraction Free Mode), restores the saved mode * and re-applies the hidden tool window layout when a project's layout loads. */ export function installDistractionFree(): () => void { @@ -90,7 +92,7 @@ export function installDistractionFree(): () => void { if (s.scope !== prev.scope && isDistractionFree()) enterDistractionFreeLayout(); }); setContext(DISTRACTION_FREE_CONTEXT, isDistractionFree()); - if (readSession()) setDistractionFree(true); + if (readStored()) setDistractionFree(true); return () => { unsubMode(); unsubLayout(); @@ -102,5 +104,5 @@ export function installDistractionFree(): () => void { export function resetDistractionFree() { useDistractionFree.setState({ active: false }); setContext(DISTRACTION_FREE_CONTEXT, false); - writeSession(false); + writeStored(false); } diff --git a/src/workbench/notificationStore.ts b/src/workbench/notificationStore.ts index f9bebf61..d2495075 100644 --- a/src/workbench/notificationStore.ts +++ b/src/workbench/notificationStore.ts @@ -85,8 +85,20 @@ interface NotificationState { export const useNotifications = create(() => ({ items: [] })); -/** IDEA fades balloons out after 10 s; errors stay until dismissed (as IDEA's sticky error groups). */ +/** IDEA fades balloons out after 10 s; errors are sticky balloons (as IDEA's error groups). */ export const BALLOON_TIMEOUT: Record = { info: 10000, warning: 10000, error: null }; +/** + * Sticky balloons fade too, after 5 minutes of active window time, as in IDEA: + * the notification stays unread in the Notifications tool window. One that the + * tool window does not list stays until dismissed. + */ +export const STICKY_BALLOON_TIMEOUT = 5 * 60 * 1000; + +/** Fade-out delay of a balloon, or null when it stays until dismissed. */ +export function balloonTimeout(n: Pick): number | null { + if (n.sticky || BALLOON_TIMEOUT[n.level] === null) return n.logged ? STICKY_BALLOON_TIMEOUT : null; + return BALLOON_TIMEOUT[n.level]; +} const MAX_HISTORY = 200; let seq = 0; diff --git a/src/workbench/theme.ts b/src/workbench/theme.ts index c9084610..22bba627 100644 --- a/src/workbench/theme.ts +++ b/src/workbench/theme.ts @@ -103,15 +103,25 @@ function syncWindowBackground() { } export function currentTheme(): "dark" | "light" { - const override = useThemeOverride.getState().theme; - const id = override - ? resolveThemeId({ theme: override }, prefersLight()) - : resolveThemeId(useProject.getState().settings?.appearance as AppearanceLike | undefined, prefersLight()); - return id === "light" ? "light" : "dark"; + return currentThemeId() === "light" ? "light" : "dark"; } -export async function toggleTheme() { - const next: ThemeSetting = currentTheme() === "dark" ? "light" : "dark"; +/** Themes offered by quick switch menus, in IDEA's order. */ +export const THEME_CHOICES: ReadonlyArray<{ id: "light" | "dark" | "dark-hc"; label: string }> = [ + { id: "light", label: "Islands Light" }, + { id: "dark", label: "Islands Dark" }, + { id: "dark-hc", label: "High Contrast" }, +]; + +/** The concrete theme in effect (`dark-hc` kept distinct). */ +export function currentThemeId(): "dark" | "light" | "dark-hc" { + const override = useThemeOverride.getState().theme; + return override + ? resolveThemeId({ theme: override }, prefersLight()) + : resolveThemeId(useProject.getState().settings?.appearance as AppearanceLike | undefined, prefersLight()); +} + +export async function setTheme(next: ThemeSetting) { useThemeOverride.setState({ theme: next }); if (!isTauri()) return; try { @@ -124,3 +134,7 @@ export async function toggleTheme() { /* keep the session override */ } } + +export async function toggleTheme() { + await setTheme(currentTheme() === "dark" ? "light" : "dark"); +} diff --git a/src/workbench/toolwindows/actions.ts b/src/workbench/toolwindows/actions.ts index bcc13f0c..657afbe6 100644 --- a/src/workbench/toolwindows/actions.ts +++ b/src/workbench/toolwindows/actions.ts @@ -1,5 +1,6 @@ import { registerAction, registerActions } from "@/platform/actions"; -import { useContributions } from "../contributions"; +import { formatMessage, tx } from "@/platform/i18n"; +import { occurrenceNavigator, showStatusInfo, useContributions } from "../contributions"; import { focusEditor } from "../focus"; import { closeSelectedContent } from "./content"; import { @@ -8,10 +9,29 @@ import { hideAllToolWindows, hideToolWindow, jumpToLastToolWindow, + lastUsedToolWindow, useToolWindowLayout, toggleMaximizeToolWindow, } from "./store"; +/** + * Next / Previous Occurrence: steps through the results of the focused tool + * window, else of the last active one, else of a shown one, so it also works + * from the editor. The editor gets the focus only when the action ran outside + * the tool window. + */ +export function goToOccurrence(dir: 1 | -1): boolean { + const s = useToolWindowLayout.getState(); + for (const id of new Set([s.focused, s.lastActive, lastUsedToolWindow(), ...Object.values(s.visible)])) { + const nav = id ? occurrenceNavigator(id) : null; + if (!nav) continue; + const step = nav.go(dir, s.focused !== id); + if (step) showStatusInfo(formatMessage(tx("{index} of {total}"), { index: step.index, total: step.total })); + return !!step; + } + return false; +} + /** Registers `toolwindow.` for every tool window plus the generic tool window actions. */ export function installToolWindowActions(): () => void { const perWindow = new Map void }>(); @@ -80,6 +100,24 @@ export function installToolWindowActions(): () => void { when: "toolWindowFocus && !popupOpen", run: () => focusEditor(), }, + { + id: "navigate.nextOccurrence", + title: "Next Occurrence", + category: "Navigate", + icon: "arrow-down", + keywords: ["usage", "result", "find"], + when: "projectOpen", + run: () => void goToOccurrence(1), + }, + { + id: "navigate.prevOccurrence", + title: "Previous Occurrence", + category: "Navigate", + icon: "arrow-up", + keywords: ["usage", "result", "find"], + when: "projectOpen", + run: () => void goToOccurrence(-1), + }, { id: "toolwindow.maximize", title: "Maximize Tool Window", diff --git a/src/workbench/toolwindows/occurrences.test.ts b/src/workbench/toolwindows/occurrences.test.ts new file mode 100644 index 00000000..4af7f023 --- /dev/null +++ b/src/workbench/toolwindows/occurrences.test.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { registerOccurrenceNavigator, registerToolWindow, useStatusInfo } from "../contributions"; +import { goToOccurrence } from "./actions"; +import { resetToolWindowLayout, setFocusedToolWindow, showToolWindow } from "./store"; + +const Empty = () => null; +let disposers: Array<() => void> = []; + +beforeEach(() => { + localStorage.clear(); + resetToolWindowLayout(); + disposers = [ + registerToolWindow({ id: "project", title: "Project", icon: "folder", anchor: "left", component: Empty }), + registerToolWindow({ id: "find", title: "Find", icon: "search", anchor: "bottom", component: Empty }), + ]; +}); + +afterEach(() => { + disposers.reverse().forEach((d) => d()); + useStatusInfo.setState({ text: null }); +}); + +describe("Next / Previous Occurrence", () => { + it("steps the focused tool window's results, keeping the focus there", () => { + const go = vi.fn(() => ({ index: 2, total: 5 })); + disposers.push(registerOccurrenceNavigator("find", { go })); + showToolWindow("find"); + setFocusedToolWindow("find"); + expect(goToOccurrence(1)).toBe(true); + expect(go).toHaveBeenCalledWith(1, false); + expect(useStatusInfo.getState().text).toBe("2 of 5"); + }); + + it("from elsewhere (the editor) uses the last active tool window and focuses the editor", () => { + const go = vi.fn(() => ({ index: 1, total: 1 })); + disposers.push(registerOccurrenceNavigator("find", { go })); + showToolWindow("find"); + setFocusedToolWindow("project"); + setFocusedToolWindow(null); + expect(goToOccurrence(-1)).toBe(true); + expect(go).toHaveBeenCalledWith(-1, true); + }); + + it("does nothing without a navigator or past the last occurrence", () => { + showToolWindow("project"); + expect(goToOccurrence(1)).toBe(false); + const nav = { go: vi.fn(() => null) }; + const dispose = registerOccurrenceNavigator("project", nav); + expect(goToOccurrence(1)).toBe(false); + expect(nav.go).toHaveBeenCalled(); + expect(useStatusInfo.getState().text).toBeNull(); + dispose(); + }); +});