mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-13 04:34:21 -10:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7d0c2c095 | ||
|
|
697a9b0ccb | ||
|
|
d41daa8721 | ||
|
|
9a425f7ac3 | ||
|
|
9da13cc075 | ||
|
|
7df5747418 | ||
|
|
e1eb12c0c2 |
No files matched your search
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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/<id>.toml`. Backend-only; the UI gets [`LanguageInfo`].
|
||||
|
||||
@@ -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<LiteralKind> {
|
||||
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<Literal> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Vec<Literal>> {
|
||||
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<Range> {
|
||||
match self.analyze(lang, text, |_, tree, text| Some(selection::selection_ranges(tree, text, offset_utf16))) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -733,19 +733,34 @@ pub fn signature_help(conv_enc: Encoding, h: lsp::SignatureHelp) -> Option<Signa
|
||||
// ---------------------------------------------------------------- decorations
|
||||
|
||||
pub fn inlay_hint(conv: &Conv, path: &Path, h: lsp::InlayHint) -> 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<u8>");
|
||||
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)]
|
||||
|
||||
@@ -1478,8 +1478,9 @@ impl LspManager {
|
||||
let t = &targets[0];
|
||||
let items: Option<Vec<lsp::CallHierarchyItem>> =
|
||||
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<Vec<serde_json::Value>> =
|
||||
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<Vec<serde_json::Value>> =
|
||||
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::*;
|
||||
|
||||
@@ -195,6 +195,19 @@ pub struct InlayHint {
|
||||
pub padding_left: bool,
|
||||
pub padding_right: bool,
|
||||
pub tooltip: Option<String>,
|
||||
/// The label's parts when the server split it (empty for a plain label);
|
||||
/// their values concatenate to `label`.
|
||||
pub parts: Vec<InlayLabelPart>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// Declaration the part stands for (⌘-click navigates there).
|
||||
pub location: Option<Location>,
|
||||
}
|
||||
|
||||
/// Decoded semantic tokens (absolute positions) with the server legend applied.
|
||||
|
||||
@@ -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)] }]));
|
||||
|
||||
@@ -35,6 +35,7 @@ fn query(pattern: &str, is_regex: bool) -> SearchQuery {
|
||||
exclude: vec![],
|
||||
scope: SearchScope::Project,
|
||||
max_matches: 0,
|
||||
context: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<PathFilter<'a>>,
|
||||
pub context: SearchContext,
|
||||
pub literals: Option<Literals<'a>>,
|
||||
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<PathFilter<'a>>,
|
||||
context: SearchContext,
|
||||
literals: Option<Literals<'a>>,
|
||||
overlay: Overlay<'a>,
|
||||
on_file: &'a (dyn Fn(FileMatches) + Sync),
|
||||
ctl: Control<'a>,
|
||||
@@ -98,6 +105,8 @@ pub(crate) fn run(req: Request<'_>) -> IdeResult<SearchSummary> {
|
||||
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<LiteralKind> {
|
||||
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;
|
||||
|
||||
@@ -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<LiteralKind>) -> 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<Vec<LiteralRange>> + 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<SearchSummary> {
|
||||
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<PathFilter<'_>>,
|
||||
literals: Option<Literals<'_>>,
|
||||
overlay: Overlay<'_>,
|
||||
cancel: &CancellationToken,
|
||||
on_file: &(dyn Fn(FileMatches) + Sync),
|
||||
) -> IdeResult<SearchSummary> {
|
||||
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,
|
||||
|
||||
@@ -97,6 +97,7 @@ mod tests {
|
||||
exclude: vec![],
|
||||
scope: SearchScope::Project,
|
||||
max_matches: 0,
|
||||
context: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ fn query(pattern: &str) -> SearchQuery {
|
||||
exclude: vec![],
|
||||
scope: SearchScope::Project,
|
||||
max_matches: 0,
|
||||
context: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Vec<ide_search::LiteralRange>> {
|
||||
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<Row> {
|
||||
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)])]
|
||||
);
|
||||
}
|
||||
@@ -53,7 +53,8 @@ impl TextFilter {
|
||||
pub(crate) fn log(r: &Repo, q: &LogQuery) -> IdeResult<Vec<Commit>> {
|
||||
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<Vec<Commit>> {
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let author = q.author.as_deref().map(str::to_lowercase).filter(|s| !s.is_empty());
|
||||
let authors: Vec<String> =
|
||||
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<Vec<Commit>> {
|
||||
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<Vec<ObjectI
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Starting points of the walk: the given revision, or HEAD + all branches.
|
||||
fn tips(repo: &gix::Repository, branch: Option<&str>) -> IdeResult<Vec<ObjectId>> {
|
||||
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<Vec<ObjectId>> {
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
/// More author needles next to `author`; a commit matches any of them (Log User filter).
|
||||
#[serde(default)]
|
||||
pub authors: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
@@ -359,6 +365,10 @@ pub struct MergeVersions {
|
||||
pub ours: Option<String>,
|
||||
pub theirs: Option<String>,
|
||||
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<String>,
|
||||
}
|
||||
|
||||
#[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<String>, Option<String>, Option<String>)> {
|
||||
|
||||
@@ -56,11 +56,43 @@ pub(crate) fn merge_sides(r: &Repo) -> IdeResult<MergeSides> {
|
||||
}
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
if commit.starts_with('-') {
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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", "[email protected]", "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::<Vec<_>>();
|
||||
|
||||
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]
|
||||
|
||||
@@ -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<CodeAction> {
|
||||
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]
|
||||
|
||||
@@ -123,6 +123,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
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,
|
||||
|
||||
@@ -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<'_> {
|
||||
|
||||
@@ -299,11 +299,12 @@ pub async fn vcs_stash_push(
|
||||
repo: Option<PathBuf>,
|
||||
message: Option<String>,
|
||||
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<PathBuf>,
|
||||
index: u32,
|
||||
pop: bool,
|
||||
reinstate_index: bool,
|
||||
branch: Option<String>,
|
||||
) -> 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]
|
||||
|
||||
+37
-2
@@ -240,6 +240,8 @@ export const commands = {
|
||||
lspCodeActions: (doc: DocId, range: Range, only: string[]) => typedError<CodeAction[], IdeError>(__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<CodeActionQuery, IdeError>(__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<CodeAction, IdeError>(__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<null, IdeError>(__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<null, IdeError>(__TAURI_INVOKE("vcs_rename_branch", { repo, from, to })),
|
||||
vcsMerge: (repo: string | null, branch: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_merge", { repo, branch })),
|
||||
vcsRebase: (repo: string | null, onto: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_rebase", { repo, onto })),
|
||||
vcsStashPush: (repo: string | null, message: string | null, includeUntracked: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stash_push", { repo, message, includeUntracked })),
|
||||
vcsStashApply: (repo: string | null, index: number, pop: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stash_apply", { repo, index, pop })),
|
||||
vcsStashPush: (repo: string | null, message: string | null, includeUntracked: boolean, keepIndex: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stash_push", { repo, message, includeUntracked, keepIndex })),
|
||||
vcsStashApply: (repo: string | null, index: number, pop: boolean, reinstateIndex: boolean, branch: string | null) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stash_apply", { repo, index, pop, reinstateIndex, branch })),
|
||||
vcsStashDrop: (repo: string | null, index: number) => typedError<null, IdeError>(__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<string>) => typedError<null, IdeError>(__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[] } |
|
||||
|
||||
@@ -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<Extension>) | 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<Extension> {
|
||||
return codePreviewProvider ? codePreviewProvider(path) : Promise.resolve([]);
|
||||
}
|
||||
|
||||
/** @internal editor/core only. */
|
||||
export function _provideCodePreview(provider: ((path: string) => Promise<Extension>) | null) {
|
||||
codePreviewProvider = provider;
|
||||
}
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
+388
-69
@@ -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<ReturnType<typeof syntaxTree>["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<OccurrenceSearch>();
|
||||
|
||||
/** State of a run of Add Selection for Next Occurrence; any other selection change or edit ends it. */
|
||||
export const occurrenceSearch = StateField.define<OccurrenceSearch | null>({
|
||||
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;
|
||||
};
|
||||
@@ -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<unknown>[] {
|
||||
const effects: StateEffect<unknown>[] = [];
|
||||
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<unknown>[] {
|
||||
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<unknown>[] {
|
||||
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<unknown>[] {
|
||||
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<unknown>[] {
|
||||
const line = state.doc.lineAt(head);
|
||||
const root = foldToExpand(state, head) ?? foldable(state, line.from, line.to);
|
||||
if (!root) return [];
|
||||
const out: StateEffect<unknown>[] = [];
|
||||
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<unknown>[]): 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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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<typeof EditorSelection.cursor>[] = [];
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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<M extends { top: number; bottom: number }>(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<unknown>[] {
|
||||
const lineEnd = state.doc.lineAt(pos).to;
|
||||
const out: StateEffect<unknown>[] = [];
|
||||
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<HTMLElement>(".cm-openidea-overviewMark");
|
||||
if (!el?.dataset.pos) return;
|
||||
if (e.button !== 0) return;
|
||||
const marks = [...this.dom.querySelectorAll<HTMLElement>(".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();
|
||||
|
||||
@@ -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<string, Promise<LanguageSupport | null>>();
|
||||
|
||||
export function findLanguageDescription(uiMode: string | null, path: string): LanguageDescription | null {
|
||||
|
||||
@@ -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<boolean>();
|
||||
|
||||
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<boolean>({
|
||||
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<string, string> =>
|
||||
s.field(f) ? { class: "cm-openidea-blockCaret" } : {},
|
||||
),
|
||||
],
|
||||
});
|
||||
+70
-25
@@ -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 },
|
||||
|
||||
@@ -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<Extension> {
|
||||
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 ?? [],
|
||||
];
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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<ReturnType<typeof syntaxTree>["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;
|
||||
});
|
||||
@@ -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<HTMLElement>(".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<HTMLElement>(".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();
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<typeof import("@/workbench/contributions")>();
|
||||
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(<LspPopups />);
|
||||
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(<LspPopups />);
|
||||
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(<LspPopups />);
|
||||
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(<LspPopups />);
|
||||
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(<LspPopups />);
|
||||
const frame = () => document.querySelector<HTMLElement>("[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(
|
||||
<>
|
||||
<LspPopups />
|
||||
<DocumentationWindow />
|
||||
</>,
|
||||
);
|
||||
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(<LspPopups />);
|
||||
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 });
|
||||
});
|
||||
});
|
||||
+332
-56
@@ -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 <span className={styles.iconSpacer} />;
|
||||
}
|
||||
|
||||
function ListPreviewPanel({ preview }: { preview: ListPreview | null | "loading" }) {
|
||||
if (preview === "loading") return <div className={styles.previewPanel} aria-busy="true" />;
|
||||
if (!preview) return null;
|
||||
return (
|
||||
<div className={styles.previewPanel} aria-label="Preview">
|
||||
{preview.kind === "text" ? (
|
||||
<div className={styles.previewText}>{preview.text}</div>
|
||||
) : (
|
||||
<pre className={styles.diff}>
|
||||
{preview.lines.map((l, i) => (
|
||||
<div key={i} className={clsx(styles.diffLine, styles[`diff-${l.kind}`])}>
|
||||
{l.kind === "gap" ? "⋯" : l.text || " "}
|
||||
</div>
|
||||
))}
|
||||
{preview.note && <div className={styles.previewText}>{preview.note}</div>}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListPopup({ popup }: { popup: Extract<LspPopup, { kind: "list" }> }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const filtered = useMemo(() => {
|
||||
@@ -45,6 +85,30 @@ function ListPopup({ popup }: { popup: Extract<LspPopup, { kind: "list" }> }) {
|
||||
);
|
||||
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<LspPopup, { kind: "list" }> }) {
|
||||
|
||||
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<LspPopup, { kind: "list" }> }) {
|
||||
// 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
|
||||
open
|
||||
anchor={popup.anchor}
|
||||
onClose={closePopup}
|
||||
aria-label={popup.title}
|
||||
className={styles.listPopup}
|
||||
style={{ width }}
|
||||
className={clsx(styles.listPopup, popup.preview && styles.withPreview)}
|
||||
style={popup.preview ? undefined : { width }}
|
||||
>
|
||||
<div className={styles.listHeader}>
|
||||
<span className={styles.listTitle}>{popup.title}</span>
|
||||
{query && <span className={styles.query}>{query}</span>}
|
||||
</div>
|
||||
<div onKeyDown={onKeyDown} className={styles.listBody} style={{ height }}>
|
||||
<List
|
||||
items={filtered}
|
||||
getKey={(r) => r.item.id}
|
||||
activeIndex={activeIndex}
|
||||
onActiveChange={setActive}
|
||||
hoverSelects
|
||||
aria-label={popup.title}
|
||||
onActivate={(r) => activate(r.item)}
|
||||
empty={
|
||||
<span className={styles.empty}>
|
||||
<T>Nothing matches</T>
|
||||
</span>
|
||||
}
|
||||
renderItem={({ item, match }) => (
|
||||
<div
|
||||
className={clsx(styles.item, item.disabled && styles.disabled, item.emphasis && styles.emphasis)}
|
||||
title={item.disabled ?? undefined}
|
||||
>
|
||||
<ItemIcon item={item} />
|
||||
<HighlightedText className={styles.label} text={item.label} indices={match} tone="accent" />
|
||||
{item.preview && <span className={styles.preview}>{item.preview}</span>}
|
||||
<span className={styles.spacer} />
|
||||
{item.detail && <span className={styles.detail}>{item.detail}</span>}
|
||||
{item.shortcut && <span className={styles.shortcut}>{item.shortcut}</span>}
|
||||
</div>
|
||||
<div className={styles.listMain} style={popup.preview ? { width } : undefined}>
|
||||
<div className={styles.listHeader}>
|
||||
<span className={styles.listTitle}>{popup.title}</span>
|
||||
{query && <span className={styles.query}>{query}</span>}
|
||||
{popup.pin && (
|
||||
<IconButton
|
||||
icon="pin"
|
||||
size="small"
|
||||
title={popup.pin.label}
|
||||
onClick={() => {
|
||||
const run = popup.pin!.run;
|
||||
closePopup();
|
||||
run();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div onKeyDown={onKeyDown} className={styles.listBody} style={{ height }}>
|
||||
<List
|
||||
items={filtered}
|
||||
getKey={(r) => r.item.id}
|
||||
activeIndex={activeIndex}
|
||||
onActiveChange={setActive}
|
||||
hoverSelects
|
||||
aria-label={popup.title}
|
||||
onActivate={(r) => activate(r.item)}
|
||||
empty={
|
||||
<span className={styles.empty}>
|
||||
<T>Nothing matches</T>
|
||||
</span>
|
||||
}
|
||||
renderItem={({ item, match }) => {
|
||||
const n = popup.numbered && !query ? indexOf(item) + 1 : 0;
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.item, item.disabled && styles.disabled, item.emphasis && styles.emphasis)}
|
||||
title={item.disabled ?? undefined}
|
||||
>
|
||||
{popup.numbered && <span className={styles.mnemonic}>{n > 0 && n <= 9 ? n : ""}</span>}
|
||||
<ItemIcon item={item} />
|
||||
<HighlightedText className={styles.label} text={item.label} indices={match} tone="accent" />
|
||||
{item.preview && <span className={styles.preview}>{item.preview}</span>}
|
||||
<span className={styles.spacer} />
|
||||
{item.detail && <span className={styles.detail}>{item.detail}</span>}
|
||||
{item.shortcut && <span className={styles.shortcut}>{item.shortcut}</span>}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{popup.preview && <ListPreviewPanel preview={shownPreview} />}
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
@@ -209,25 +304,112 @@ function DomContent({ build, className }: { build: () => HTMLElement; className?
|
||||
return <div ref={ref} className={className} />;
|
||||
}
|
||||
|
||||
/** 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<HTMLDivElement>(null);
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
tabIndex={-1}
|
||||
data-lsp-doc={host}
|
||||
className={clsx(themeClasses, host === "popup" ? styles.docFrame : styles.docWindowFrame)}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
{entry && (
|
||||
<DomContent
|
||||
key={`${history.index}:${history.entries.length}`}
|
||||
className={styles.docBody}
|
||||
build={() => renderMarkup(entry.markup, entry.language, docLinkHandler(host))}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.docToolbar}>
|
||||
<IconButton
|
||||
icon="arrow-left"
|
||||
size="small"
|
||||
title="Back"
|
||||
shortcut="←"
|
||||
disabled={history.index === 0}
|
||||
onClick={() => docStep(host, -1)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="arrow-right"
|
||||
size="small"
|
||||
title="Forward"
|
||||
shortcut="→"
|
||||
disabled={history.index >= history.entries.length - 1}
|
||||
onClick={() => docStep(host, 1)}
|
||||
/>
|
||||
<span className={styles.spacer} />
|
||||
{entry?.source && (
|
||||
<IconButton icon="edit" size="small" title="Edit Source" shortcut="F4" onClick={() => void editDocSource(host)} />
|
||||
)}
|
||||
{host === "popup" && (
|
||||
<IconButton
|
||||
icon="open-in-tool-window"
|
||||
size="small"
|
||||
title="Show in Documentation Tool Window"
|
||||
shortcut={shortcutLabel("code.quickDocumentation") || undefined}
|
||||
onClick={() => moveDocPopupToWindow()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DocPopup({ popup }: { popup: Extract<LspPopup, { kind: "doc" }> }) {
|
||||
const focusSeq = useLspPopup((s) => s.focusSeq);
|
||||
const scope = useRef<HTMLDivElement>(null);
|
||||
const opened = useRef(focusSeq);
|
||||
useEffect(() => {
|
||||
if (focusSeq !== opened.current) scope.current?.focus({ preventScroll: true });
|
||||
if (focusSeq !== opened.current) scope.current?.querySelector<HTMLElement>("[data-lsp-doc]")?.focus({ preventScroll: true });
|
||||
}, [focusSeq]);
|
||||
return (
|
||||
<Popup open anchor={popup.anchor} onClose={closePopup} aria-label="Documentation" className={styles.docPopup}>
|
||||
<div ref={scope} tabIndex={-1} className={clsx(popup.themeClasses, styles.docScope)}>
|
||||
<DomContent className={styles.docBody} build={() => renderMarkup(popup.markup, popup.language)} />
|
||||
<Popup
|
||||
open
|
||||
anchor={popup.anchor}
|
||||
onClose={closePopup}
|
||||
aria-label="Documentation"
|
||||
className={styles.docPopup}
|
||||
// As in IDEA the popup leaves the keyboard to the editor until F1 focuses it.
|
||||
autoFocus={false}
|
||||
>
|
||||
<div ref={scope} className={styles.docScope}>
|
||||
<DocView host="popup" history={popup} themeClasses={popup.themeClasses} />
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
/** The Documentation tool window (F1 on a focused documentation popup). */
|
||||
export function DocumentationWindow() {
|
||||
const history = useDocWindow();
|
||||
if (!history.entries.length) {
|
||||
return (
|
||||
<div className={styles.docWindowEmpty}>
|
||||
<T>No documentation</T>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <DocView host="window" history={history} themeClasses={history.themeClasses} />;
|
||||
}
|
||||
|
||||
function DefinitionPopup({ popup }: { popup: Extract<LspPopup, { kind: "definition" }> }) {
|
||||
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<LspPopup, { kind: "definiti
|
||||
focus: true,
|
||||
});
|
||||
};
|
||||
const select = (index: number) =>
|
||||
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 (
|
||||
<Popup open anchor={popup.anchor} onClose={closePopup} aria-label="Definition" className={styles.defPopup}>
|
||||
<div className={styles.defHeader}>
|
||||
<Icon name={fileIconName(basename(location.path))} size={14} />
|
||||
<span className={styles.defFile}>{basename(location.path)}</span>
|
||||
<span className={styles.defPath}>
|
||||
{relativePath(root, location.path)}:{location.range.start.line + 1}
|
||||
</span>
|
||||
<span className={styles.spacer} />
|
||||
<Button size="small" variant="ghost" onClick={open}>
|
||||
<T>Open</T>
|
||||
</Button>
|
||||
</div>
|
||||
<div className={clsx(popup.themeClasses, styles.docScope)}>
|
||||
<DomContent className={styles.defBody} build={() => renderCode(popup.text, popup.language, "cm-lsp-code")} />
|
||||
<div className={styles.defFrame} tabIndex={-1} data-autofocus onKeyDown={onKeyDown}>
|
||||
<div className={styles.defHeader}>
|
||||
<Icon name={fileIconName(basename(location.path))} size={14} />
|
||||
{many ? (
|
||||
<Select
|
||||
variant="ghost"
|
||||
aria-label="Definition"
|
||||
value={String(popup.index)}
|
||||
options={popup.targets.map((t, i) => ({
|
||||
value: String(i),
|
||||
label: t.name,
|
||||
detail: `${basename(t.location.path)}:${t.location.range.start.line + 1}`,
|
||||
}))}
|
||||
onChange={(v) => select(Number(v))}
|
||||
/>
|
||||
) : (
|
||||
<span className={styles.defFile}>{basename(location.path)}</span>
|
||||
)}
|
||||
<span className={styles.defPath}>
|
||||
{relativePath(root, location.path)}:{location.range.start.line + 1}
|
||||
</span>
|
||||
<span className={styles.spacer} />
|
||||
{many && (
|
||||
<span className={styles.defCount}>
|
||||
{popup.index + 1}/{popup.targets.length}
|
||||
</span>
|
||||
)}
|
||||
<IconButton icon="edit" size="small" title="Edit Source" shortcut="F4" onClick={open} />
|
||||
</div>
|
||||
<div className={clsx(popup.themeClasses, styles.docScope)}>
|
||||
<DomContent
|
||||
key={popup.index}
|
||||
className={styles.defBody}
|
||||
build={() => renderCode(target.text, target.language, "cm-lsp-code")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
@@ -326,6 +543,63 @@ function PreviewDialog({ popup }: { popup: Extract<LspPopup, { kind: "preview" }
|
||||
);
|
||||
}
|
||||
|
||||
/** Reformat File… (⌥⇧⌘L): scope and options, then Reformat Code. */
|
||||
function ReformatDialog({ popup }: { popup: Extract<LspPopup, { kind: "reformat" }> }) {
|
||||
const [selectionOnly, setSelectionOnly] = useState(popup.hasSelection && popup.initial.selectionOnly);
|
||||
const [optimizeImports, setOptimizeImports] = useState(popup.canOptimizeImports && popup.initial.optimizeImports);
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={closePopup}
|
||||
title={popup.title}
|
||||
width={380}
|
||||
buttons={[
|
||||
{
|
||||
label: "Run",
|
||||
role: "default",
|
||||
autoFocus: true,
|
||||
onClick: () => {
|
||||
closePopup();
|
||||
popup.onRun({ selectionOnly, optimizeImports });
|
||||
},
|
||||
},
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
]}
|
||||
>
|
||||
<fieldset className={styles.reformatGroup}>
|
||||
<legend className={styles.reformatLegend}>
|
||||
<T>Scope</T>
|
||||
</legend>
|
||||
<label className={styles.reformatOption}>
|
||||
<input type="radio" name="reformat-scope" checked={!selectionOnly} onChange={() => setSelectionOnly(false)} />
|
||||
<T>Whole file</T>
|
||||
</label>
|
||||
<label className={clsx(styles.reformatOption, !popup.hasSelection && styles.disabled)}>
|
||||
<input
|
||||
type="radio"
|
||||
name="reformat-scope"
|
||||
checked={selectionOnly}
|
||||
disabled={!popup.hasSelection}
|
||||
onChange={() => setSelectionOnly(true)}
|
||||
/>
|
||||
<T>Selected text</T>
|
||||
</label>
|
||||
</fieldset>
|
||||
<fieldset className={styles.reformatGroup}>
|
||||
<legend className={styles.reformatLegend}>
|
||||
<T>Options</T>
|
||||
</legend>
|
||||
<Checkbox
|
||||
checked={optimizeImports}
|
||||
disabled={!popup.canOptimizeImports}
|
||||
onChange={setOptimizeImports}
|
||||
label="Optimize imports"
|
||||
/>
|
||||
</fieldset>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 <DefinitionPopup key={seq} popup={popup} />;
|
||||
case "preview":
|
||||
return <PreviewDialog key={seq} popup={popup} />;
|
||||
case "reformat":
|
||||
return <ReformatDialog key={seq} popup={popup} />;
|
||||
}
|
||||
}
|
||||
@@ -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) },
|
||||
|
||||
@@ -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>", "function", 3),
|
||||
]),
|
||||
];
|
||||
expect(lensTargets(items).map((t) => t.name)).toEqual(["Cart", "count"]);
|
||||
});
|
||||
});
|
||||
@@ -40,12 +40,15 @@ export interface LensTarget {
|
||||
col: number;
|
||||
}
|
||||
|
||||
/** Servers name callbacks and anonymous functions (`<function>`, `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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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<Provider>("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);
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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<DocWindowState>(() => ({ 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<boolean> {
|
||||
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<DocEntry | null> {
|
||||
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;
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
+28
-1
@@ -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<T>(path: string, fn: (doc: number) => Promise<T>): Promise<T> {
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+97
-12
@@ -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<FormatResult> {
|
||||
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<number>();
|
||||
// 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<FormatResult> {
|
||||
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<FormatResult> {
|
||||
const own = contributedAt<ContributedFormatter>(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<unknown> = async () => {},
|
||||
): Promise<void> {
|
||||
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 });
|
||||
})(),
|
||||
});
|
||||
}
|
||||
+102
-41
@@ -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<Location | null> {
|
||||
let found: Promise<Location | null> | 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<Range | null> {
|
||||
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<DefinitionTarget | null> {
|
||||
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<string>();
|
||||
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);
|
||||
}
|
||||
|
||||
+17
-3
@@ -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(),
|
||||
|
||||
@@ -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<typeof import("@/workbench/contributions")>();
|
||||
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<HTMLElement>(".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");
|
||||
});
|
||||
});
|
||||
+65
-4
@@ -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<InlayHint["kind"], string> = {
|
||||
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() {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<InplaceSession>();
|
||||
@@ -63,11 +66,30 @@ const sessionField = StateField.define<InplaceSession | null>({
|
||||
? {
|
||||
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<InplaceSessio
|
||||
});
|
||||
}
|
||||
|
||||
/** Replaces the name being edited (and so its occurrences) with `name`, selected. */
|
||||
export function putSuggestion(view: EditorView, name: string): boolean {
|
||||
const s = inplaceSession(view.state);
|
||||
if (!s) return false;
|
||||
const { from, to } = s.primary;
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: name },
|
||||
selection: EditorSelection.range(from, from + name.length),
|
||||
annotations: Transaction.addToHistory.of(false),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** ↑ / ↓ over the suggestions; false without any (the key then moves the caret, ending the session). */
|
||||
export function cycleSuggestion(view: EditorView, delta: 1 | -1): boolean {
|
||||
const s = inplaceSession(view.state);
|
||||
const list = s?.suggestions;
|
||||
// An open lookup keeps the arrows.
|
||||
if (!s || !list?.length || completionStatus(view.state) === "active") return false;
|
||||
const current = view.state.sliceDoc(s.primary.from, s.primary.to);
|
||||
const i = list.indexOf(current);
|
||||
const next = list[i < 0 ? (delta > 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) },
|
||||
]),
|
||||
);
|
||||
|
||||
|
||||
@@ -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<typeof import("@/platform/ipc")>();
|
||||
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<CodeAction, Promise<CodeAction>>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<CodeAction, Promise<CodeAction>>): 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<CodeAction, Promise<CodeAction>>): Promise<CodeAction> {
|
||||
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<CodeAction, Promise<CodeAction>>,
|
||||
signal: AbortSignal,
|
||||
): Promise<ListPreview | null> {
|
||||
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<CodeAction, Promise<CodeAction>>();
|
||||
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);
|
||||
|
||||
+41
-13
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -24,20 +24,81 @@ export interface ListPopupItem {
|
||||
disabled?: string | null;
|
||||
shortcut?: string;
|
||||
run: () => void | Promise<void>;
|
||||
/** 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<Location | null>;
|
||||
}
|
||||
|
||||
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<ListPreview | null>;
|
||||
/** 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<K extends LspPopup["kind"]>(kind: K, patch: Partial<Extract<LspPopup, { kind: K }>>): 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). */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+111
-2
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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<readonly HighlightRange[]>();
|
||||
|
||||
const mark = Decoration.mark({ class: "cm-lsp-chooser-highlight" });
|
||||
|
||||
const temporaryField = StateField.define<readonly HighlightRange[]>({
|
||||
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<unknown>[] = [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];
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<number>(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: "" }];
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,11 @@ let query: (request: number) => Promise<Result> = 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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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<Partial<StructuralRanges>> => ({ 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) }]);
|
||||
|
||||
@@ -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(`(?<![\\p{L}\\p{N}_$])${escaped}(?![\\p{L}\\p{N}_$])`, "gu");
|
||||
const ids = new Set(identifiers.map((o) => 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<void> {
|
||||
const ctx = docOf(view);
|
||||
@@ -353,22 +426,44 @@ export async function safeDelete(view: EditorView): Promise<void> {
|
||||
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)));
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<RefactoringId> = new Set([
|
||||
"extractVariable",
|
||||
"extractConstant",
|
||||
"extractField",
|
||||
"extractParameter",
|
||||
]);
|
||||
|
||||
/** Refactorings whose result is a new name renamed in place afterwards. */
|
||||
const NAMING_REFACTORINGS: ReadonlySet<RefactoringId> = 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<boolean> {
|
||||
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<string, Span[]>();
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<SeverityKey, string> = {
|
||||
info: "Weak warnings and hints",
|
||||
};
|
||||
|
||||
export function buildInspectionNodes(groups: ReturnType<typeof groupInspection>): 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<InspectionOptions> {
|
||||
try {
|
||||
return JSON.parse(globalThis.localStorage?.getItem(OPTIONS_KEY) ?? "{}") as Partial<InspectionOptions>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export const useInspectionOptions = create<InspectionOptions>(() => ({
|
||||
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<SeverityKey, number> = { error: 0, warning: 1, info: 2 };
|
||||
|
||||
/** Inspections across severities, merged by name (the same check may report errors and warnings). */
|
||||
function mergeKinds(
|
||||
groups: ReturnType<typeof groupInspection>,
|
||||
): Array<InspectionKindGroup & { severity: SeverityKey }> {
|
||||
const byKind = new Map<string, InspectionKindGroup & { severity: SeverityKey }>();
|
||||
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<typeof groupInspection>, 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<void> {
|
||||
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 (
|
||||
<>
|
||||
<IconButton icon="arrow-up" action="navigate.prevOccurrence" size="small" />
|
||||
<IconButton icon="arrow-down" action="navigate.nextOccurrence" size="small" />
|
||||
<IconButton
|
||||
icon="rerun"
|
||||
title="Rerun Inspection"
|
||||
@@ -81,15 +213,18 @@ export function InspectionHeaderActions() {
|
||||
const plural = (n: number, w: string) => `${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<Set<string>>(() => new Set());
|
||||
// Severities and inspections start expanded; files too unless there are many problems.
|
||||
const [lead, setLead] = useState<Node | null>(null);
|
||||
const tree = useRef<TreeHandle>(null);
|
||||
// Groups start expanded; files too unless there are many problems.
|
||||
const expanded = useMemo(() => {
|
||||
const out = new Set<string>();
|
||||
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<HTMLDivElement>) => {
|
||||
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 (
|
||||
<span className={styles.row}>
|
||||
<span className={clsx(styles.row, off)}>
|
||||
<Icon name={n.severity} size={14} className={styles[`sev-${n.severity}`]} />
|
||||
<span className={styles.strong}>{SEVERITY_LABEL[n.severity]}</span>
|
||||
<span className={styles.count}>{plural(n.count, "problem")}</span>
|
||||
@@ -115,14 +275,15 @@ export function InspectionView() {
|
||||
);
|
||||
case "inspection":
|
||||
return (
|
||||
<span className={styles.row}>
|
||||
<span className={clsx(styles.row, off)}>
|
||||
{!bySeverity && <Icon name={n.severity} size={14} className={styles[`sev-${n.severity}`]} />}
|
||||
<span className={styles.strong}>{n.name}</span>
|
||||
<span className={styles.count}>{n.count}</span>
|
||||
</span>
|
||||
);
|
||||
case "file":
|
||||
return (
|
||||
<span className={styles.row}>
|
||||
<span className={clsx(styles.row, off)}>
|
||||
<Icon name={fileIconName(basename(n.path))} size={16} className={styles.fileIcon} />
|
||||
<span className={state.selected ? styles.strong : undefined}>{basename(n.path)}</span>
|
||||
<span className={styles.muted}>{relativeDir(root, n.path)}</span>
|
||||
@@ -131,7 +292,7 @@ export function InspectionView() {
|
||||
);
|
||||
case "problem":
|
||||
return (
|
||||
<span className={styles.row} title={n.p.d.message}>
|
||||
<span className={clsx(styles.row, off)} title={n.p.d.message}>
|
||||
<span className={styles.message}>{n.p.d.message.split("\n")[0]}</span>
|
||||
<span className={styles.muted}>:{n.p.d.range.start.line + 1}</span>
|
||||
</span>
|
||||
@@ -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() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.body}>
|
||||
<Tree<Node>
|
||||
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 ? (
|
||||
<EmptyState icon="error" title={error} />
|
||||
) : running ? (
|
||||
<EmptyState icon="problems" title="Inspecting…" />
|
||||
) : summary ? (
|
||||
<EmptyState icon="check" title="No problems found" />
|
||||
) : (
|
||||
<EmptyState icon="problems" title="Run Code › Inspect Code… to analyse files" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className={styles.body} onKeyDown={onKeyDown}>
|
||||
<SplitGroup orientation="horizontal" id="inspection-details-split">
|
||||
<SplitPanel id="results" minSize={160} className={styles.panel}>
|
||||
<Tree<Node>
|
||||
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 ? (
|
||||
<EmptyState icon="error" title={error} />
|
||||
) : running ? (
|
||||
<EmptyState icon="problems" title="Inspecting…" />
|
||||
) : summary ? (
|
||||
<EmptyState icon="check" title="No problems found" />
|
||||
) : (
|
||||
<EmptyState icon="problems" title="Run Code › Inspect Code… to analyse files" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</SplitPanel>
|
||||
{roots.length > 0 && (
|
||||
<>
|
||||
<SplitHandle />
|
||||
<SplitPanel id="details" minSize={160} defaultSize="40%" className={styles.panel}>
|
||||
<ProblemDetails node={lead} root={root} />
|
||||
</SplitPanel>
|
||||
</>
|
||||
)}
|
||||
</SplitGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className={clsx(styles.details, styles.detailsEmpty)}>
|
||||
{node ? plural(count, "problem") : <T>Select a problem to see its details</T>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (node.kind === "inspection") {
|
||||
return (
|
||||
<div className={styles.details}>
|
||||
<div className={styles.detailsTitle}>{node.name}</div>
|
||||
<div className={styles.muted}>
|
||||
{SEVERITY_LABEL[node.severity]} · {plural(node.count, "problem")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const { p } = node;
|
||||
const sev = severityKey(p.d);
|
||||
return (
|
||||
<div className={styles.details}>
|
||||
<div className={styles.detailsTitle}>
|
||||
<Icon name={sev} size={14} className={styles[`sev-${sev}`]} />
|
||||
<span>{p.d.source ? `${p.d.source}${p.d.code ? ` (${p.d.code})` : ""}` : <T>Language server</T>}</span>
|
||||
</div>
|
||||
<p className={styles.detailsMessage}>{p.d.message}</p>
|
||||
<div className={styles.muted}>
|
||||
{relativeDir(root, p.path) ? `${relativeDir(root, p.path)}/` : ""}
|
||||
{basename(p.path)}:{p.d.range.start.line + 1}:{p.d.range.start.col + 1}
|
||||
</div>
|
||||
<div className={styles.detailsActions}>
|
||||
<Button size="small" icon="bulb" onClick={() => void showFixes(p)}>
|
||||
<T>Show Quick Fixes</T>
|
||||
</Button>
|
||||
<Button size="small" variant="ghost" onClick={() => void open(p)}>
|
||||
<T>Jump to Source</T>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string>;
|
||||
}
|
||||
|
||||
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<InspectState>(initialInspection);
|
||||
|
||||
let inflight: RequestId | null = null;
|
||||
@@ -146,6 +162,7 @@ export async function runInspection(choice: ScopeChoice): Promise<void> {
|
||||
error: null,
|
||||
scopeLabel: describeScope(choice, scope),
|
||||
last: choice,
|
||||
excluded: new Set(),
|
||||
});
|
||||
toolWindowService().show("inspection");
|
||||
if (!isTauri()) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<div className={styles.toolbar} role="toolbar" aria-label="Hierarchy actions">
|
||||
<IconButton
|
||||
@@ -58,6 +92,17 @@ function HierarchyToolbar() {
|
||||
/>
|
||||
<span className={styles.sep} />
|
||||
<IconButton icon="refresh" size="small" iconSize={16} title="Refresh" onClick={refreshHierarchy} />
|
||||
<span className={styles.sep} />
|
||||
<IconButton icon="arrow-up" action="navigate.prevOccurrence" size="small" iconSize={16} />
|
||||
<IconButton icon="arrow-down" action="navigate.nextOccurrence" size="small" iconSize={16} />
|
||||
<IconButton
|
||||
icon="autoscroll-to-source"
|
||||
size="small"
|
||||
iconSize={16}
|
||||
title="Autoscroll to Source"
|
||||
pressed={autoscroll}
|
||||
onClick={() => useHierarchyOptions.setState({ autoscroll: !autoscroll })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -78,6 +123,23 @@ export function HierarchyView() {
|
||||
},
|
||||
[children],
|
||||
);
|
||||
const tree = useRef<TreeHandle>(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);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<ViewState>((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<ProblemsOptions> {
|
||||
try {
|
||||
return JSON.parse(globalThis.localStorage?.getItem(OPTIONS_KEY) ?? "{}") as Partial<ProblemsOptions>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export const useProblemsOptions = create<ProblemsOptions>(() => ({
|
||||
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<Severity, string> = { 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<void> {
|
||||
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 (
|
||||
<>
|
||||
<IconButton icon="arrow-up" action="navigate.prevOccurrence" size="small" />
|
||||
<IconButton icon="arrow-down" action="navigate.nextOccurrence" size="small" />
|
||||
<IconButton icon="error" title="Show Errors" size="small" pressed={show.error} onClick={() => toggle("error")} />
|
||||
<IconButton
|
||||
icon="warning"
|
||||
@@ -132,14 +227,47 @@ export function ProblemsTabs() {
|
||||
export function ProblemsView() {
|
||||
const map = useProblems((s) => s.map);
|
||||
const { tab, show } = useProblemsView();
|
||||
const options = useProblemsOptions();
|
||||
const current = useCurrentPath();
|
||||
const root = useProject((s) => s.project?.root ?? "");
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
|
||||
const [lead, setLead] = useState<Node | null>(null);
|
||||
const tree = useRef<TreeHandle>(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<HTMLDivElement>) => {
|
||||
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() {
|
||||
<EmptyState icon="check" title={tab === "file" ? "No problems in this file" : "No problems found"} />
|
||||
);
|
||||
|
||||
const selected = target(lead);
|
||||
const list = (
|
||||
<Tree<Node>
|
||||
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 (
|
||||
<div className={styles.view}>
|
||||
<div className={styles.body}>
|
||||
<Tree<Node>
|
||||
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}
|
||||
/>
|
||||
<div className={styles.body} onKeyDownCapture={onKeyDownCapture}>
|
||||
{options.preview ? (
|
||||
<SplitGroup orientation="horizontal" id="problems-preview-split">
|
||||
<SplitPanel id="problems" minSize={120} className={styles.panel}>
|
||||
{list}
|
||||
</SplitPanel>
|
||||
<SplitHandle />
|
||||
<SplitPanel id="preview" minSize={120} defaultSize="50%" className={styles.panel}>
|
||||
<SourcePreview
|
||||
path={selected?.path ?? null}
|
||||
line={selected?.d.range.start.line ?? 0}
|
||||
highlights={selected ? [diagHighlight(selected.d)] : []}
|
||||
load={previewText}
|
||||
/>
|
||||
</SplitPanel>
|
||||
</SplitGroup>
|
||||
) : (
|
||||
list
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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] };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 ? <UsagesPopupInner /> : null;
|
||||
}
|
||||
|
||||
const ROW = 24;
|
||||
|
||||
function UsagesPopupInner() {
|
||||
const { anchor, symbol, groups, note } = useUsagesPopup();
|
||||
const items = useMemo<Usage[]>(() => groups.flatMap((g) => g.usages), [groups]);
|
||||
const preview = useUsagesPopupPreview((s) => s.preview);
|
||||
const all = useMemo<Usage[]>(() => 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 (
|
||||
<Popup open anchor={anchor} onClose={close} aria-label="Usages" className={styles.popup}>
|
||||
<div className={styles.header}>
|
||||
<span className={styles.title}>
|
||||
Usages{symbol ? ` of ${symbol}` : ""} <span className={styles.count}>{total} found</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.link}
|
||||
onClick={() => {
|
||||
close();
|
||||
void executeAction("find.usages");
|
||||
}}
|
||||
>
|
||||
Open in Find Window{keys ? ` ${keys}` : ""}
|
||||
</button>
|
||||
</div>
|
||||
{note && <div className={styles.note}>{note}</div>}
|
||||
<div className={styles.body} style={{ height: Math.min(14, items.length) * 24 }}>
|
||||
<List
|
||||
items={items}
|
||||
getKey={(u) => `${u.path}:${u.line}:${u.col}`}
|
||||
activeIndex={Math.min(active, items.length - 1)}
|
||||
onActiveChange={setActive}
|
||||
hoverSelects
|
||||
aria-label="Usages"
|
||||
onActivate={pick}
|
||||
renderItem={(u) => (
|
||||
<div className={styles.row}>
|
||||
<Icon name={fileIconName(basename(u.path))} size={14} className={styles.icon} />
|
||||
<UsagePreview usage={u} />
|
||||
<span className={styles.spacer} />
|
||||
<span className={styles.where}>
|
||||
{basename(u.path)}:{u.line + 1}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.focus} tabIndex={0} data-autofocus onKeyDown={onKeyDown}>
|
||||
<div className={styles.header}>
|
||||
<span className={styles.title}>
|
||||
Usages{symbol ? ` of ${symbol}` : ""}{" "}
|
||||
<span className={styles.count}>{filter ? `${items.length} of ${total}` : total} found</span>
|
||||
</span>
|
||||
{filter && (
|
||||
<span className={clsx(styles.filter, !items.length && styles.noMatch)} role="status">
|
||||
{filter}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="list"
|
||||
size="small"
|
||||
title="Show Preview"
|
||||
pressed={preview}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => setUsagesPopupPreview(!preview)}
|
||||
/>
|
||||
<button type="button" className={styles.link} onClick={openInFindWindow}>
|
||||
Open in Find Window{keys ? ` ${keys}` : ""}
|
||||
</button>
|
||||
</div>
|
||||
{note && <div className={styles.note}>{note}</div>}
|
||||
<div className={styles.body} style={{ height: Math.max(1, Math.min(14, items.length)) * ROW }}>
|
||||
<List
|
||||
items={items}
|
||||
getKey={(u) => `${u.path}:${u.line}:${u.col}`}
|
||||
activeIndex={current}
|
||||
onActiveChange={setActive}
|
||||
focusable={false}
|
||||
hoverSelects
|
||||
rowHeight={ROW}
|
||||
aria-label="Usages"
|
||||
onActivate={pick}
|
||||
empty={<div className={styles.empty}>Nothing matches '{filter}'</div>}
|
||||
renderItem={(u) => (
|
||||
<div className={styles.row}>
|
||||
<Icon name={fileIconName(basename(u.path))} size={14} className={styles.icon} />
|
||||
<UsagePreview usage={u} />
|
||||
<span className={styles.spacer} />
|
||||
<span className={styles.where}>
|
||||
{basename(u.path)}:{u.line + 1}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{preview && <UsageCodePreview usage={items[current] ?? null} />}
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
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<HTMLDivElement>(null);
|
||||
const view = useRef<EditorView | null>(null);
|
||||
const [loaded, setLoaded] = useState<string | null>(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 (
|
||||
<div className={styles.preview} role="region" aria-label="Preview">
|
||||
<div ref={host} className={clsx(styles.previewEditor, loaded !== path && styles.previewHidden)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<Tabs
|
||||
variant="compact"
|
||||
className={styles.tabs}
|
||||
aria-label="Usage searches"
|
||||
items={[{ id: String(search.id), label }]}
|
||||
items={tabs.map((t) => ({
|
||||
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<number, ReadonlySet<string>> }>(() => ({ byTab: {} }));
|
||||
const NONE: ReadonlySet<string> = 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<string>, usages: readonly Usage[]): Set<string> {
|
||||
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<string> }>({
|
||||
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<TreeHandle>(null);
|
||||
const [lead, setLead] = useState<Node | null>(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<HTMLDivElement>) => {
|
||||
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 (
|
||||
<span className={styles.row}>
|
||||
<span className={clsx(styles.row, isExcluded(n) && styles.excluded)}>
|
||||
<Icon name={fileIconName(basename(path))} size={16} className={styles.icon} />
|
||||
<span className={styles.file}>{basename(path)}</span>
|
||||
<span className={styles.muted}>{relativeDir(root, path)}</span>
|
||||
@@ -108,7 +216,7 @@ export function UsagesView() {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className={styles.row}>
|
||||
<span className={clsx(styles.row, excluded.has(n.key) && styles.excluded)}>
|
||||
<span className={styles.line}>{n.usage.line + 1}</span>
|
||||
<UsagePreview usage={n.usage} />
|
||||
</span>
|
||||
@@ -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?.()}
|
||||
/>
|
||||
<span className={styles.toolbarSep} />
|
||||
<IconButton icon="arrow-up" action="navigate.prevOccurrence" size="small" iconSize={16} />
|
||||
<IconButton icon="arrow-down" action="navigate.nextOccurrence" size="small" iconSize={16} />
|
||||
<span className={styles.toolbarSep} />
|
||||
<IconButton icon="expand-all" title="Expand All" size="small" iconSize={16} onClick={() => setAll(false)} />
|
||||
<IconButton icon="collapse-all" title="Collapse All" size="small" iconSize={16} onClick={() => setAll(true)} />
|
||||
</div>
|
||||
<div className={styles.body}>
|
||||
<div className={styles.body} onKeyDown={onKeyDown}>
|
||||
{(search.loading || search.error) && (
|
||||
<div className={styles.summary} role="status" aria-live="polite">
|
||||
{search.loading ? (
|
||||
@@ -146,38 +258,124 @@ export function UsagesView() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Tree<Node>
|
||||
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 ? (
|
||||
<EmptyState>
|
||||
<T>No usages found</T>
|
||||
</EmptyState>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<UsagesSplit preview={<UsagesPreview node={lead} />}>
|
||||
<Tree<Node>
|
||||
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 ? (
|
||||
<EmptyState>
|
||||
<T>No usages found</T>
|
||||
</EmptyState>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</UsagesSplit>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 (
|
||||
<SplitGroup orientation="horizontal" id="usages-preview-split">
|
||||
<SplitPanel id="usages" minSize={160} className={styles.panel}>
|
||||
{children}
|
||||
</SplitPanel>
|
||||
<SplitHandle />
|
||||
<SplitPanel id="preview" minSize={160} defaultSize="45%" className={styles.panel}>
|
||||
{preview}
|
||||
</SplitPanel>
|
||||
</SplitGroup>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<SourcePreview path={usage?.path ?? null} line={usage?.line ?? 0} highlights={highlights} load={previewText} />
|
||||
);
|
||||
}
|
||||
Loaded 100 of 262 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user