feat: Second pass aligning interactions with IntelliJ IDEA (#23)

* feat: Second pass aligning interactions with IntelliJ IDEA

Result tool windows
- Next/Previous Occurrence (⌥⌘↓/↑) across Find, Usages, Problems, TODO,
  Inspection and Hierarchy, with an "N of M" status hint
- Find: reuse the unpinned tab, Pin Tab, source preview, group by file or
  directory, exclude moves to the next result, Rerun
- Usages: tabs with pinning, exclude and preview
- Problems, TODO, Inspection: no autoscroll by default, severity-first sorting,
  context menu with quick fixes and copy description
- Trees: F4/⌘↓ jump to source; speed search Home/End and arrow handling

Find in Files, bookmarks, navigation
- Replace mode: Enter replaces the selection, ⇧⌥Enter replaces all, multi-select
- Pin Window toggle, highlighted read-only preview, search/replace history (⌥↓),
  Alt mnemonics, search context filter (comments / string literals)
- Mnemonic bookmarks (⌥F3, ⌃⇧digit, ⌃digit), ⌘F3 bookmarks popup, descriptions,
  ⌘-click gutter toggles a bookmark
- Select In popup (⌥F1); Recent Locations double-click and Delete;
  Search Everywhere remembers the query per tab

Code insight and refactoring
- Extract/Introduce continue with in-place rename and name suggestions;
  expression and occurrence choosers with live highlighting
- Safe Delete confirms first; numbered Refactor This
- Quick Documentation history, in-popup links and a Documentation tool window;
  Quick Definition switches between targets and shows the whole declaration
- Goto chooser lists symbol names and can pin to the Find window
- Parameter Info Tab/⇧Tab, intention preview, Reformat dialog and summary,
  inlay hint ⌘-click and context menu
- Live templates: Enter moves to the next field, two-step Esc, ⌘J as a lookup,
  language surrounders with mnemonics
- File Structure popup as a filterable tree; Show Usages filter and preview;
  call hierarchy jumps to call sites

Editor
- Paste re-indents each line by default; Move Statement moves whole statements
  in and out of blocks and re-indents
- Type over closing quotes of existing strings; ⌘D/⌘⌫ cover folded regions;
  ⌥⌫ deletes the preceding word
- ⌃G / ⌃⌘G whole-word rules and wrap hint; double-modifier caret cloning
- Visual-column block selection, nested Collapse All, recursive folding,
  fold selection toggle, Go to Matching Brace, gutter drag selects lines,
  sticky line and error stripe clicks, Insert toggles overwrite mode

Run, VCS, workbench
- Gutter icons show the last test result; Modify Run Configuration; test tree
  hides passed tests, context menu and assertion diff
- Stack frames resolve by fully qualified class name; context run chooser
- Inline Smart Step Into
- Git Log multi-select filters, "me" and date range; favourite branches,
  Fetch and tags in the branch popup; stash Keep index and Unstash options
- Terminal tab titles follow the shell; Distraction Free persists; gear menu;
  sticky balloons move to the notifications tool window after a while

* fix(vcs): Label restored local changes as yours after Smart Checkout; name the branch checked out

Conflicts from restoring stashed changes show the user's uncommitted
changes on the left like other operations, and the checkout notification
names the new branch instead of the previous one.

* fix: Close the merge editor on Apply; confirm before terminating a running Run tab

Like IDEA, applying a merge result closes its tab, and closing a Run tab
whose process still runs asks before terminating it (with a Settings
toggle and "Do not ask again").

* fix(terminal): Don't start a new session in the background after closing the last tab

The hidden Terminal window stays mounted, so it reopened a session as soon
as the last tab closed. It now opens the Local tab only once the window is
shown again, as IDEA does.

* fix(vcs): Start the merge result from a three-way merge; close the editor on Apply

The result pane now begins with non-conflicting changes merged and each
conflict showing the base text, resolved from the side panes, instead of
raw conflict markers. Apply now reports success, so the tab closes.

fix(search): Match File Structure filter on word starts

* fix: Limit Smart Step Into to the paused line; drop usages hints on callbacks; show full intention titles

Adapters that list every remaining call in the function (debugpy) made Smart
Step Into fall back to a chooser that offered calls from later lines. Targets
are now narrowed to the calls on the paused line, so they are picked in place.

Anonymous functions and callbacks no longer get a 'no usages' hint, and
intention titles are no longer cut short when there is room for them.

* fix: Undo / Redo from the Edit menu and Find Action use the editor's history

They ran the webview's native undo, which misses programmatic edits such as
templates, completion and refactorings, so nothing happened in the editor.
This commit is contained in:
l0ng-ai
2026-10-25 09:44:46 +08:00
committed by GitHub
parent 81e0e8e2d0
commit 82b6980a59
262 changed files with 13085 additions and 1596 deletions
+2 -2
View File
@@ -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(),
}
+3 -1
View File
@@ -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 }
}
}
+3 -2
View File
@@ -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;
}
}
}
}
+8
View File
@@ -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))) {
+17
View File
@@ -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());
}
+49 -7
View File
@@ -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)]
+10 -2
View File
@@ -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::*;
+13
View File
@@ -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.
+5
View File
@@ -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)] }]));
+1
View File
@@ -35,6 +35,7 @@ fn query(pattern: &str, is_regex: bool) -> SearchQuery {
exclude: vec![],
scope: SearchScope::Project,
max_matches: 0,
context: Default::default(),
}
}
+50 -6
View File
@@ -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;
+72
View File
@@ -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,
+1
View File
@@ -97,6 +97,7 @@ mod tests {
exclude: vec![],
scope: SearchScope::Project,
max_matches: 0,
context: Default::default(),
}
}
+1
View File
@@ -17,6 +17,7 @@ fn query(pattern: &str) -> SearchQuery {
exclude: vec![],
scope: SearchScope::Project,
max_matches: 0,
context: Default::default(),
}
}
+73
View File
@@ -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)])]
);
}
+21 -14
View File
@@ -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());
}
+26 -5
View File
@@ -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>)> {
+32
View File
@@ -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('-') {
+23 -3
View File
@@ -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<()> {
+2
View File
@@ -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();
+76 -5
View File
@@ -615,11 +615,11 @@ fn stash_push_list_apply_pop() {
let repo = fx.repo();
fx.write("s.txt", "change 1\n");
repo.stash_push(Some("first stash"), false).unwrap();
repo.stash_push(Some("first stash"), false, false).unwrap();
assert_eq!(fx.read("s.txt"), "base\n");
fx.write("s.txt", "change 2\n");
fx.write("u.txt", "untracked\n");
repo.stash_push(None, true).unwrap();
repo.stash_push(None, true, false).unwrap();
assert!(!fx.path("u.txt").exists());
let stashes = repo.stashes().unwrap();
@@ -629,11 +629,11 @@ fn stash_push_list_apply_pop() {
assert!(stashes[1].message.contains("first stash"), "{stashes:?}");
assert!(stashes[0].time_ms > 0.0);
repo.stash_apply(1, false).unwrap();
repo.stash_apply(1, false, false).unwrap();
assert_eq!(fx.read("s.txt"), "change 1\n");
assert_eq!(repo.stashes().unwrap().len(), 2);
repo.discard(&[fx.path("s.txt")]).unwrap();
repo.stash_apply(0, true).unwrap();
repo.stash_apply(0, true, false).unwrap();
assert_eq!(fx.read("s.txt"), "change 2\n");
assert!(fx.path("u.txt").exists());
assert_eq!(repo.stashes().unwrap().len(), 1);
@@ -642,6 +642,72 @@ fn stash_push_list_apply_pop() {
assert!(repo.stash_drop(0).is_err());
}
#[test]
fn stash_keep_index_reinstate_index_and_branch() {
let fx = Fixture::new();
fx.write("a.txt", "a\n");
fx.write("b.txt", "b\n");
fx.commit_all("base");
let repo = fx.repo();
let staged = |fx: &Fixture| fx.git(&["diff", "--cached", "--name-only"]).trim().to_string();
fx.write("a.txt", "a staged\n");
fx.git(&["add", "a.txt"]);
fx.write("b.txt", "b unstaged\n");
repo.stash_push(Some("keep"), false, true).unwrap();
// Keep index: the staged change stays, the unstaged one is stashed.
assert_eq!(fx.read("a.txt"), "a staged\n");
assert_eq!(fx.read("b.txt"), "b\n");
assert_eq!(staged(&fx), "a.txt");
fx.git(&["reset", "-q", "--hard"]);
repo.stash_apply(0, false, true).unwrap();
// Reinstate index: what was staged is staged again.
assert_eq!(staged(&fx), "a.txt");
assert_eq!(fx.read("b.txt"), "b unstaged\n");
fx.git(&["reset", "-q", "--hard"]);
repo.stash_apply(0, false, false).unwrap();
assert_eq!(staged(&fx), "");
fx.git(&["reset", "-q", "--hard"]);
assert!(repo.stash_branch(0, "-bad").is_err());
repo.stash_branch(0, "from-stash").unwrap();
assert_eq!(fx.git(&["rev-parse", "--abbrev-ref", "HEAD"]).trim(), "from-stash");
assert_eq!(fx.read("b.txt"), "b unstaged\n");
assert!(repo.stashes().unwrap().is_empty());
}
#[test]
fn log_filters_several_branches_and_authors() {
let fx = Fixture::new();
fx.write("a.txt", "1\n");
let c1 = fx.commit_all("one");
fx.git(&["checkout", "-q", "-b", "left"]);
fx.write("l.txt", "l\n");
let left = fx.commit_all("left");
fx.git(&["checkout", "-q", "-b", "right", &c1]);
fx.write("r.txt", "r\n");
fx.git(&["add", "-A"]);
fx.git(&["-c", "user.name=Someone Else", "-c", "user.email=else@corp.test", "commit", "-q", "-m", "right"]);
let right = fx.git(&["rev-parse", "HEAD"]).trim().to_string();
fx.git(&["checkout", "-q", "-b", "third", &c1]);
fx.write("t.txt", "t\n");
let third = fx.commit_all("third");
let repo = fx.repo();
let ids = |q: LogQuery| repo.log(&q).unwrap().into_iter().map(|c| c.id).collect::<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]
+15
View File
@@ -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]
+1
View File
@@ -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,
+27 -3
View File
@@ -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<'_> {
+11 -3
View File
@@ -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
View File
@@ -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[] } |
+18
View File
@@ -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;
}
+30 -8
View File
@@ -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" }),
+40 -2
View File
@@ -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;
};
+19 -5
View File
@@ -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),
+4
View File
@@ -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
View File
@@ -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;
};
+99 -7
View File
@@ -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";
+72
View File
@@ -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);
}
},
);
+331
View File
@@ -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");
});
});
+36 -6
View File
@@ -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();
+15
View File
@@ -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 {
+43
View File
@@ -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
View File
@@ -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 },
+31
View File
@@ -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 ?? [],
];
}
+6
View File
@@ -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),
+31 -2
View File
@@ -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;
});
+30 -6
View File
@@ -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();
+12
View File
@@ -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"];
+4
View File
@@ -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;
}
+5 -1
View File
@@ -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();
};
}
+13
View File
@@ -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;
}
+120 -2
View File
@@ -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);
}
+187
View File
@@ -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
View File
@@ -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} />;
}
}
+4 -1
View File
@@ -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) },
+20
View File
@@ -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"]);
});
});
+4 -1
View File
@@ -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);
}
};
+19 -2
View File
@@ -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);
};
}
// ---------------------------------------------------------------------------
+163
View File
@@ -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;
};
}
+15
View File
@@ -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
View File
@@ -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;
}
+28
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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(),
+69
View File
@@ -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
View File
@@ -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() {
+38 -1
View File
@@ -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);
});
});
+51 -2
View File
@@ -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) },
]),
);
+64
View File
@@ -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();
});
});
+48 -4
View File
@@ -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
View File
@@ -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;
}
+23 -4
View File
@@ -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;
}
+82 -6
View File
@@ -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). */
+53 -1
View File
@@ -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
View File
@@ -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,
+2 -1
View File
@@ -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"],
+58
View File
@@ -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];
}
+15
View File
@@ -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" },
+4 -2
View File
@@ -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) {
+30 -1
View File
@@ -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([]);
});
});
+70 -2
View File
@@ -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: "" }];
}
+36 -11
View File
@@ -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),
});
}
+84 -4
View File
@@ -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);
});
});
+28 -7
View File
@@ -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 });
}
+50 -3
View File
@@ -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) }]);
+105 -10
View File
@@ -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();
});
});
+327
View File
@@ -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,
});
}
+46
View File
@@ -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;
}
+326 -84
View File
@@ -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>
);
+17
View File
@@ -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", () => {
+2 -1
View File
@@ -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",
+17
View File
@@ -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");
});
});
+14 -3
View File
@@ -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;
+71 -7
View File
@@ -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;
}
+222 -40
View File
@@ -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);
}
+215 -44
View File
@@ -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;
}
+244 -46
View File
@@ -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} />
);
}

Some files were not shown because too many files have changed in this diff Show More