Compare commits

..
Author SHA1 Message Date
l0ng-ai cbb7bc952b fix(vim): draw the Normal-mode block cursor solid, with the character in the background colour
The focused block cursor was a 60% caret-coloured fill. On dark themes the caret is light
grey, so it read as a slightly brighter character and the cursor was easy to lose. It is now a
solid block in the caret colour with the character under it in the editor background colour,
as IdeaVim's block caret; the unfocused 1px outline and partial blocks are unchanged.

Fixes #3197
2026-11-14 01:27:00 +08:00
74 changed files with 190 additions and 1717 deletions

No files matched your search

-46
View File
@@ -309,13 +309,9 @@ pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos)
pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult<Vec<Location>> {
let target = position_document(host, window, doc, pos).await?;
// ⌘B on a declaration shows its usages, also when a plugin's index links the name elsewhere
// (a MyBatis mapper method to its XML statement): the server's answer comes along for the
// editor to tell.
if target == doc
&& kind == GotoKind::Definition
&& let Some(plugins) = crate::plugins::editor_plugin_host()
&& !on_declaration_name(host, window, doc, pos).await
{
let project = host.project(window)?;
// A notebook cell is a language-server document only: no plugin navigates in it.
@@ -356,30 +352,6 @@ pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: Got
definition_targets(result, found, in_string)
}
/// Whether the syntax tree puts the identifier at `pos` as a declaration's name (`int f()`'s `f`).
async fn on_declaration_name(host: &Host, window: &str, doc: DocId, pos: Pos) -> bool {
let Ok(project) = host.project(window) else { return false };
blocking(move || {
let snap = project.documents.text_snapshot(doc)?;
if snap.large {
return Ok(false);
}
let s = ide_syntax::Snapshot::doc(doc, snap.version, &snap.language, &snap.text);
let cancel = ide_core::CancellationToken::new();
let Ok(Some(at)) = project.structure.node_at(&s, Range::new(pos, pos), &cancel) else { return Ok(false) };
Ok(is_declaration_name(&at))
})
.await
.unwrap_or(false)
}
fn is_declaration_name(at: &ide_syntax::NodeAt) -> bool {
at.node.field.as_deref() == Some("name")
&& at.ancestors.first().is_some_and(|parent| {
["declaration", "definition", "declarator"].iter().any(|kind| parent.kind.ends_with(kind))
})
}
async fn in_string_literal(host: &Host, window: &str, doc: DocId, pos: Pos) -> bool {
let Ok(project) = host.project(window) else { return false };
blocking(move || {
@@ -2164,24 +2136,6 @@ mod tests {
assert_eq!(paths(definition_targets(Ok(vec![helper, guess]), Vec::new(), false)), ["/p/app/helpers.php"]);
}
#[test]
fn declaration_names_are_told_apart_from_uses() {
let syntax = std::sync::Arc::new(ide_language::Syntax::new());
let structure = ide_syntax::StructureService::new(std::sync::Arc::new(ide_language::SyntaxCache::new(syntax)));
let java = ide_core::LanguageId::new("java");
let text = "interface ShareMapper {\n int batchCancel(List<Long> ids);\n}\nclass S { int f(ShareMapper m) { return m.batchCancel(null); } }\n";
let snap = ide_syntax::Snapshot::text(&java, text);
let cancel = ide_core::CancellationToken::new();
let at = |line, col| {
let pos = Pos::new(line, col);
is_declaration_name(&structure.node_at(&snap, Range::new(pos, pos), &cancel).unwrap().unwrap())
};
assert!(at(1, 10), "the mapper method's name");
assert!(at(0, 12), "the interface's name");
assert!(!at(3, 45), "a call of the method");
assert!(!at(1, 4), "its return type");
}
#[test]
fn positions_inside_string_literals_are_told_apart() {
let syntax = std::sync::Arc::new(ide_language::Syntax::new());
+2 -8
View File
@@ -2554,8 +2554,7 @@ fn official_git_toolbox_fetches_on_schedule_after_idle_reclaim() {
f.install(&official("git-toolbox"));
// Focusing an editor wakes it to show the branch (nothing fetched yet).
let now = Instant::now();
let focused = f.project.join("src/notes.txt").to_string_lossy().into_owned();
f.host.post_events(Some("main"), vec![WorkbenchEvent::EditorFocused(Some(focused))]);
f.host.post_events(Some("main"), vec![WorkbenchEvent::EditorFocused(Some("src/notes.txt".into()))]);
f.host.flush_events_at(now + Duration::from_secs(3));
assert!(f.host.wait_events(Duration::from_secs(10)));
let item = |host: &PluginHost| {
@@ -2993,18 +2992,13 @@ fn official_csv_editor_edits_the_shared_document() {
f.host.view_event("openidea.csv-editor", "table", cell, inv.clone()).unwrap();
assert_eq!(f.workspace.text("people.csv"), "name,city\nAda,\"Paris, FR\"\nBob,Oslo\n");
// Add Row appends, also on wasm32 (where its row index used to wrap to the header).
let add = ide_plugin::ViewEvent::Button { id: "add-row".into() };
f.host.view_event("openidea.csv-editor", "table", add, inv.clone()).unwrap();
assert_eq!(f.workspace.text("people.csv"), "name,city\nAda,\"Paris, FR\"\nBob,Oslo\n,\n");
// Settings: without a header row every record is a row.
let mut values = serde_json::Map::new();
values.insert("firstRowIsHeader".into(), serde_json::Value::Bool(false));
f.workspace.settings.lock().insert("openidea.csv-editor".into(), values);
let view = f.host.render_view("openidea.csv-editor", "table", None, inv.clone()).unwrap();
let ide_plugin::ViewBody::Table(t) = &view.body else { panic!("{view:?}") };
assert_eq!((t.columns[0].as_str(), t.total_rows), ("Column 1", 4));
assert_eq!((t.columns[0].as_str(), t.total_rows), ("Column 1", 3));
// Undeclared views are refused before any code runs.
let err = f.host.render_view("openidea.csv-editor", "other", None, inv).unwrap_err();
+4 -10
View File
@@ -223,10 +223,6 @@ pub trait Handler: Send + Sync + 'static {
/// Called once the response to a `method` request is queued: what it sends follows the response.
fn after_response(&self, _method: &str) {}
/// Adjusts every message from the peer (responses included) before it is dispatched, e.g.
/// to repair values the peer spells more loosely than the types that read them accept.
fn normalize_incoming(&self, _message: &mut Value) {}
}
/// A [`Handler`] that ignores notifications and rejects requests.
@@ -589,10 +585,9 @@ impl Peer {
.expect("spawn jsonrpc notification thread");
}
// Reader: parses and dispatches; runs no handler code beyond `normalize_incoming`.
// Reader: parses and dispatches; never runs handler code itself.
{
let inner = inner.clone();
let handler = handler.clone();
tokio::spawn(async move {
let mut reader = BufReader::new(reader);
let seen_message = AtomicBool::new(false);
@@ -615,7 +610,7 @@ impl Peer {
match msg {
Ok(Some(body)) => {
seen_message.store(true, Ordering::Relaxed);
dispatch(&inner, &*handler, &notif_tx, &body);
dispatch(&inner, &notif_tx, &body);
}
Ok(None) => break,
Err(e) => {
@@ -788,16 +783,15 @@ async fn handle_request(inner: Arc<Inner>, handler: Arc<dyn Handler>, id: Value,
}
}
fn dispatch(inner: &Arc<Inner>, handler: &dyn Handler, notif_tx: &std::sync::mpsc::Sender<Incoming>, body: &[u8]) {
fn dispatch(inner: &Arc<Inner>, notif_tx: &std::sync::mpsc::Sender<Incoming>, body: &[u8]) {
tracing::trace!(target: "ide_jsonrpc::wire", "<-- {}", String::from_utf8_lossy(body));
let mut msg: Value = match serde_json::from_slice(body) {
let msg: Value = match serde_json::from_slice(body) {
Ok(v) => v,
Err(e) => {
tracing::warn!("jsonrpc: unparsable message: {e}");
return;
}
};
handler.normalize_incoming(&mut msg);
let Value::Object(mut obj) = msg else {
tracing::warn!("jsonrpc: ignoring non-object message");
return;
-26
View File
@@ -541,29 +541,3 @@ async fn output_before_the_handshake_is_reported_not_dropped() {
assert!(lines.iter().any(|l| l == "python: not found"), "{lines:?}");
assert!(lines.iter().any(|l| l.ends_with(". . .") && l.len() > 6), "{lines:?}");
}
#[tokio::test(flavor = "multi_thread")]
async fn incoming_messages_are_normalized_before_dispatch() {
struct Server;
impl Handler for Server {
fn on_notification(&self, _: &str, _: Value) {}
fn on_request(&self, _: &str, params: Value) -> Result<Value, (i64, String)> {
Ok(params)
}
}
struct Client;
impl Handler for Client {
fn on_notification(&self, _: &str, _: Value) {}
fn on_request(&self, _: &str, _: Value) -> Result<Value, (i64, String)> {
Err((codes::METHOD_NOT_FOUND, "no".into()))
}
fn normalize_incoming(&self, message: &mut Value) {
if let Some(result) = message.get_mut("result") {
*result = json!("normalized");
}
}
}
let (client, _server) = pair(Arc::new(Client), Arc::new(Server));
let v: Value = client.request("echo", "raw", None).await.unwrap();
assert_eq!(v, json!("normalized"));
}
-4
View File
@@ -391,10 +391,6 @@ fn tsserver_ran_out_of_memory(text: &str) -> bool {
}
impl Handler for ClientHandler {
fn normalize_incoming(&self, message: &mut Value) {
crate::uri::encode_loose_file_uris(message);
}
fn on_notification(&self, method: &str, params: Value) {
let Some(mgr) = self.mgr.upgrade() else { return };
match method {
-71
View File
@@ -1,8 +1,6 @@
//! `file://` URI ⇄ path conversion (RFC 8089, VS Code-compatible encoding).
use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode};
use serde_json::Value;
use std::borrow::Cow;
use std::path::{Path, PathBuf};
use std::str::FromStr;
@@ -24,53 +22,6 @@ const PATH: &AsciiSet = &CONTROLS
.add(b'|')
.add(b'}');
/// What RFC 3986 doesn't allow in a URI and a server may still leave unencoded in a `file:`
/// URI (non-ASCII is always encoded); `%` stays, it starts the escapes already there.
const LOOSE: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'<')
.add(b'>')
.add(b'[')
.add(b'\\')
.add(b']')
.add(b'^')
.add(b'`')
.add(b'{')
.add(b'|')
.add(b'}');
/// Percent-encodes the characters URIs don't allow in every `file:` URI in `value`, string
/// values and object keys alike (a workspace edit's `changes`). Eclipse's jdtls sends a path
/// as is (`file:///H:/项目/…`), which `lsp_types::Uri` rejects, failing the whole response.
pub fn encode_loose_file_uris(value: &mut Value) {
match value {
Value::String(s) => {
if let Some(encoded) = encode_loose(s) {
*s = encoded;
}
}
Value::Array(items) => items.iter_mut().for_each(encode_loose_file_uris),
Value::Object(map) => {
if map.keys().any(|k| encode_loose(k).is_some()) {
*map = std::mem::take(map).into_iter().map(|(k, v)| (encode_loose(&k).unwrap_or(k), v)).collect();
}
map.values_mut().for_each(encode_loose_file_uris);
}
_ => {}
}
}
fn encode_loose(s: &str) -> Option<String> {
if !s.get(..5).is_some_and(|scheme| scheme.eq_ignore_ascii_case("file:")) {
return None;
}
match Cow::from(utf8_percent_encode(s, LOOSE)) {
Cow::Owned(encoded) => Some(encoded),
Cow::Borrowed(_) => None,
}
}
pub fn path_to_uri_string(path: &Path) -> String {
let s = if cfg!(windows) {
dunce::simplified(path).to_string_lossy().replace('\\', "/")
@@ -144,28 +95,6 @@ pub use ide_core::text::is_virtual_uri;
mod tests {
use super::*;
#[test]
fn file_uris_a_server_leaves_unencoded_are_encoded() {
// jdtls on a project under a Chinese folder (`java.usages` / references response).
let mut v = serde_json::json!({ "result": [{
"location": { "uri": "file:///H:/项目/online disk/A.java", "range": { "start": { "line": 1, "character": 2 }, "end": { "line": 1, "character": 3 } } },
"kind": "unknown"
}], "edit": { "changes": { "file:///tmp/目录/b.java": [] } }, "text": "项目" });
let raw = v["result"][0]["location"].clone();
assert!(serde_json::from_value::<lsp_types::Location>(raw).is_err(), "lsp-types rejects it as sent");
encode_loose_file_uris(&mut v);
let uri = v["result"][0]["location"]["uri"].as_str().unwrap();
assert_eq!(uri, "file:///H:/%E9%A1%B9%E7%9B%AE/online%20disk/A.java");
let location: lsp_types::Location = serde_json::from_value(v["result"][0]["location"].clone()).unwrap();
assert!(uri_to_path(location.uri.as_str()).unwrap().ends_with("项目/online disk/A.java"));
assert!(v["edit"]["changes"].get("file:///tmp/%E7%9B%AE%E5%BD%95/b.java").is_some());
assert_eq!(v["text"], "项目", "only file: URIs change");
// Already encoded: untouched.
let mut encoded = serde_json::json!("file:///a%20b/%E9%A1%B9.java");
encode_loose_file_uris(&mut encoded);
assert_eq!(encoded, "file:///a%20b/%E9%A1%B9.java");
}
#[cfg(unix)]
#[test]
fn round_trip() {
+1 -22
View File
@@ -65,26 +65,5 @@ fn bench(c: &mut Criterion) {
g.finish();
}
// No .ignore file: the query's subtree masks must prevent traversal themselves.
fn excluded_subtrees(c: &mut Criterion) {
let dir = tempfile::tempdir().unwrap();
for module in 0..100 {
let root = dir.path().join(format!("modules/m{module}"));
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/main.go"), "needle").unwrap();
for dependency in 0..50 {
let dep = root.join(format!("node_modules/dep{dependency}/lib"));
std::fs::create_dir_all(&dep).unwrap();
std::fs::write(dep.join("index.js"), "needle").unwrap();
}
}
let q = SearchQuery { exclude: vec!["**/node_modules/**".into()], ..query("needle", false) };
assert_eq!(run(dir.path(), &q), 100);
let mut group = c.benchmark_group("search_excluded_subtrees");
group.sample_size(15);
group.bench_function("5k_dependencies", |b| b.iter(|| black_box(run(dir.path(), &q))));
group.finish();
}
criterion_group!(benches, bench, excluded_subtrees);
criterion_group!(benches, bench);
criterion_main!(benches);
+13 -60
View File
@@ -143,22 +143,18 @@ pub(crate) fn run(req: Request<'_>) -> IdeResult<SearchSummary> {
}
if !ctx.ctl.should_stop() {
walk_files(
&on_disk,
&|path| ctx.mask.has_directory_exclusions() && ctx.mask.excludes_dir(&rel_path(ctx.root, path)),
|| {
let mut searcher = searcher_builder.build();
let matcher = matcher.clone();
let ctx = &ctx;
Box::new(move |path| {
if ctx.ctl.should_stop() {
return WalkState::Quit;
}
ctx.search_file(path, &matcher, &mut searcher);
if ctx.ctl.stopped() { WalkState::Quit } else { WalkState::Continue }
})
},
);
walk_files(&on_disk, || {
let mut searcher = searcher_builder.build();
let matcher = matcher.clone();
let ctx = &ctx;
Box::new(move |path| {
if ctx.ctl.should_stop() {
return WalkState::Quit;
}
ctx.search_file(path, &matcher, &mut searcher);
if ctx.ctl.stopped() { WalkState::Quit } else { WalkState::Continue }
})
});
}
let cancelled = ctx.ctl.cancelled.load(Relaxed) || req.cancel.is_cancelled();
@@ -180,11 +176,7 @@ pub(crate) type VisitFile<'s> = Box<dyn FnMut(&Path) -> WalkState + Send + 's>;
/// them, while ignored files elsewhere (`.env`) and hidden files and folders
/// (`.github`, `.eslintrc`) are visited. `.git`, symlinks and archives (jar,
/// zip: browsed, not searched) are never visited.
pub(crate) fn walk_files<'s>(
roots: &[PathBuf],
skip_dir: &'s (dyn Fn(&Path) -> bool + Sync),
mut visitor: impl FnMut() -> VisitFile<'s> + Send,
) {
pub(crate) fn walk_files<'s>(roots: &[PathBuf], mut visitor: impl FnMut() -> VisitFile<'s> + Send) {
let Some((first, rest)) = roots.split_first() else { return };
let mut walk = WalkBuilder::new(first);
for path in rest {
@@ -212,9 +204,6 @@ pub(crate) fn walk_files<'s>(
};
let Some(file_type) = entry.file_type() else { return WalkState::Continue };
if file_type.is_dir() {
if skip_dir(entry.path()) {
return WalkState::Skip;
}
// The walker drops ignored files unseen, so the files of every walked
// directory come from listing it; the walker only picks the directories.
let Ok(children) = std::fs::read_dir(entry.path()) else { return WalkState::Continue };
@@ -606,39 +595,3 @@ mod tests {
}
}
}
#[cfg(test)]
mod walk_tests {
use super::*;
use std::sync::Mutex;
#[test]
fn excluded_subtree_is_not_visited() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("edge/device/build/deep")).unwrap();
std::fs::create_dir_all(root.join("edge/device/src")).unwrap();
std::fs::write(root.join("edge/device/build/deep/generated.go"), "hit").unwrap();
std::fs::write(root.join("edge/device/src/main.go"), "hit").unwrap();
let mask = FileMask::new(&[], &["**/build/**".into()]).unwrap();
let seen = Mutex::new(Vec::new());
walk_files(&[root.to_path_buf()], &|path| mask.excludes_dir(&rel_path(root, path)), || {
let seen = &seen;
Box::new(move |path| {
// No file-level mask here: a visit means the walker entered the subtree.
seen.lock().unwrap().push(rel_path(root, path));
WalkState::Continue
})
});
assert_eq!(*seen.lock().unwrap(), ["edge/device/src/main.go"]);
seen.lock().unwrap().clear();
walk_files(&[root.join("edge/device/build/deep")], &|path| mask.excludes_dir(&rel_path(root, path)), || {
let seen = &seen;
Box::new(move |path| {
seen.lock().unwrap().push(rel_path(root, path));
WalkState::Continue
})
});
assert!(seen.lock().unwrap().is_empty());
}
}
+1 -1
View File
@@ -43,7 +43,7 @@ pub fn scope_files(
on_disk.push(path);
}
}
walk_files(&on_disk, &|path| mask.has_directory_exclusions() && mask.excludes_dir(&rel_path(root, path)), || {
walk_files(&on_disk, || {
let found = &found;
let keep = &keep;
Box::new(move |path| {
-80
View File
@@ -12,7 +12,6 @@ pub struct FileMask {
exclude_name: GlobSet,
exclude_path: GlobSet,
has_include: bool,
exclude_dirs: GlobSet,
}
impl FileMask {
@@ -21,7 +20,6 @@ impl FileMask {
pub fn new(include: &[String], exclude: &[String]) -> IdeResult<Self> {
let mut inc = Lists::default();
let mut exc = Lists::default();
let mut dirs = Lists::default();
for (entries, is_include_list) in [(include, true), (exclude, false)] {
for mask in entries.iter().flat_map(|e| e.split(',')) {
let mask = mask.trim();
@@ -34,20 +32,10 @@ impl FileMask {
}
let target = if is_include_list != negated { &mut inc } else { &mut exc };
target.push(mask)?;
// Only a trailing /** guarantees that every descendant is excluded.
// File masks and inclusion masks cannot safely prune a directory.
if is_include_list == negated
&& !mask.contains('\\')
&& let Some(prefix) = mask.strip_suffix("/**")
&& !prefix.is_empty()
{
dirs.push(&format!("{prefix}/"))?;
}
}
}
Ok(Self {
has_include: inc.count > 0,
exclude_dirs: dirs.path.build().map_err(glob_error)?,
include_name: inc.name.build().map_err(glob_error)?,
include_path: inc.path.build().map_err(glob_error)?,
exclude_name: exc.name.build().map_err(glob_error)?,
@@ -55,20 +43,6 @@ impl FileMask {
})
}
pub(crate) fn has_directory_exclusions(&self) -> bool {
!self.exclude_dirs.is_empty()
}
/// Whether an exclusion covers every file below this directory. This is
/// deliberately conservative; ordinary masks still filter individual files.
pub(crate) fn excludes_dir(&self, rel_path: &str) -> bool {
if self.exclude_dirs.is_empty() {
return false;
}
let path = format!("{rel_path}/");
path.match_indices('/').any(|(i, _)| self.exclude_dirs.is_match(&path[..=i]))
}
/// `rel_path` uses `/` separators.
pub fn matches(&self, rel_path: &str) -> bool {
let name = rel_path.rsplit('/').next().unwrap_or(rel_path);
@@ -159,60 +133,6 @@ mod tests {
assert!(!m.matches("Cargo.lock"));
}
#[test]
fn only_complete_subtree_exclusions_prune_directories() {
let m = mask(&["*.go"], &["**/node_modules/**", "edge/device/build/**"]);
for dir in [
"node_modules",
"edge/node_modules",
"edge/device/build",
"edge/device/build/deep",
"edge/node_modules/pkg/lib",
] {
assert!(m.excludes_dir(dir), "{dir}");
}
for dir in ["edge", "edge/device", "internal/build", "src.go"] {
assert!(!m.excludes_dir(dir), "{dir}");
}
for pattern in ["*.go", "build", "build/*", "build/", "build/**/*.go"] {
assert!(!mask(&[], &[pattern]).excludes_dir("build"), "{pattern}");
}
}
#[test]
fn directory_pruning_preserves_mask_negation_semantics() {
assert!(mask(&["!vendor/**"], &[]).excludes_dir("vendor"));
assert!(!mask(&[], &["!vendor/**"]).excludes_dir("vendor"));
// Exclusions already win over inclusions in FileMask, irrespective of order.
let m = mask(&["vendor/keep.go"], &["vendor/**"]);
assert!(m.excludes_dir("vendor"));
assert!(!m.matches("vendor/keep.go"));
assert!(mask(&[], &["./vendor/**, **/node_modules/**"]).excludes_dir("vendor"));
}
#[test]
fn directory_pruning_uses_the_same_case_rules_as_files() {
let m = mask(&[], &["Vendor/**"]);
let case_insensitive = cfg!(any(target_os = "macos", target_os = "windows"));
assert_eq!(m.excludes_dir("vendor"), case_insensitive);
assert_eq!(m.matches("vendor/a.go"), !case_insensitive);
}
#[test]
fn pruned_directory_descendants_are_already_excluded() {
for pattern in ["vendor/**", "**/build/**", "modules/*/build/**", "[ab]/**", "**/**"] {
let m = mask(&[], &[pattern]);
for dir in ["vendor", "vendor/deep", "build", "a", "modules/a/build", "modules/a/src", "x/build/y"] {
if m.excludes_dir(dir) {
for file in ["main.go", "deep/lib.go", ".hidden/config", "node_modules/a.js"] {
assert!(!m.matches(&format!("{dir}/{file}")), "{pattern}: {dir}/{file}");
}
}
}
}
assert!(!mask(&[], &[r"escaped\*/**"]).excludes_dir("escaped*"));
}
#[test]
fn invalid_glob() {
let err = FileMask::new(&["a[".into()], &[]).unwrap_err();
-39
View File
@@ -797,42 +797,3 @@ fn search_context_reads_non_utf8_files() {
let p = project(&[("a.rs", &gbk("foo(\"新增\"); // 中文 foo\n"))]);
assert_eq!(run_context(p.path(), C::InComments), vec![("a.rs".to_owned(), 0, vec![(17, 20)])]);
}
#[test]
fn subtree_masks_preserve_results_across_search_scopes() {
let p = project(&[
("edge/build/deep/generated.go", b"hit"),
("edge/src/main.go", b"hit"),
("internal/build/build.go", b"hit"),
("edge/build-notes.go", b"hit"),
]);
let q = SearchQuery { exclude: vec!["edge/build/**".into()], ..query("hit") };
let (views, _) = run(p.path(), &q);
assert_eq!(paths(&views), ["edge/build-notes.go", "edge/src/main.go", "internal/build/build.go"]);
let mask = ide_search::FileMask::new(&q.include, &q.exclude).unwrap();
for scope in [
SearchScope::Directory { path: "edge/build".into() },
SearchScope::Files { paths: vec!["edge/build/deep/generated.go".into()] },
] {
let (views, _) = run(p.path(), &SearchQuery { scope: scope.clone(), ..q.clone() });
assert!(views.is_empty());
assert!(ide_search::scope_files(p.path(), &scope, &mask, None, &CancellationToken::new()).unwrap().is_empty());
}
// A file-name exclusion must not prune a same-named directory.
let (views, _) = run(p.path(), &SearchQuery { exclude: vec!["build".into()], ..query("hit") });
assert_eq!(views.len(), 4);
}
#[test]
fn ignore_whitelists_do_not_override_explicit_subtree_masks() {
let p = project(&[
(".gitignore", b"generated/*\n!generated/keep/\n"),
("generated/keep/.gitignore", b"!keep.go\n"),
("generated/keep/keep.go", b"needle"),
("src/main.go", b"needle"),
]);
let (views, _) = run(p.path(), &query("needle"));
assert_eq!(paths(&views), ["generated/keep/keep.go", "src/main.go"]);
let (views, _) = run(p.path(), &SearchQuery { exclude: vec!["generated/**".into()], ..query("needle") });
assert_eq!(paths(&views), ["src/main.go"]);
}
+6 -29
View File
@@ -1410,16 +1410,10 @@ impl Tools {
.ok_or_else(|| IdeError::new(ErrorKind::Io, format!("`{main}` was not found in {}", dl.name)))?;
let rel = found.strip_prefix(&pkg).expect("inside pkg").to_path_buf();
let final_dir = self.root.join(spec.name);
// Downloads made by the IDE itself are not quarantined, but archives may carry the flag.
clear_quarantine(&pkg);
// The previous install is removed in the background once replaced: deleting a large
// tool takes a while, and there is nothing left to cancel by then.
let previous = self.root.join(format!(".previous-{}", spec.name));
let _ = std::fs::remove_dir_all(&previous);
put_in_place(&pkg, &final_dir, &previous, cancel)?;
std::thread::spawn(move || {
let _ = std::fs::remove_dir_all(previous);
});
if final_dir.exists() {
std::fs::remove_dir_all(&final_dir)?;
}
std::fs::rename(&pkg, &final_dir)?;
let target = final_dir.join(&rel);
let bin = final_dir.join("bin");
if is_bundle(main) {
@@ -1434,6 +1428,8 @@ impl Tools {
archive::link_executable(&bin, main, &target)?;
}
}
// Downloads made by the IDE itself are not quarantined, but archives may carry the flag.
clear_quarantine(&final_dir);
self.write_manifest(spec.name, &Manifest { version: Some(dl.version.clone()), source: Some(dl.url.clone()) })
}
@@ -1567,25 +1563,6 @@ fn fetch_verified(
}
/// Removes the quarantine flag archives may carry, so macOS runs what was unpacked.
/// Moves the unpacked `pkg` to `dest`, moving a previous install aside to `aside`.
/// Cancelling stops it until then: the last point where the previous install is untouched.
fn put_in_place(pkg: &Path, dest: &Path, aside: &Path, cancel: &CancellationToken) -> IdeResult<()> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let replaced = dest.exists();
if replaced {
std::fs::rename(dest, aside)?;
}
if let Err(e) = std::fs::rename(pkg, dest) {
if replaced {
let _ = std::fs::rename(aside, dest);
}
return Err(e.into());
}
Ok(())
}
fn clear_quarantine(dir: &Path) {
#[cfg(target_os = "macos")]
{
-19
View File
@@ -916,22 +916,3 @@ exit 1
let args = fs::read_to_string(log).unwrap();
assert!(args.contains(&format!("--python {}", sdk.join("python3.14").display())), "{args}");
}
#[test]
fn a_cancelled_install_leaves_the_previous_one_in_place() {
let tmp = tempfile::tempdir().unwrap();
let (pkg, dest, aside) = (tmp.path().join("pkg"), tmp.path().join("tool"), tmp.path().join(".previous-tool"));
fs::create_dir_all(&pkg).unwrap();
fs::write(pkg.join("new"), "").unwrap();
fs::create_dir_all(&dest).unwrap();
fs::write(dest.join("old"), "").unwrap();
let cancel = CancellationToken::new();
cancel.cancel();
let err = put_in_place(&pkg, &dest, &aside, &cancel).unwrap_err();
assert_eq!(err.kind, ErrorKind::Cancelled);
assert!(dest.join("old").exists() && pkg.join("new").exists() && !aside.exists());
put_in_place(&pkg, &dest, &aside, &CancellationToken::new()).unwrap();
assert!(dest.join("new").exists() && !dest.join("old").exists());
assert!(aside.join("old").exists(), "the previous install is moved aside");
}
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
ec93db9b37437986167b4040714f5cda5cb7a2fc138e6c2dc2a9447faaf5a802
446156fc345df7c8252592f73129f3996139e5980c6ed9a296a9cbecc334eb17
+1 -33
View File
@@ -31,19 +31,10 @@ struct Config {
enabled: bool,
}
/// Whether `endpoint` is OpenAI's API host itself, so the key may go to it:
/// `https`, exactly `api.openai.com` (or `:443`), and no `user@` before the host
/// (`https://api.openai.com@localhost` is localhost).
pub fn is_openai(endpoint: &str) -> bool {
let Some(rest) = endpoint.strip_prefix("https://") else { return false };
let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
matches!(authority, "api.openai.com" | "api.openai.com:443")
}
fn config() -> Config {
let get = |k: &str| storage::get(k).ok().flatten();
let endpoint = get("endpoint").unwrap_or_else(|| LOCAL.into());
let openai = is_openai(&endpoint);
let openai = endpoint.starts_with(OPENAI);
let model = get("model").unwrap_or_else(|| (if openai { OPENAI_MODEL } else { LOCAL_MODEL }).into());
Config { endpoint, model, openai, enabled: get("enabled").as_deref() != Some("false") }
}
@@ -204,29 +195,6 @@ mod tests {
assert!(sent.headers.contains(&("Authorization".into(), "Bearer sk-test".into())));
}
#[test]
fn the_key_goes_to_openai_only() {
assert!(is_openai(OPENAI));
assert!(is_openai("https://api.openai.com/"));
assert!(is_openai("https://api.openai.com:443/v1"));
for other in [
"https://api.openai.com@localhost:9999",
"https://api.openai.com:x@localhost",
"https://api.openai.com.example.com",
"https://api.openai.com\\@localhost",
"http://api.openai.com",
"http://localhost:11434",
] {
assert!(!is_openai(other), "{other}");
}
let host = MockHost::install().with_capabilities(&["storage"]);
AiCompletion::default()
.run_command("set-endpoint", &host.invocation(), Some("https://api.openai.com@localhost:9999"))
.unwrap();
assert!(!config().openai, "no key for a look-alike endpoint");
}
#[test]
fn quiet_when_cancelled_denied_or_mid_word() {
let host = MockHost::install().with_capabilities(&["storage"]);
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
1bd90bca250bb06ab3b0f27b045110c722685d894ce79d23648cb7c795d2c033
b072d07c4aa4a972a71fffba355ea215da7860b8f5527c6074055f52c52a29be
+1 -15
View File
@@ -106,16 +106,10 @@ fn project_of(path: &str) -> Option<Project> {
host::projects()
.ok()?
.into_iter()
.filter(|p| p.root().is_ok_and(|r| is_under(path, &r)))
.filter(|p| p.root().is_ok_and(|r| path.starts_with(&r)))
.max_by_key(|p| p.root().map(|r| r.len()).unwrap_or(0))
}
/// Whether `path` is `root` or inside it (`/proj-x/a` is not inside `/proj`).
fn is_under(path: &str, root: &str) -> bool {
path.strip_prefix(root)
.is_some_and(|rest| rest.is_empty() || root.ends_with(['/', '\\']) || rest.starts_with(['/', '\\']))
}
fn windows_path(path: &str) -> String {
let path = path.replace('\\', "/");
if let Some(rest) = path.strip_prefix("//?/UNC/") {
@@ -317,14 +311,6 @@ mod tests {
assert!(!report_matches_file("/project/A\\B.java", "/project/A/B.java"));
}
#[test]
fn a_project_contains_only_its_own_paths() {
assert!(is_under("/proj/A.java", "/proj"));
assert!(is_under("C:\\proj\\A.java", "C:\\proj"));
assert!(is_under("/A.java", "/"));
assert!(!is_under("/proj-x/A.java", "/proj"));
}
#[test]
fn windows_reports_match_the_saved_file_and_keep_quick_fixes() {
for (root, reported) in [
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
c4c86cef9512ac03193e7d89d39b6ee41665c2d4721a78f9415d406289e9ece1
a78162e7c4c6ebd3a22c06737fd35948e11b0dd8949571617bc099ba6b4423f0
+4 -5
View File
@@ -143,9 +143,8 @@ impl CsvEditor {
let d = csv.delimiter;
let width = csv.records.iter().map(|r| r.fields.len()).max().unwrap_or(1).max(1);
let empty_record = d.to_string().repeat(width - 1);
let record = |row: u32| csv.records.get(usize::try_from(row).ok()?.checked_add(skip)?);
// Before row `before`; at the end for `None` or a row past the end.
let insert = |before: Option<u32>| match before.and_then(record) {
let record = |row: u32| csv.records.get(row as usize + skip);
let insert = |before: u32| match record(before) {
Some(r) => vec![edit(text, r.span.start..r.span.start, format!("{empty_record}\n"))],
None => {
// Append; the last record may lack its line break.
@@ -167,8 +166,8 @@ impl CsvEditor {
}
}
}
ViewEvent::InsertRow(before) => insert(Some(*before)),
ViewEvent::Button(b) if b == "add-row" => insert(None),
ViewEvent::InsertRow(before) => insert(*before),
ViewEvent::Button(b) if b == "add-row" => insert(u32::MAX),
ViewEvent::DeleteRows(rows) => {
let mut rows: Vec<u32> = rows.clone();
rows.sort_unstable();
@@ -6,7 +6,7 @@
* which turns it into the file's problems.
*/
import { instance, type Viz } from "@viz-js/viz";
import { ENGINES, type Engine, exportName, problemsOf, renderOptions, withoutLinks } from "./render";
import { ENGINES, type Engine, exportName, problemsOf, renderOptions } from "./render";
const RENDER_DELAY_MS = 150;
const MIN_ZOOM = 0.1;
@@ -73,7 +73,7 @@ function start(bridge: OpenideaBridge) {
const problems = problemsOf(result);
report(problems);
if (result.status === "success") {
canvas.innerHTML = withoutLinks(result.output);
canvas.innerHTML = result.output;
canvas.classList.remove("stale");
banner.hidden = true;
if (fitted) fit();
@@ -1,6 +1,6 @@
import { instance } from "@viz-js/viz";
import { describe, expect, it } from "vitest";
import { ENGINES, exportName, graphvizColor, problemsOf, renderOptions, withoutLinks } from "./render";
import { ENGINES, exportName, graphvizColor, problemsOf, renderOptions } from "./render";
describe("the preview's rendering", () => {
it("renders with the theme's colour as a default the graph can override", async () => {
@@ -42,22 +42,4 @@ describe("the preview's rendering", () => {
expect(exportName("Flow.GV", "png")).toBe("Flow.png");
expect(exportName("", "svg")).toBe("graph.svg");
});
it("previews a graph's links without them", async () => {
const viz = await instance();
const js = "javascript:alert(1)";
const graph = `digraph {
URL="${js}"; target="_top"
a [URL="${js}" href="https://example.com" label="a href=\\"x\\""]
a -> b [URL="${js}" edgeURL="${js}" headURL="${js}" tailURL="${js}" labelURL="${js}" label="e" headlabel="h" taillabel="t"]
subgraph cluster_x { URL="${js}"; c }
}`;
const result = viz.render(graph, renderOptions("dot", null));
const svg = result.status === "success" ? result.output : "";
expect(svg).toContain(js);
const preview = withoutLinks(svg);
expect(preview).not.toMatch(/\s(xlink:)?(href|target)="|javascript:/);
expect(preview).toContain("a href=&quot;x&quot;");
expect(preview.match(/<g id="node\d+"/g)).toHaveLength(3);
});
});
@@ -66,13 +66,3 @@ export function exportName(file: string, extension: string): string {
const base = file.replace(/\.(dot|gv)$/i, "") || "graph";
return `${base}.${extension}`;
}
/**
* Graphviz's SVG without its links, for the preview: Graphviz writes a graph's
* `URL` / `href` attributes as given (`javascript:` too), and the preview's
* page may run inline script. Exports keep them. Graphviz quotes attribute
* values with `"` and escapes the ones inside, so a value never holds one.
*/
export function withoutLinks(svg: string): string {
return svg.replace(/\s(?:xlink:)?(?:href|target)="[^"]*"/g, "");
}
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
0d7731de73514b950d8579762029d98c46136f55bfdb2fa44e256cc9a9955a0f
6d84abcc58c1894203506fa89855a6eade61f14add0ed72fdd7af9e3cd1298f6
+2 -32
View File
@@ -58,16 +58,8 @@ pub fn has_header(text: &str) -> bool {
})
}
/// `value` on one line: a line break (or any control character, or the
/// Unicode line separators JavaScript ends a comment at) would put the rest of
/// the setting into the file as code.
fn one_line(value: &str) -> String {
value.chars().map(|c| if c.is_control() || matches!(c, '\u{2028}' | '\u{2029}') { ' ' } else { c }).collect()
}
/// The header's lines, without comment markers.
pub fn header_lines(year: i64, owner: &str, license: &str) -> Vec<String> {
let (owner, license) = (one_line(owner), one_line(license));
let owner = owner.trim();
let mut lines =
vec![if owner.is_empty() { format!("Copyright (c) {year}") } else { format!("Copyright (c) {year} {owner}") }];
@@ -90,8 +82,7 @@ pub fn render(comment: Comment, lines: &[String]) -> String {
out.push_str(open);
out.push('\n');
for l in lines {
// `*/` would end the comment early.
out.push_str(&format!(" * {}\n", l.replace("*/", "* /")));
out.push_str(&format!(" * {l}\n"));
}
out.push_str(close);
out.push('\n');
@@ -179,16 +170,10 @@ fn project_of(path: &str) -> Option<Project> {
host::projects()
.ok()?
.into_iter()
.filter(|p| p.root().is_ok_and(|r| is_under(path, &r)))
.filter(|p| p.root().is_ok_and(|r| path.starts_with(&r)))
.max_by_key(|p| p.root().map(|r| r.len()).unwrap_or(0))
}
/// Whether `path` is `root` or inside it (`/proj-x/a` is not inside `/proj`).
fn is_under(path: &str, root: &str) -> bool {
path.strip_prefix(root)
.is_some_and(|rest| rest.is_empty() || root.ends_with(['/', '\\']) || rest.starts_with(['/', '\\']))
}
#[derive(Default)]
pub struct LicenseHeader;
@@ -320,15 +305,6 @@ mod tests {
assert_eq!(header_lines(2026, " ", ""), ["Copyright (c) 2026"]);
}
#[test]
fn settings_cannot_add_code_to_the_file() {
let lines = header_lines(2026, "ACME\nimport os\r\u{2028}x", "MIT\nrm -rf");
assert_eq!(lines, ["Copyright (c) 2026 ACME import os x", "SPDX-License-Identifier: MIT rm -rf"]);
assert_eq!(render(comment_for("a.py").unwrap(), &lines).lines().filter(|l| !l.starts_with('#')).count(), 1);
let css = render(comment_for("s.css").unwrap(), &header_lines(2026, "X */ body { }", ""));
assert_eq!(css.matches("*/").count(), 1, "{css}");
}
#[test]
fn keeps_shebang_and_encoding_lines_first() {
assert_eq!(insert_line("fn main() {}\n"), 0);
@@ -348,12 +324,6 @@ mod tests {
assert!(header_edit("a.rs", "// Copyright (c) 2020 X\n", 2026, "", "").is_none());
}
#[test]
fn a_project_contains_only_its_own_paths() {
assert!(is_under("/proj/a.rs", "/proj"));
assert!(!is_under("/proj-x/a.rs", "/proj"));
}
#[test]
fn years() {
assert_eq!(year_of_day(0), 1970);
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
959cb2ab750211bd1cc894a4d804acb2dd16e95c1e65b267e7eccbb64cbdd8da
a303522c605ccc9804e67f85326778a380951ce14022a70bc37ba3ae63b76f79
+51 -211
View File
@@ -9,15 +9,11 @@ use openidea_plugin::text::{offset, position};
use openidea_plugin::{
Error, Invocation, Location, Plugin, Position, Project, Range, TextQuery, host, register_plugin,
};
use std::collections::{BTreeMap, BTreeSet};
use std::collections::BTreeMap;
use std::time::{Duration, Instant};
/// How long a namespace → XML file lookup is trusted.
const INDEX_TTL: Duration = Duration::from_secs(30);
/// Lookups kept per project before the expired ones are dropped.
const MAX_INDEXED: usize = 1024;
/// Other mappers one file's references are looked up in (each is a search).
const MAX_OTHER_MAPPERS: usize = 32;
/// Files declaring one namespace that are tried (normally one).
const MAX_MAPPERS: u32 = 64;
const STATEMENTS: [&str; 4] = ["select", "insert", "update", "delete"];
@@ -89,77 +85,27 @@ pub struct MapperJava {
pub result_ids: Vec<(usize, Named)>,
}
/// Positions of many byte offsets in one text, asked in about increasing
/// order: each one scans only from the offset asked before it, so a file with
/// many names costs about one pass, not one pass per name.
struct Positions<'a> {
text: &'a str,
at: usize,
pos: Position,
}
fn utf16_len(s: &str) -> u32 {
s.chars().map(|c| c.len_utf16() as u32).sum()
}
impl<'a> Positions<'a> {
fn new(text: &'a str) -> Self {
Positions { text, at: 0, pos: Position::new(0, 0) }
}
fn of(&mut self, at: usize) -> Position {
let at = at.min(self.text.len());
if at >= self.at {
for c in self.text[self.at..at].chars() {
if c == '\n' {
self.pos = Position::new(self.pos.line + 1, 0);
} else {
self.pos.character += c.len_utf16() as u32;
}
}
} else {
let back = &self.text[at..self.at];
match back.matches('\n').count() as u32 {
0 => self.pos.character -= utf16_len(back),
lines => {
let line_start = self.text[..at].rfind('\n').map_or(0, |i| i + 1);
self.pos = Position::new(self.pos.line - lines, utf16_len(&self.text[line_start..at]));
}
}
}
self.at = at;
self.pos
}
fn range(&mut self, start: usize, end: usize) -> Range {
Range::new(self.of(start), self.of(end))
}
}
/// Where a completion that replaces `typed` (just before `at`, on its line) starts.
fn replace_start(at: Position, typed: &str) -> Option<Position> {
if typed.contains('\n') {
return None;
}
Some(Position::new(at.line, at.character.checked_sub(utf16_len(typed))?))
fn range_of(text: &str, start: usize, end: usize) -> Range {
Range::new(position(text, start), position(text, end))
}
pub fn parse_xml(text: &str) -> MapperXml {
let mut out = MapperXml::default();
let mut positions = Positions::new(text);
let attr = |tag_start: usize, name: &str| -> Option<(String, usize, usize)> {
let tag_end = text[tag_start..].find('>').map_or(text.len(), |i| tag_start + i);
let tag = &text[tag_start..tag_end];
let i = tag.find(&format!("{name}=\""))? + name.len() + 2;
let j = tag[i..].find('"')? + i;
Some((tag[i..j].to_owned(), tag_start + i, tag_start + j))
};
let mut at = 0;
while let Some(i) = text[at..].find('<') {
let start = at + i + 1;
at = start;
let name: String = text[start..].chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
// `<` cannot appear inside an attribute value, so a tag without its
// `>` ends at the next `<`: no tag is searched past the next one.
let tag_end = text[start..].find(['<', '>']).map_or(text.len(), |i| start + i);
let tag = &text[start..tag_end];
let mut named = |attr_name: &str| -> Option<Named> {
let i = tag.find(&format!("{attr_name}=\""))? + attr_name.len() + 2;
let j = tag[i..].find('"')? + i;
Some(Named { name: tag[i..j].to_owned(), range: positions.range(start + i, start + j) })
let named = |attr_name: &str| -> Option<Named> {
let (v, s, e) = attr(start, attr_name)?;
Some(Named { name: v, range: range_of(text, s, e) })
};
if name == "mapper" {
out.namespace = named("namespace");
@@ -217,8 +163,7 @@ fn skip_string(bytes: &[u8], mut i: usize) -> usize {
_ => i += 1,
}
}
// A `\` as the last byte steps past the end.
i.min(bytes.len())
i
}
/// The index just past the `//…` or `/*…*/` comment opening at `bytes[i]`.
@@ -282,7 +227,7 @@ fn scan_declaration(stmt: &str) -> Option<Declaration> {
}
b'(' => {
let before = stmt[..i].trim_end();
let start = before.char_indices().rev().find(|&(_, c)| !ident(c)).map_or(0, |(p, c)| p + c.len_utf8());
let start = before.rfind(|c: char| !ident(c)).map_or(0, |p| p + 1);
if start == before.len() {
return None;
}
@@ -518,8 +463,11 @@ fn annotation_string_arg(args: &str, key: &str) -> Option<(String, usize, usize)
depth = depth.saturating_sub(1);
i += 1;
}
_ if depth == 0 && args.is_char_boundary(i) && args[i..].chars().next().is_some_and(ident) => {
let j = args[i..].char_indices().find(|&(_, c)| !ident(c)).map_or(args.len(), |(n, _)| i + n);
_ if depth == 0 && ident(bytes[i] as char) => {
let mut j = i + 1;
while j < bytes.len() && ident(bytes[j] as char) {
j += 1;
}
if &args[i..j] == key {
let mut k = j;
while k < bytes.len() && bytes[k].is_ascii_whitespace() {
@@ -600,9 +548,8 @@ pub fn parse_java(text: &str) -> MapperJava {
Some(p) => format!("{p}.{simple}"),
None => simple.clone(),
});
let mut positions = Positions::new(text);
let decl =
Some(Named { name: simple.clone(), range: positions.range(i + 10 + indent, i + 10 + indent + simple.len()) });
Some(Named { name: simple.clone(), range: range_of(text, i + 10 + indent, i + 10 + indent + simple.len()) });
let body_start = text[i..].find('{').map_or(text.len(), |b| i + b + 1);
let mut methods = Vec::new();
let mut annotation_sql = Vec::new();
@@ -652,21 +599,22 @@ pub fn parse_java(text: &str) -> MapperJava {
{
let (s, e) = (stmt_start + decl.name.0, stmt_start + decl.name.1);
let list_base = stmt_start + decl.parens.0 + 1;
let method_name =
Named { name: stmt[decl.name.0..decl.name.1].to_owned(), range: positions.range(s, e) };
let params = params_of(&stmt[decl.parens.0 + 1..decl.parens.1])
.into_iter()
.map(|(name, ps, pe)| Param { name, range: positions.range(list_base + ps, list_base + pe) })
.map(|(name, ps, pe)| Param { name, range: range_of(text, list_base + ps, list_base + pe) })
.collect();
let method = methods.len();
methods.push((method_name, params));
methods.push((
Named { name: stmt[decl.name.0..decl.name.1].to_owned(), range: range_of(text, s, e) },
params,
));
let mut strings = Vec::new();
let mut sql_params = Vec::new();
for (ss, se, ps) in annotation_sql_of(&stmt[..decl.parens.0]) {
strings.push(positions.range(stmt_start + ss, stmt_start + se));
strings.push(range_of(text, stmt_start + ss, stmt_start + se));
sql_params.extend(ps.into_iter().map(|(n, ms, me)| Named {
name: n,
range: positions.range(stmt_start + ms, stmt_start + me),
range: range_of(text, stmt_start + ms, stmt_start + me),
}));
}
if !strings.is_empty() {
@@ -675,10 +623,10 @@ pub fn parse_java(text: &str) -> MapperJava {
let (refs, defs) = result_map_of(&stmt[..decl.parens.0]);
result_refs.extend(refs.into_iter().map(|(v, rs, re)| ResultMapRef {
method,
name: Named { name: v, range: positions.range(stmt_start + rs, stmt_start + re) },
name: Named { name: v, range: range_of(text, stmt_start + rs, stmt_start + re) },
}));
result_ids.extend(defs.into_iter().map(|(v, ds, de)| {
(method, Named { name: v, range: positions.range(stmt_start + ds, stmt_start + de) })
(method, Named { name: v, range: range_of(text, stmt_start + ds, stmt_start + de) })
}));
}
stmt_start = k + 1;
@@ -693,13 +641,8 @@ pub fn parse_java(text: &str) -> MapperJava {
/// `namespace="com.acme.UserMapper"` in an XML file, either quote.
fn namespace_query(namespace: &str) -> TextQuery {
let mut escaped = String::with_capacity(namespace.len());
for c in namespace.chars() {
if r"\.+*?()|[]{}^$#&-~".contains(c) {
escaped.push('\\');
}
escaped.push(c);
}
let escaped: String =
namespace.chars().flat_map(|c| matches!(c, '.' | '$').then_some('\\').into_iter().chain([c])).collect();
TextQuery {
pattern: format!(r#"namespace\s*=\s*["']{escaped}["']"#),
regex: true,
@@ -713,22 +656,14 @@ fn project_of(path: &str) -> Option<Project> {
host::projects()
.ok()?
.into_iter()
.filter(|p| p.root().is_ok_and(|r| is_under(path, &r)))
.filter(|p| p.root().is_ok_and(|r| path.starts_with(&r)))
.max_by_key(|p| p.root().map(|r| r.len()).unwrap_or(0))
}
/// Whether `path` is `root` or inside it (`/proj-x/a` is not inside `/proj`).
fn is_under(path: &str, root: &str) -> bool {
path.strip_prefix(root)
.is_some_and(|rest| rest.is_empty() || root.ends_with(['/', '\\']) || rest.starts_with(['/', '\\']))
}
#[derive(Default)]
pub struct Navigator {
/// project root → namespace → (when it was looked up, the XML mapping it, if any).
xml_index: BTreeMap<String, BTreeMap<String, (Instant, Option<String>)>>,
/// Other mappers looked up for the file being linked (see [`MAX_OTHER_MAPPERS`]).
other_mappers: BTreeSet<String>,
}
impl Navigator {
@@ -752,20 +687,15 @@ impl Navigator {
let mut paths: Vec<&str> = found.matches.iter().map(|m| m.location.path.as_str()).collect();
paths.dedup();
let hit = paths.into_iter().find_map(|path| Some((path.to_owned(), read(path)?)));
let index = self.xml_index.entry(root).or_default();
if index.len() >= MAX_INDEXED {
index.retain(|_, (at, _)| at.elapsed() < INDEX_TTL);
}
index.insert(namespace.to_owned(), (Instant::now(), hit.as_ref().map(|(path, _)| path.clone())));
self.xml_index
.entry(root)
.or_default()
.insert(namespace.to_owned(), (Instant::now(), hit.as_ref().map(|(path, _)| path.clone())));
hit
}
/// The interface `namespace` names (`com.acme.UserMapper` → `**/com/acme/UserMapper.java`).
fn java_for(project: &Project, namespace: &str) -> Option<(String, MapperJava)> {
// A Java name only: anything else would be read as a glob.
if namespace.is_empty() || !namespace.chars().all(|c| ident(c) || c == '.') {
return None;
}
let rel = format!("**/{}.java", namespace.replace('.', "/"));
let path = project.find_files(&rel, 1).ok()?.into_iter().next()?;
let java = parse_java(&project.read_file(&path).ok()?);
@@ -792,14 +722,6 @@ impl Navigator {
if ns == here_ns {
return pick(xml, doc.path.clone());
}
// Each other mapper is a project search: a file naming thousands of them
// resolves the first few rather than running out of time.
if !self.other_mappers.contains(ns) {
if self.other_mappers.len() >= MAX_OTHER_MAPPERS {
return None;
}
self.other_mappers.insert(ns.to_owned());
}
let (path, other) = self.xml_for(project, ns)?;
pick(&other, path)
}
@@ -829,7 +751,7 @@ impl Navigator {
}
typed
};
let start = replace_start(at, typed)?;
let start = Position::new(at.line, at.character - typed.encode_utf16().count() as u32);
let mut ids: Vec<String> = java.result_ids.iter().map(|(_, d)| d.name.clone()).collect();
if let Some(project) = project_of(&doc.path)
&& let Some(name) = &java.name
@@ -874,7 +796,6 @@ impl Navigator {
}));
}
// resultMap / sql references resolve even when the interface is missing.
self.other_mappers.clear();
for r in xml.result_map_refs.iter().chain(xml.extends_refs.iter()) {
if let Some(target) = self.anchor_target(&project, doc, &xml, &ns.name, &r.name, false) {
out.push((r.clone(), target, format!("resultMap {}", r.name)));
@@ -993,7 +914,7 @@ impl Providers for Navigator {
&& STATEMENTS.iter().any(|t| line.contains(&format!("<{t}")))
{
let typed = &line[i + 4..];
let Some(start) = replace_start(at, typed) else { return Vec::new() };
let start = Position::new(at.line, at.character - typed.encode_utf16().count() as u32);
return java
.methods
.iter()
@@ -1011,7 +932,7 @@ impl Providers for Navigator {
&& line.contains("<include")
{
let typed = &line[i + 7..];
let Some(start) = replace_start(at, typed) else { return Vec::new() };
let start = Position::new(at.line, at.character - typed.encode_utf16().count() as u32);
return xml
.sql_fragments
.iter()
@@ -1028,12 +949,12 @@ impl Providers for Navigator {
&& !before[i..].contains('}')
{
let typed = &before[i + 2..];
let here = position(&doc.text, cursor);
let statement = xml.statements.iter().rev().find(|s| s.range.start < here);
let statement =
xml.statements.iter().rev().find(|s| offset(&doc.text, s.range.start).is_some_and(|o| o < cursor));
let Some((_, params)) = statement.and_then(|s| java.methods.iter().find(|(m, _)| m.name == s.name)) else {
return Vec::new();
};
let Some(start) = replace_start(at, typed) else { return Vec::new() };
let start = Position::new(at.line, at.character - typed.encode_utf16().count() as u32);
return params
.iter()
.filter(|p| p.name.starts_with(typed))
@@ -1050,12 +971,16 @@ impl Providers for Navigator {
/// After `#{` / `${` inside an annotation SQL string: the method's parameters.
fn annotation_param_completion(doc: &Document, at: Position) -> Vec<CompletionItem> {
let Some(cursor) = offset(&doc.text, at) else { return Vec::new() };
let here = position(&doc.text, cursor);
let java = parse_java(&doc.text);
for sql in &java.annotation_sql {
let (_, params) = &java.methods[sql.method];
for s in sql.strings.iter().filter(|s| s.start <= here && here <= s.end) {
let Some(start) = offset(&doc.text, s.start) else { continue };
for s in &sql.strings {
let (Some(start), Some(end)) = (offset(&doc.text, s.start), offset(&doc.text, s.end)) else {
continue;
};
if !(start <= cursor && cursor <= end) {
continue;
}
let seg = &doc.text[start..cursor];
let mut token: Option<usize> = None;
for t in ["#{", "${"] {
@@ -1068,7 +993,7 @@ fn annotation_param_completion(doc: &Document, at: Position) -> Vec<CompletionIt
if typed.contains('}') || !typed.chars().all(|c| ident(c) || c == '.') {
continue;
}
let Some(start) = replace_start(at, typed) else { continue };
let start = Position::new(at.line, at.character - typed.encode_utf16().count() as u32);
return params
.iter()
.filter(|p| p.name.starts_with(typed))
@@ -1552,89 +1477,4 @@ mod tests {
let items = n.completion(&closed, Position::new(7, col as u32), None);
assert_eq!(items.iter().map(|c| c.label.as_str()).collect::<Vec<_>>(), ["userMap"]);
}
#[test]
fn positions_agree_with_the_sdk_in_any_order() {
let text = "a\n中文 😀 x\r\n\n é\nlast";
let mut positions = Positions::new(text);
let boundaries: Vec<usize> = (0..=text.len()).filter(|&i| text.is_char_boundary(i)).collect();
let order = boundaries.iter().chain(boundaries.iter().rev()).chain(boundaries.iter().step_by(3).rev());
for &at in order.chain([0, text.len(), 5, 1, text.len()].iter()) {
assert_eq!(positions.of(at), position(text, at), "at {at}");
}
}
/// Text a mapper may hold (or be crafted to hold) must not crash the plugin.
#[test]
fn odd_java_does_not_panic() {
// A no-break space before the name: the char before it is 2 bytes.
let java = parse_java("package a;\ninterface M {\n User\u{00A0}findById(long id);\n}\n");
assert_eq!(java.methods.iter().map(|(m, _)| m.name.as_str()).collect::<Vec<_>>(), ["findById"]);
// A non-ASCII identifier among `@Results` arguments.
let java = parse_java("interface M { @Results(é) User f(); @Results(id = 常量.ID, x = \"y\") User g(); }");
assert_eq!(java.methods.len(), 2);
// A string the annotation scanner reads as a char literal, ending in `\`.
for text in [
"package a;\ninterface M {\n @Select('\"' \\) void f();\n}",
"interface M { @Select(/*\"*/ \\) void f(); }",
] {
parse_java(text);
}
assert_eq!(skip_string(b"\"a\\", 0), 3);
}
#[test]
fn a_tag_without_its_end_stops_at_the_next() {
let xml = parse_xml("<mapper namespace=\"a.B\"\n<select id=\"x\"\n<sql id=\"y\"/>");
assert_eq!(xml.namespace.map(|n| n.name).as_deref(), Some("a.B"));
assert_eq!(xml.statements.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["x"]);
assert_eq!(xml.sql_fragments[0].range, Range::new(Position::new(2, 9), Position::new(2, 10)));
// Many unclosed tags parse in one pass, not one pass per tag.
let many = "<select id=\"a\" ".repeat(50_000);
assert_eq!(parse_xml(&many).statements.len(), 50_000);
}
#[test]
fn completion_before_an_unclosed_parameter_on_an_earlier_line() {
let _host = setup();
let text = "<mapper namespace=\"com.acme.UserMapper\">\n <select id=\"findById\">\n where id = #{i\nx";
let xml = doc("/project/src/main/resources/mapper/UserMapper.xml", "xml", text);
assert!(Navigator::default().completion(&xml, Position::new(3, 0), None).is_empty());
}
#[test]
fn namespaces_are_searched_and_globbed_literally() {
let pattern = namespace_query("a|b.x[0]").pattern;
assert!(pattern.contains(r"a\|b\.x\[0\]"), "{pattern}");
let _host = setup();
let project = host::projects().unwrap().remove(0);
assert!(Navigator::java_for(&project, "com.acme.UserMapper").is_some());
assert!(Navigator::java_for(&project, "com.*.UserMapper").is_none());
assert!(Navigator::java_for(&project, "").is_none());
}
#[test]
fn a_project_contains_only_its_own_paths() {
assert!(is_under("/proj/a.xml", "/proj"));
assert!(is_under("/proj/a.xml", "/proj/"));
assert!(is_under("C:\\proj\\a.xml", "C:\\proj"));
assert!(is_under("/proj", "/proj"));
assert!(!is_under("/proj-x/a.xml", "/proj"));
}
#[test]
fn a_file_naming_many_mappers_looks_up_a_few() {
let host = MockHost::install();
let searches = std::rc::Rc::new(std::cell::Cell::new(0));
let count = searches.clone();
host.on_search(move |_, _| {
count.set(count.get() + 1);
Ok(TextSearch { matches: Vec::new(), limit_hit: false })
});
let refs: String = (0..200).map(|n| format!(" <include refid=\"ns{n}.x\"/>\n")).collect();
let text = format!("<mapper namespace=\"a.Self\">\n{refs}</mapper>\n");
let xml = doc("/project/src/m.xml", "xml", &text);
assert!(Navigator::default().line_markers(&xml).is_empty());
assert_eq!(searches.get(), MAX_OTHER_MAPPERS);
}
}
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
d5ec377869ded4ec0bf533d07d51610aec7a6813d43ead98a261b3f54e7d0655
b7b4e6be26ff08cc0e7fa791cb956acd9afa09905720d2c0c93acdae5638a35e
+1 -24
View File
@@ -94,23 +94,7 @@ pub fn evaluate(run: &Run) -> Value {
}
}
let replaced = run.replace.as_ref().map(|r| {
// Built match by match and cut off at the limit: `replace_all` would
// first build all of it (an empty match at every character, each
// replaced with a long text, runs out of memory).
let mut out = String::new();
let mut last = 0;
for caps in re.captures_iter(&run.text) {
let m = caps.get(0).expect("group 0");
out.push_str(&run.text[last..m.start()]);
caps.expand(r, &mut out);
last = m.end();
if out.len() > MAX_REPLACED {
break;
}
}
if out.len() <= MAX_REPLACED {
out.push_str(&run.text[last..]);
}
let mut out = re.replace_all(&run.text, r.as_str()).into_owned();
if out.len() > MAX_REPLACED {
let mut cut = MAX_REPLACED;
while !out.is_char_boundary(cut) {
@@ -257,13 +241,6 @@ mod tests {
assert!(bad["error"].as_str().unwrap().contains("unclosed"), "{bad}");
assert_eq!(bad["count"], 0);
assert_eq!(run("", "", "x", None)["count"], 0);
let long = run("(?:)", "", &"a".repeat(100_000), Some(&"b".repeat(10_000)));
let replaced = long["replaced"].as_str().unwrap();
assert!(replaced.len() <= MAX_REPLACED + '…'.len_utf8() && replaced.ends_with('…'));
for (pattern, text, with) in [("a*", "baac", "[$0]"), (r"(\w)(\d)", "a1 b2 c", "$2$1"), ("x", "y", "z")] {
let expected = regex::Regex::new(pattern).unwrap().replace_all(text, with).into_owned();
assert_eq!(run(pattern, "", text, Some(with))["replaced"], expected);
}
}
#[test]
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
ebbd24e08c19953895464567cbaf1335b9847e98e8a8da8a4e0b8c2886c1abd6
4f585c3a1a3ae01a89811e05d27fc39121dd8e74210c1281d04ff31556d393b5
+4 -24
View File
@@ -347,8 +347,6 @@ fn highlights(text: &str) -> Vec<Token> {
/// An open `{`: the symbol whose body it is, and what may be declared inside.
struct Frame {
symbol: Option<usize>,
/// The innermost symbol of this frame or the ones around it.
parent: Option<usize>,
body: Body,
}
@@ -368,7 +366,7 @@ fn outline(text: &str) -> Vec<Symbol> {
let code: Vec<Lexeme> = lex(text).into_iter().filter(|l| !l.is_trivia()).collect();
let word = |i: usize| code.get(i).map_or("", |l| l.text(text));
let mut symbols: Vec<Symbol> = Vec::new();
let mut frames = vec![Frame { symbol: None, parent: None, body: Body::File }];
let mut frames = vec![Frame { symbol: None, body: Body::File }];
// A declaration waiting for its body (`{`) or its end (`;`).
let mut pending: Option<(usize, Body)> = None;
let mut stmt_start = 0usize;
@@ -377,9 +375,7 @@ fn outline(text: &str) -> Vec<Symbol> {
let l = code[i];
let w = l.text(text);
let body = frames.last().map_or(Body::File, |f| f.body);
// From the top frame alone: walking every open `{` per token is
// quadratic in deeply nested code.
let parent = frames.last().and_then(|f| f.parent);
let parent = frames.iter().rev().find_map(|f| f.symbol);
let add = |symbols: &mut Vec<Symbol>, name_at: usize, kind: SymbolKind, detail: Option<&str>| {
let name = code[name_at];
symbols.push(Symbol {
@@ -399,7 +395,7 @@ fn outline(text: &str) -> Vec<Symbol> {
Some((s, b)) => (Some(s), b),
None => (None, if body == Body::Code || body == Body::Contract { Body::Code } else { body }),
};
frames.push(Frame { symbol, parent: symbol.or(parent), body: inner });
frames.push(Frame { symbol, body: inner });
stmt_start = i + 1;
}
(Kind::Punct, "}") => {
@@ -516,8 +512,7 @@ fn outline(text: &str) -> Vec<Symbol> {
fn folding(text: &str) -> Vec<Fold> {
let lexemes = lex(text);
let newlines: Vec<usize> = text.bytes().enumerate().filter(|&(_, b)| b == b'\n').map(|(i, _)| i).collect();
let line_of = |offset: usize| newlines.partition_point(|&n| n < offset) as u32;
let line_of = |offset: usize| text[..offset].bytes().filter(|&b| b == b'\n').count() as u32;
let skip: Vec<Range<usize>> = lexemes
.iter()
.filter(|l| matches!(l.kind, Kind::Str | Kind::Comment | Kind::DocComment))
@@ -691,19 +686,4 @@ function free(uint x) pure returns (uint) { return x; }
let _ = folding(text);
}
}
/// Deep nesting and many comments stay about linear (both used to be quadratic).
#[test]
fn large_inputs_stay_linear() {
let deep = format!("contract C {{ function f() public {{ {} }} }}", "{".repeat(200_000));
let symbols = outline(&deep);
assert_eq!(
symbols.iter().map(|s| (s.name.as_str(), s.parent)).collect::<Vec<_>>(),
[("C", None), ("f", Some(0))]
);
let comments = "/* a\n */\n".repeat(100_000);
let folds = folding(&comments);
assert_eq!(folds.len(), 100_000);
assert_eq!((folds[99_999].start_line, folds[99_999].end_line), (199_998, 199_999));
}
}
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
997e2ac7520a561f22d12ad7013ed576262424ffa8acd69fab219f68d00c92f1
81637f9a265e6bcb9839b1019b0d58b43f80535fb1fca044716904aef0c12724
+2 -7
View File
@@ -230,8 +230,7 @@ fn step_numbers(s: &str, by: i64) -> String {
out.pop(); // the minus sign
}
let width = digits.len();
let text =
if next < 0 { format!("-{:0width$}", next.unsigned_abs()) } else { format!("{next:0width$}") };
let text = if next < 0 { format!("-{:0width$}", -next) } else { format!("{next:0width$}") };
out.push_str(&text);
}
Err(_) => out.push_str(&digits),
@@ -339,8 +338,7 @@ fn html_unescape(s: &str) -> String {
while let Some(i) = rest.find('&') {
out.push_str(&rest[..i]);
rest = &rest[i..];
// `;` is ASCII, so where it is found is a char boundary.
let Some(end) = rest.bytes().take(12).position(|b| b == b';') else {
let Some(end) = rest[..rest.len().min(12)].find(';') else {
out.push('&');
rest = &rest[1..];
continue;
@@ -418,7 +416,6 @@ mod tests {
assert_eq!(base64_encode(b"Man"), "TWFu");
assert_eq!(url_encode("a b&c"), "a%20b%26c");
assert_eq!(html_unescape("&lt;a&gt; &#x41;&#66; &bogus; &"), "<a> AB &bogus; &");
assert_eq!(html_unescape("R&D部门的预算 &aaaaaaaaaa€"), "R&D部门的预算 &aaaaaaaaaa€");
assert_eq!(json_escape("a\"b\n"), "a\\\"b\\n");
}
@@ -429,8 +426,6 @@ mod tests {
assert_eq!(t("unique-lines", "a\nb\na"), "a\nb");
assert_eq!(t("increment", "v1.09 and -1"), "v2.10 and 0");
assert_eq!(t("decrement", "x0"), "x-1");
assert_eq!(t("decrement", "-9223372036854775807"), "-9223372036854775808");
assert_eq!(t("decrement", "-9223372036854775808"), "-9223372036854775808");
}
#[test]
+4 -7
View File
@@ -95,12 +95,9 @@ import { readFileLines, withDocument } from "@/editor/lsp/files";
import { pathToCopy } from "@/platform/copyPath";
import { shortcutLabel } from "@/platform/keymap";
/**
* Text-editing actions: the editor has focus, or a popup (Find Action) runs them on it. Not a
* tool window's speed search: it opens a layer too, but the keys belong to its tree (⌘⌫).
*/
export const TEXT_WHEN = "editorTextFocus || textEditorActive && popupOpen && !toolWindowFocus";
const EDITOR_WHEN = "textEditorActive && (editorFocus || !toolWindowFocus)";
/** Text-editing actions: the editor has focus, or a popup (Find Action) runs them on it. */
export const TEXT_WHEN = "editorTextFocus || textEditorActive && popupOpen";
const EDITOR_WHEN = "textEditorActive && (editorFocus || popupOpen || !toolWindowFocus)";
const TABS_WHEN = "editorTabsOpen && !toolWindowFocus";
function activeSlot(): EditorSlot | null {
@@ -669,7 +666,7 @@ export function registerEditorActions(): () => void {
id,
title,
category: "Edit",
when: `${TEXT_WHEN} || findBarFocus || findBarOpen && popupOpen && !toolWindowFocus`,
when: `${TEXT_WHEN} || findBarFocus || findBarOpen && popupOpen`,
run: ({ arg, ctx }) => {
const view = typeof arg === "string" || ctx.findBarFocus ? findPanelTarget(arg)?.view() : null;
if (view) {
@@ -1,38 +0,0 @@
import { describe, expect, it } from "vitest";
import { EditorState } from "@codemirror/state";
import { braceBlock, braceNextTo } from "./bracketBlockSelect";
const DOC = "int f(Object raw) {\n if (a) {\n g(b[0]);\n }\n}";
const state = EditorState.create({ doc: DOC });
describe("double-clicking a curly brace", () => {
it("selects the block from the opening brace to its match, both included (IDEA)", () => {
const open = DOC.indexOf("{");
expect(braceBlock(state, open)).toEqual({ from: open, to: DOC.length });
});
it("selects the same block from the closing brace", () => {
const inner = DOC.indexOf("{", DOC.indexOf("if"));
const close = DOC.indexOf("}");
expect(braceBlock(state, close)).toEqual({ from: inner, to: close + 1 });
});
it("takes the brace next to the caret the click gives, past the end of the line too", () => {
const open = DOC.indexOf("{");
const lineEnd = state.doc.line(1).to;
expect(braceNextTo(state, lineEnd, false)).toBe(open);
expect(braceNextTo(state, open, false)).toBe(open);
const close = DOC.lastIndexOf("}");
expect(braceNextTo(state, close, false)).toBe(close);
// On a word, or away from braces, the double-click selects the word.
expect(braceNextTo(state, open, true)).toBeNull();
expect(braceNextTo(state, DOC.indexOf("raw"), false)).toBeNull();
});
it("leaves parentheses, brackets, other characters and unmatched braces to the word selection", () => {
expect(braceBlock(state, DOC.indexOf("("))).toBeNull();
expect(braceBlock(state, DOC.indexOf("["))).toBeNull();
expect(braceBlock(state, DOC.indexOf("raw"))).toBeNull();
expect(braceBlock(EditorState.create({ doc: "f {" }), 2)).toBeNull();
});
});
-53
View File
@@ -1,53 +0,0 @@
/**
* A double-click that puts the caret next to a curly brace selects the block it opens or
* closes, both braces included, as IDEA does: past the end of a line ending in `{` too.
* A double-click on a word still selects the word.
*/
import { matchBrackets } from "@codemirror/language";
import { EditorSelection, Prec, type EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
/** The range from the brace at `at` to its match, both included, or null when it has none. */
export function braceBlock(state: EditorState, at: number): { from: number; to: number } | null {
const ch = state.sliceDoc(at, at + 1);
if (ch !== "{" && ch !== "}") return null;
const match = ch === "{" ? matchBrackets(state, at, 1) : matchBrackets(state, at + 1, -1);
if (!match?.matched || !match.end) return null;
return { from: Math.min(match.start.from, match.end.from), to: Math.max(match.start.to, match.end.to) };
}
const isBrace = (ch: string) => ch === "{" || ch === "}";
/**
* The brace next to the caret position `pos` a click at `x` gives, unless the click is on a
* word character (that double-click selects the word).
*/
export function braceNextTo(state: EditorState, pos: number, onWord: boolean): number | null {
if (onWord) return null;
if (pos > 0 && isBrace(state.sliceDoc(pos - 1, pos))) return pos - 1;
if (isBrace(state.sliceDoc(pos, pos + 1))) return pos;
return null;
}
/** Whether the click at `x` is on a word character around `pos`. */
function clickOnWord(view: EditorView, pos: number, x: number): boolean {
for (const at of [pos - 1, pos]) {
if (at < 0 || at >= view.state.doc.length) continue;
const box = view.coordsForChar(at);
if (box && x >= box.left && x <= box.right) return /[\p{L}\p{N}_$]/u.test(view.state.sliceDoc(at, at + 1));
}
return false;
}
export const bracketBlockSelect = Prec.high(
EditorView.mouseSelectionStyle.of((view, event) => {
if (event.detail !== 2 || event.button !== 0 || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey)
return null;
const pos = view.posAtCoords({ x: event.clientX, y: event.clientY });
const at = pos == null ? null : braceNextTo(view.state, pos, clickOnWord(view, pos, event.clientX));
const block = at == null ? null : braceBlock(view.state, at);
if (!block) return null;
const selection = EditorSelection.single(block.from, block.to);
return { get: () => selection, update: () => false };
}),
);
+10 -58
View File
@@ -5,7 +5,7 @@ import { indentUnit } from "@codemirror/language";
import { codeLenses } from "@/editor/api";
import type { EditorSettings } from "@/bindings";
import { codeLensExtension } from "./codeLens";
import { braceGuide, caretGuide, guideVars, indentGuides, rainbowIndentGuides } from "./indentGuides";
import { guideVars, indentGuides, rainbowIndentGuides } from "./indentGuides";
import { heavyExtension } from "./setup";
const SOURCE = [
@@ -46,23 +46,23 @@ describe("indent guides", () => {
expect(guideVars(1, 4)).toBeNull();
expect(guideVars(3, 4)).toEqual({
images: "var(--openidea-guide), var(--openidea-guide)",
positions: "calc(0.5px + 4ch) 0, calc(0.5px + 8ch) 0",
positions: "calc(2.5px + 4ch) 0, calc(2.5px + 8ch) 0",
});
});
it("positions each guide the same way on lines of different depth", () => {
const v = mount(SOURCE);
expect(line(v, 1).classList.contains("cm-openidea-guides")).toBe(false);
expect(line(v, 2).style.getPropertyValue("--openidea-guide-positions")).toBe("calc(0.5px + 4ch) 0");
expect(line(v, 2).style.getPropertyValue("--openidea-guide-positions")).toBe("calc(2.5px + 4ch) 0");
expect(line(v, 3).style.getPropertyValue("--openidea-guide-positions")).toBe(
"calc(0.5px + 4ch) 0, calc(0.5px + 8ch) 0",
"calc(2.5px + 4ch) 0, calc(2.5px + 8ch) 0",
);
});
it("keeps guides through blank lines inside a block", () => {
const v = mount(SOURCE);
expect(line(v, 4).style.getPropertyValue("--openidea-guide-positions")).toBe(
"calc(0.5px + 4ch) 0, calc(0.5px + 8ch) 0",
"calc(2.5px + 4ch) 0, calc(2.5px + 8ch) 0",
);
});
@@ -77,66 +77,18 @@ describe("indent guides", () => {
]);
const [implLens, fnLens] = [...v.contentDOM.querySelectorAll<HTMLElement>(".cm-openidea-lens")];
expect(v.contentDOM.classList.contains("cm-openidea-guidesOn")).toBe(true);
expect(fnLens!.style.getPropertyValue("--openidea-guide-positions")).toBe("calc(0.5px + 4ch) 0");
expect(fnLens!.style.getPropertyValue("--openidea-guide-positions")).toBe("calc(2.5px + 4ch) 0");
expect(implLens!.style.getPropertyValue("--openidea-guide-positions")).toBe("");
});
});
describe("the caret's indent guide", () => {
const at = (lineNo: number, col: number) => {
const state = EditorState.create({ doc: SOURCE });
return state.update({ selection: { anchor: state.doc.line(lineNo).from + col } }).state;
};
it("is the guide in the caret's column whose block holds the caret line (IDEA)", () => {
// `for` opens the guide at column 4, drawn on lines 3-7 (`if` … its `}`).
expect(caretGuide(at(2, 4), 4)).toEqual({ level: 1, from: 3, to: 7 });
expect(caretGuide(at(4, 4), 4)).toEqual({ level: 1, from: 3, to: 7 });
expect(caretGuide(at(4, 8), 4)).toEqual({ level: 2, from: 4, to: 6 });
});
it("is none in column 0, between indent units, in text, or on the closing line", () => {
expect(caretGuide(at(4, 0), 4)).toBeNull();
expect(caretGuide(at(4, 6), 4)).toBeNull();
expect(caretGuide(at(2, 12), 4)).toBeNull();
expect(caretGuide(at(8, 4), 4)).toBeNull();
});
it("is the matched brace colour's guide while the caret is at a brace of a multi-line block", () => {
const state = (pos: number) => EditorState.create({ doc: SOURCE, selection: { anchor: pos } });
const forOpen = SOURCE.indexOf("{", SOURCE.indexOf("for"));
// `for x in y {`: its guide is at column 4, on lines 3-7.
expect(braceGuide(state(forOpen + 1), 4)).toEqual({ level: 1, from: 3, to: 7 });
expect(braceGuide(state(forOpen), 4)).toEqual({ level: 1, from: 3, to: 7 });
const forClose = SOURCE.lastIndexOf(" }") + 4;
expect(braceGuide(state(forClose + 1), 4)).toEqual({ level: 1, from: 3, to: 7 });
expect(braceGuide(state(SOURCE.indexOf("push")), 4)).toBeNull();
expect(braceGuide(EditorState.create({ doc: "a {\n b { c }\n}", selection: { anchor: 9 } }), 4)).toBeNull();
});
it("draws the brace guide over the caret's", () => {
const v = mount(SOURCE);
v.dispatch({ selection: { anchor: SOURCE.indexOf("{", SOURCE.indexOf("for")) + 1 } });
expect(images(line(v, 3))).toBe("var(--openidea-guide-brace), var(--openidea-guide)");
});
it("draws that guide on its lines in the selected colour", () => {
const v = mount(SOURCE);
v.dispatch({ selection: { anchor: v.state.doc.line(4).from + 8 } });
expect(images(line(v, 3))).toBe("var(--openidea-guide), var(--openidea-guide-active)");
expect(images(line(v, 6))).toBe("var(--openidea-guide)");
v.dispatch({ selection: { anchor: v.state.doc.line(4).from + 12 } });
expect(images(line(v, 3))).toBe("var(--openidea-guide), var(--openidea-guide)");
});
});
describe("rainbow indent guides", () => {
it("gives each depth its own image, cycling through the five colours", () => {
expect(guideVars(0, 4, true)).toBeNull();
expect(guideVars(1, 4, true)).toBeNull();
expect(guideVars(3, 4, true)).toEqual({
images: "var(--openidea-guide-1), var(--openidea-guide-2)",
positions: "calc(0.5px + 4ch) 0, calc(0.5px + 8ch) 0",
positions: "calc(2.5px + 4ch) 0, calc(2.5px + 8ch) 0",
});
const deep = guideVars(8, 2, true)!;
expect(deep.images.split(", ")).toEqual([1, 2, 3, 4, 5, 1, 2].map((n) => `var(--openidea-guide-${n})`));
@@ -156,7 +108,7 @@ describe("rainbow indent guides", () => {
expect(images(line(v, 2))).toBe("var(--openidea-guide-1)");
expect(images(line(v, 3))).toBe("var(--openidea-guide-1), var(--openidea-guide-2)");
expect(images(line(v, 4))).toBe("var(--openidea-guide-1), var(--openidea-guide-2)");
expect(positions(line(v, 4))).toBe("calc(0.5px + 4ch) 0, calc(0.5px + 8ch) 0");
expect(positions(line(v, 4))).toBe("calc(2.5px + 4ch) 0, calc(2.5px + 8ch) 0");
expect(images(line(v, 6))).toBe("var(--openidea-guide-1)");
});
@@ -170,7 +122,7 @@ describe("rainbow indent guides", () => {
spaces.destroy();
const tabs = mount(body("\t"), [EditorState.tabSize.of(4)], rainbowIndentGuides, "\t");
expect(images(deepest(tabs))).toBe(expected);
expect(positions(deepest(tabs)).split(", ")[1]).toBe("calc(0.5px + 8ch) 0");
expect(positions(deepest(tabs)).split(", ")[1]).toBe("calc(2.5px + 8ch) 0");
});
it("does not count a partial indent unit as a level", () => {
@@ -211,7 +163,7 @@ describe("rainbow indent guides", () => {
});
const lens = () => view!.contentDOM.querySelector<HTMLElement>(".cm-openidea-lens")!;
expect(images(lens())).toBe("var(--openidea-guide-1), var(--openidea-guide-2)");
expect(positions(lens())).toBe("calc(0.5px + 4ch) 0, calc(0.5px + 8ch) 0");
expect(positions(lens())).toBe("calc(2.5px + 4ch) 0, calc(2.5px + 8ch) 0");
view.dispatch({ effects: guides.reconfigure(indentGuides) });
expect(images(lens())).toBe("var(--openidea-guide), var(--openidea-guide)");
});
+12 -99
View File
@@ -6,17 +6,13 @@
* so a guide stays straight across lines of different depth. Blank lines take
* the smaller level of their neighbours and code lens rows the level of the
* line they annotate (`guideVars`), so guides stay continuous. With rainbow
* guides each depth gets its own image, coloured from `--syn-rainbow-*`. The
* guide the caret is on takes the selected colour (`caretGuide`).
* guides each depth gets its own image, coloured from `--syn-rainbow-*`.
*/
import { countColumn, Facet, RangeSetBuilder, type EditorState, type Extension } from "@codemirror/state";
import { Facet, RangeSetBuilder, type EditorState, type Extension } from "@codemirror/state";
import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from "@codemirror/view";
import { getIndentUnit, matchBrackets } from "@codemirror/language";
import { getIndentUnit } from "@codemirror/language";
import { LINE_INSET } from "./lineInset";
/** How far (px) a guide sits left of its column, so a caret in that column is right of it (IDEA's indent guide shift). */
const GUIDE_SHIFT = 2;
/** Number of `--openidea-guide-<n>` rainbow colours; deeper guides repeat them. */
export const GUIDE_COLORS = 5;
@@ -26,41 +22,26 @@ export const rainbowGuides = Facet.define<boolean, boolean>({ combine: (values)
/**
* Values of `--openidea-guide-images` / `--openidea-guide-positions` for an
* indentation `levels` deep (in units of `step` columns), or null when it has
* no guides; the guide at level `active` takes the selected colour, the one at
* level `brace` the matched brace one. `.cm-openidea-guides` lines and code lens
* rows turn them into backgrounds (theme.ts).
* no guides. `.cm-openidea-guides` lines and code lens rows turn them into
* backgrounds (theme.ts).
*/
export function guideVars(
levels: number,
step: number,
rainbow = false,
active = 0,
brace = 0,
): { images: string; positions: string } | null {
export function guideVars(levels: number, step: number, rainbow = false): { images: string; positions: string } | null {
if (levels < 2) return null;
const images: string[] = [];
const positions: string[] = [];
for (let level = 1; level < levels; level++) {
images.push(
level === brace
? "var(--openidea-guide-brace)"
: level === active
? "var(--openidea-guide-active)"
: rainbow
? `var(--openidea-guide-${((level - 1) % GUIDE_COLORS) + 1})`
: "var(--openidea-guide)",
);
positions.push(`calc(${LINE_INSET - GUIDE_SHIFT}px + ${level * step}ch) 0`);
images.push(rainbow ? `var(--openidea-guide-${((level - 1) % GUIDE_COLORS) + 1})` : "var(--openidea-guide)");
positions.push(`calc(${LINE_INSET}px + ${level * step}ch) 0`);
}
return { images: images.join(", "), positions: positions.join(", ") };
}
const cache = new Map<string, Decoration | null>();
function guideDeco(levels: number, step: number, rainbow: boolean, active: number, brace: number) {
const key = `${levels}:${step}:${rainbow ? 1 : 0}:${active}:${brace}`;
function guideDeco(levels: number, step: number, rainbow: boolean) {
const key = `${levels}:${step}:${rainbow ? 1 : 0}`;
let d = cache.get(key);
if (d === undefined) {
const vars = guideVars(levels, step, rainbow, active, brace);
const vars = guideVars(levels, step, rainbow);
const style = vars && `--openidea-guide-images: ${vars.images}; --openidea-guide-positions: ${vars.positions}`;
d = style ? Decoration.line({ attributes: { class: "cm-openidea-guides", style } }) : null;
cache.set(key, d);
@@ -90,77 +71,10 @@ function levelOf(state: EditorState, lineNo: number, step: number, dir: 1 | -1):
return 0;
}
const BLOCK_SCAN = 10_000;
/** A guide: its level (in indent units) and the lines (`from`..`to`) it is drawn on. */
export interface Guide {
level: number;
from: number;
to: number;
}
/** The guide line `start` opens at column `col` (its indentation): down to the last deeper line. */
function blockGuide(state: EditorState, start: number, col: number, step: number): Guide | null {
if (col === 0 || col % step) return null;
let end = start;
for (let n = start + 1; n <= state.doc.lines && n - start < BLOCK_SCAN; n++) {
const cols = indentColumns(state.doc.line(n).text, state.tabSize);
if (cols === null) continue;
if (cols <= col) break;
end = n;
}
return end === start ? null : { level: col / step, from: start + 1, to: end };
}
/**
* The guide the caret is on, as IDEA's `IndentsModel.getCaretIndentGuide`: the caret's
* column is the guide's and its line lies in the guide's block, from the line opening it
* to the last deeper one.
*/
export function caretGuide(state: EditorState, step: number): Guide | null {
const head = state.selection.main.head;
const caretLine = state.doc.lineAt(head);
const col = countColumn(caretLine.text.slice(0, head - caretLine.from), state.tabSize);
if (col === 0 || col % step) return null;
let start = caretLine.number;
for (let i = 0; ; start--, i++) {
if (start < 1 || i > BLOCK_SCAN) return null;
const cols = indentColumns(state.doc.line(start).text, state.tabSize);
if (cols === null || cols > col) continue;
if (cols !== col) return null;
break;
}
const guide = blockGuide(state, start, col, step);
return guide && caretLine.number <= guide.to ? guide : null;
}
/**
* The guide of the block whose braces the caret is at, while they match on different lines:
* IDEA draws it in the matched brace colour (`MATCHED_BRACES_INDENT_GUIDE_COLOR`).
*/
export function braceGuide(state: EditorState, step: number): Guide | null {
const head = state.selection.main.head;
const match =
matchBrackets(state, head, -1) ??
(head > 0 ? matchBrackets(state, head - 1, 1) : null) ??
matchBrackets(state, head, 1) ??
(head < state.doc.length ? matchBrackets(state, head + 1, -1) : null);
if (!match?.matched || !match.end) return null;
const open = Math.min(match.start.from, match.end.from);
if (state.sliceDoc(open, open + 1) !== "{") return null;
const openLine = state.doc.lineAt(open);
if (openLine.number === state.doc.lineAt(Math.max(match.start.from, match.end.from)).number) return null;
const col = indentColumns(openLine.text, state.tabSize) ?? 0;
return blockGuide(state, openLine.number, col, step);
}
function build(view: EditorView): DecorationSet {
const state = view.state;
const step = Math.max(1, getIndentUnit(state));
const rainbow = state.facet(rainbowGuides);
const caret = caretGuide(state, step);
const brace = braceGuide(state, step);
const on = (g: Guide | null, n: number) => (g && n >= g.from && n <= g.to ? g.level : 0);
const b = new RangeSetBuilder<Decoration>();
for (const { from, to } of view.visibleRanges) {
let pos = from;
@@ -174,7 +88,7 @@ function build(view: EditorView): DecorationSet {
} else {
levels = Math.floor(cols / step);
}
const deco = guideDeco(levels, step, rainbow, on(caret, line.number), on(brace, line.number));
const deco = guideDeco(levels, step, rainbow);
if (deco) b.add(line.from, line.from, deco);
}
pos = line.to + 1;
@@ -193,7 +107,6 @@ export const indentGuides: Extension = [
update(u: ViewUpdate) {
if (
u.docChanged ||
u.selectionSet ||
u.viewportChanged ||
u.startState.tabSize !== u.state.tabSize ||
u.startState.facet(rainbowGuides) !== u.state.facet(rainbowGuides)
-2
View File
@@ -47,7 +47,6 @@ import { caretExtension, fontZoom, methodLinesField, prefsExtension, softWrapAct
import { commentTokens } from "./languages";
import { multiCaretMouse } from "./multiCaret";
import { columnModeExtension } from "./columnMode";
import { bracketBlockSelect } from "./bracketBlockSelect";
import { visibleGoalColumns } from "./verticalMotion";
import { tabKeymap } from "./smartKeys";
import { editorBaseKeymap } from "./baseKeymap";
@@ -377,7 +376,6 @@ export function baseExtensions(o: SetupOptions): Extension {
EditorState.allowMultipleSelections.of(true),
multiCaretMouse,
columnModeExtension,
bracketBlockSelect,
visibleGoalColumns,
occurrenceSearch,
overwriteField,
-7
View File
@@ -137,13 +137,6 @@ export const openideaTheme = EditorView.theme({
"var(--bg-editor-selection-inactive, color-mix(in srgb, var(--accent) 14%, transparent))",
"--editor-indent-guide": "var(--border-indent-guide, var(--border))",
"--openidea-guide": "linear-gradient(var(--editor-indent-guide), var(--editor-indent-guide))",
// The guide the caret is on (IDEA's Selected indent guide).
"--editor-indent-guide-active": "var(--border-indent-guide-active, var(--editor-indent-guide))",
"--openidea-guide-active": "linear-gradient(var(--editor-indent-guide-active), var(--editor-indent-guide-active))",
// The guide of the block whose braces match at the caret (IDEA's Matched braces indent guide).
"--editor-indent-guide-brace":
"var(--border-indent-guide-brace, var(--bg-matched-brace, var(--editor-indent-guide-active)))",
"--openidea-guide-brace": "linear-gradient(var(--editor-indent-guide-brace), var(--editor-indent-guide-brace))",
// Rainbow indent guides: one image per depth, so they follow the colour scheme.
...Object.fromEntries(
Array.from({ length: GUIDE_COLORS }, (_, i) => {
+1 -20
View File
@@ -9,10 +9,9 @@ import { registerActions } from "@/platform/actions";
import { useProject } from "@/platform/project";
import { useRemote } from "@/platform/remote";
import { hideContextMenu, resolveMenuItems, useContextMenuStore } from "@/ui";
import { evaluateWhen, getContext, setContext } from "@/platform/context";
import { getContext, setContext } from "@/platform/context";
import { installKeyboardDispatcher } from "@/platform/keymap";
import { TEXT_WHEN } from "./actions";
import { EDITOR_WHEN } from "@/editor/lsp/actions";
let ctrl: EditorController | null = null;
@@ -465,21 +464,3 @@ describe("EditorController", () => {
}
});
});
describe("editor action when clauses", () => {
const editorBehind = { textEditorActive: true, editorTextFocus: false, editorFocus: false, popupOpen: true };
it.each([
["TEXT_WHEN", TEXT_WHEN],
["EDITOR_WHEN", EDITOR_WHEN],
])("%s runs on the editor from a popup such as Find Action", (_, when) => {
expect(evaluateWhen(when, { ...editorBehind, toolWindowFocus: false })).toBe(true);
});
it.each([
["TEXT_WHEN", TEXT_WHEN],
["EDITOR_WHEN", EDITOR_WHEN],
])("%s leaves keys to a tool window's speed search, e.g. ⌘⌫ to Delete Line", (_, when) => {
expect(evaluateWhen(when, { ...editorBehind, toolWindowFocus: true })).toBe(false);
});
});
+1 -1
View File
@@ -30,7 +30,7 @@ import {
import { evaluateWhen } from "@/platform/context";
/** Editor actions apply to the active text editor unless a tool window or popup owns the keyboard. */
export const EDITOR_WHEN = "textEditorActive && (editorFocus || !toolWindowFocus)";
export const EDITOR_WHEN = "textEditorActive && (editorFocus || popupOpen || !toolWindowFocus)";
/**
* Languages whose servers offer Generate (constructors, accessors, overrides…).
+22 -120
View File
@@ -10,16 +10,7 @@ import {
* click), the multiple-targets chooser, and document highlights (occurrences
* of the symbol at the caret, plus the sticky find.highlightUsages variant).
*/
import {
countColumn,
EditorSelection,
Prec,
StateEffect,
StateField,
type Extension,
type Text,
} from "@codemirror/state";
import { LINE_INSET } from "@/editor/core/cm/lineInset";
import { EditorSelection, Prec, StateEffect, StateField, type Extension, type Text } from "@codemirror/state";
import {
Decoration,
EditorView,
@@ -63,7 +54,6 @@ import {
SYNTAX_NOTE,
declarationFallback,
indexDefinitions,
isDeclarationName,
isSelfTarget,
localHighlights,
type DeclarationTarget,
@@ -361,15 +351,6 @@ export async function gotoTargets(view: EditorView, kind: GotoKind, at?: number)
}
}
/** Whether the caret is on the name of a declaration, by the syntax tree (no language server wait). */
async function onDeclarationName(view: EditorView, offset: number): Promise<boolean> {
const doc = docOf(view);
if (!doc) return false;
const pos = offsetToPos(view.state.doc, offset);
const at = await structure.nodeAt(doc.doc, { start: pos, end: pos }).catch(() => null);
return isDeclarationName(at);
}
async function gotoResolved(
view: EditorView,
kind: GotoKind,
@@ -384,10 +365,7 @@ async function gotoResolved(
const before = view.state.doc;
const plugin = await contributedNavigation(view, offset).catch(() => null);
if (view.state.doc !== before || superseded()) return;
// ⌘B on a declaration shows its usages, also when a plugin links the name elsewhere (a
// MyBatis mapper method to its XML statement).
if (plugin && !(kind === "definition" && (await onDeclarationName(view, offset)))) {
if (superseded()) return;
if (plugin) {
if (plugin.targets.length === 1) await navigateTo(plugin.targets[0]!);
else {
const items = await locationItems(plugin.targets);
@@ -1053,8 +1031,6 @@ function navigateFromPointer(view: EditorView, e: MouseEvent): boolean {
interface HighlightState {
deco: DecorationSet;
/** The highlighted usages (the decorations also hold whole-line backgrounds). */
ranges: readonly { from: number; to: number }[];
sticky: boolean;
}
@@ -1062,94 +1038,31 @@ type HighlightItem = { from: number; to: number; kind: Highlight["kind"] };
const setHighlights = StateEffect.define<{ items: HighlightItem[]; sticky: boolean }>();
const OCCURRENCE = "cm-lsp-occurrence";
const KIND_CLASS = {
text: OCCURRENCE,
read: `${OCCURRENCE} ${OCCURRENCE}-read`,
write: `${OCCURRENCE} ${OCCURRENCE}-write`,
};
const marks = {
text: Decoration.mark({ class: KIND_CLASS.text }),
read: Decoration.mark({ class: KIND_CLASS.read }),
write: Decoration.mark({ class: KIND_CLASS.write }),
text: Decoration.mark({ class: "cm-lsp-occurrence" }),
read: Decoration.mark({ class: "cm-lsp-occurrence cm-lsp-occurrence-read" }),
write: Decoration.mark({ class: "cm-lsp-occurrence cm-lsp-occurrence-write" }),
};
const fullLines = {
text: Decoration.line({ class: KIND_CLASS.text }),
read: Decoration.line({ class: KIND_CLASS.read }),
write: Decoration.line({ class: KIND_CLASS.write }),
};
/**
* Highlights as IDEA paints a text range: one on a single line marks its text; one over
* several lines (a `return` statement's exit point) is a block, from its start to the right
* edge, whole lines between, and its last line up to its end.
*/
function highlightDeco(doc: Text, tabSize: number, items: readonly HighlightItem[]): DecorationSet {
const out = [];
for (const i of items) {
if (i.from >= i.to) continue;
const first = doc.lineAt(i.from);
const last = doc.lineAt(i.to);
if (first.number === last.number) {
out.push(marks[i.kind].range(i.from, i.to));
continue;
}
const col = countColumn(first.text.slice(0, i.from - first.from), tabSize);
const tail = Decoration.line({
class: i.kind === "write" ? "cm-lsp-occurrence-tail cm-lsp-occurrence-tail-write" : "cm-lsp-occurrence-tail",
attributes: { style: `--lsp-occurrence-from: calc(${LINE_INSET}px + ${col}ch)` },
});
out.push(tail.range(first.from));
for (let n = first.number + 1; n < last.number; n++) out.push(fullLines[i.kind].range(doc.line(n).from));
if (i.to > last.from) out.push(marks[i.kind].range(last.from, i.to));
}
return Decoration.set(out, true);
}
const highlightField = StateField.define<HighlightState>({
create: () => ({ deco: Decoration.none, ranges: [], sticky: false }),
create: () => ({ deco: Decoration.none, sticky: false }),
update(value, tr) {
for (const e of tr.effects)
if (e.is(setHighlights)) {
const items = e.value.items.filter((i) => i.from < i.to);
if (e.is(setHighlights))
return {
deco: highlightDeco(tr.state.doc, tr.state.tabSize, items),
ranges: items.map(({ from, to }) => ({ from, to })),
deco: Decoration.set(
e.value.items.filter((i) => i.from < i.to).map((i) => marks[i.kind].range(i.from, i.to)),
true,
),
sticky: e.value.sticky,
};
}
// Kept (mapped) until recomputed; ⌘⇧F7 highlights survive edits until Escape.
if (tr.docChanged)
return {
deco: value.deco.map(tr.changes),
ranges: value.ranges.map((r) => ({ from: tr.changes.mapPos(r.from, 1), to: tr.changes.mapPos(r.to, -1) })),
sticky: value.sticky,
};
if (tr.docChanged) return { deco: value.deco.map(tr.changes), sticky: value.sticky };
return value;
},
provide: (f) => EditorView.decorations.from(f, (v) => v.deco),
});
/**
* The latest usages found per editor, for its current document: a caret landing in one shows
* them at once while the server is asked again, as IDEA's highlighting follows the caret.
*/
const recentHighlights = new WeakMap<EditorView, { doc: Text; sets: HighlightItem[][] }>();
const RECENT_SETS = 8;
function rememberHighlights(view: EditorView, doc: Text, items: HighlightItem[]) {
if (!items.length) return;
let recent = recentHighlights.get(view);
if (recent?.doc !== doc) recentHighlights.set(view, (recent = { doc, sets: [] }));
recent.sets = [items, ...recent.sets].slice(0, RECENT_SETS);
}
function recentHighlightsAt(view: EditorView, doc: Text, head: number): HighlightItem[] | null {
const recent = recentHighlights.get(view);
if (recent?.doc !== doc) return null;
return recent.sets.find((set) => set.some((i) => i.from <= head && head <= i.to)) ?? null;
}
/** IDEA's status bar text while ⌘⇧F7 highlights are shown. */
export const useUsagesStatus = create<{ text: string | null; owner: EditorView | null }>(() => ({
text: null,
@@ -1179,7 +1092,6 @@ async function fetchHighlights(view: EditorView, sticky: boolean) {
const stale = () => view.state.doc !== state.doc || view.state.selection.main.head !== head;
const apply = (items: HighlightItem[]) => {
view.dispatch({ effects: setHighlights.of({ items, sticky: sticky && items.length > 0 }) });
rememberHighlights(view, state.doc, items);
if (!sticky) return;
if (!items.length) {
if (useUsagesStatus.getState().owner === view) setUsagesStatus(null, null);
@@ -1217,7 +1129,7 @@ async function fetchHighlights(view: EditorView, sticky: boolean) {
if (lspError) reportLspError(view, lspError, sticky);
else if (sticky) showHint(view, NO_SERVER);
// Drop stale marks from an earlier caret position.
if (view.state.field(highlightField).ranges.length) apply([]);
if (view.state.field(highlightField).deco.size) apply([]);
}
async function syntaxHighlights(view: EditorView, doc: EditorDocContext, head: number) {
@@ -1236,7 +1148,10 @@ async function syntaxHighlights(view: EditorView, doc: EditorDocContext, head: n
export function highlightUsages(view: EditorView) {
const st = view.state.field(highlightField, false);
const head = view.state.selection.main.head;
const onUsage = !!st?.ranges.some(({ from, to }) => from <= head && head <= to);
let onUsage = false;
st?.deco.between(head, head, (from, to) => {
if (from <= head && head <= to) onUsage = true;
});
if (st?.sticky && onUsage) {
clearHighlights(view);
return;
@@ -1250,7 +1165,8 @@ export function highlightUsages(view: EditorView) {
*/
export function gotoHighlightedUsage(view: EditorView, dir: 1 | -1): boolean {
const st = view.state.field(highlightField, false);
const ranges = [...(st?.ranges ?? [])].sort((a, b) => a.from - b.from);
const ranges: { from: number; to: number }[] = [];
st?.deco.between(0, view.state.doc.length, (from, to) => void ranges.push({ from, to }));
const head = view.state.selection.main.head;
const current = ranges.findIndex((r) => r.from <= head && head <= r.to);
if (ranges.length < 2 || current < 0) return false;
@@ -1262,8 +1178,7 @@ export function gotoHighlightedUsage(view: EditorView, dir: 1 | -1): boolean {
const highlightPlugin = ViewPlugin.fromClass(
class {
// Keyboard moves wait a little, so holding an arrow key does not ask for every line.
private request = debounce(() => void fetchHighlights(this.view, false), 100);
private request = debounce(() => void fetchHighlights(this.view, false), 300);
constructor(private view: EditorView) {}
update(u: ViewUpdate) {
// Recomputed after caret moves and, like IDEA's daemon, after edits.
@@ -1275,7 +1190,7 @@ const highlightPlugin = ViewPlugin.fromClass(
}
const clear = () => {
this.request.cancel();
if (st.ranges.length)
if (st.deco.size)
queueMicrotask(() => this.view.dispatch({ effects: setHighlights.of({ items: [], sticky: false }) }));
};
// Code Editing › "Usages of element at caret".
@@ -1283,20 +1198,7 @@ const highlightPlugin = ViewPlugin.fromClass(
clear();
return;
}
const doc = u.state.doc;
const head = u.state.selection.main.head;
const known = u.docChanged ? null : recentHighlightsAt(this.view, doc, head);
if (known)
queueMicrotask(() => {
const now = this.view.state;
if (now.doc === doc && now.selection.main.head === head)
this.view.dispatch({ effects: setHighlights.of({ items: known, sticky: false }) });
});
// A click asks at once (IDEA highlights as the caret lands).
if (u.transactions.some((tr) => tr.isUserEvent("select.pointer"))) {
this.request.cancel();
void fetchHighlights(this.view, false);
} else this.request();
this.request();
}
destroy() {
this.request.cancel();
-43
View File
@@ -45,7 +45,6 @@ provideEditorService({ openFile } as never);
const fb = await import("./syntaxFallback");
const { gotoTargets, navigationExtension, highlightUsages } = await import("./navigation");
const { registerNavigationProvider } = await import("@/editor/navigationProviders");
const { startRename, renameNamePart } = await import("./rename");
const { lspDoc } = await import("./lsp");
const { invalidateConfigurationNavigation } = await import("./configurationNavigation");
@@ -417,48 +416,6 @@ describe("navigation wiring", () => {
expect(openFile).not.toHaveBeenCalled();
});
it("shows usages on a declaration a plugin's index links elsewhere, and follows the link from a call", async () => {
// MyBatis Duo indexes the mapper method's name as a link to its XML statement.
const xml = { path: "/p/UserMapper.xml", range: r(3, 16, 24) };
const off = registerNavigationProvider(async (view) => {
const at = view.state.doc.toString().indexOf("findName");
return { from: at, to: at + 8, targets: [xml] };
});
try {
// The syntax tree puts the caret on the method's name; the host then answers both.
cmd.syntaxNodeAt.mockReturnValueOnce(
ok({
node: node("identifier", r(3, 11, 19), "name"),
ancestors: [node("method_declaration", { start: { line: 3, col: 4 }, end: { line: 3, col: 29 } })],
}),
);
cmd.lspGoto.mockReturnValueOnce(ok([xml, { path: "/p/UserMapper.java", range: r(3, 11, 19) }]));
const view = editor(
"package demo;\n\npublic interface UserMapper {\n String findName(Long id);\n}\n",
"java",
58,
"/p/UserMapper.java",
);
await gotoTargets(view, "definition");
expect(executeAction).toHaveBeenCalledWith("find.showUsages");
expect(openFile).not.toHaveBeenCalled();
// A call: the plugin's link is followed without asking the language server.
executeAction.mockClear();
cmd.lspGoto.mockClear();
cmd.syntaxNodeAt.mockReturnValueOnce(
ok({ node: node("identifier", r(1, 26, 34), "name"), ancestors: [node("method_invocation", r(1, 24, 38))] }),
);
const call = editor("class S {\n void f(UserMapper m) { m.findName(1L); }\n}\n", "java", 37, "/p/S.java");
await gotoTargets(call, "definition");
expect(openFile).toHaveBeenCalledWith("/p/UserMapper.xml", expect.objectContaining({ focus: true }));
expect(executeAction).not.toHaveBeenCalledWith("find.showUsages");
expect(cmd.lspGoto).not.toHaveBeenCalled();
} finally {
off();
}
});
it("looks past a server answer that points at the import itself", async () => {
cmd.lspGoto.mockReturnValueOnce(ok([{ path: "/p/a.py", range: r(0, 37, 43) }]));
cmd.navIndexDefinitions.mockReturnValueOnce(ok([sym("logger", "variable", "/deps/torchtitan/tools/logging.py")]));
-8
View File
@@ -63,14 +63,6 @@ export function usageRole(at: NodeAt | null): UsageRole {
return "other";
}
const DECLARATION = /(declaration|definition|declarator)$/;
/** Whether the syntax tree puts the identifier at `at` as the name of a declaration (`int f()`'s `f`). */
export function isDeclarationName(at: NodeAt | null): boolean {
const parent = at?.ancestors[0];
return !!parent && at.node.field === "name" && DECLARATION.test(parent.kind);
}
/** Index symbols that may be the declaration of `name` used in `role`. */
export function filterSymbols(matches: readonly SymbolMatch[], name: string, role: UsageRole): SymbolMatch[] {
return matches.filter((m) => {
+4 -11
View File
@@ -288,18 +288,11 @@ export const lspTheme = EditorView.theme({
textUnderlineOffset: "2px",
cursor: "pointer",
},
".cm-lsp-occurrence, .cm-lsp-occurrence-tail": {
"--lsp-occurrence-bg": "var(--bg-identifier-under-caret, color-mix(in srgb, var(--accent) 14%, transparent))",
".cm-lsp-occurrence": {
backgroundColor: "var(--bg-identifier-under-caret, color-mix(in srgb, var(--accent) 14%, transparent))",
},
".cm-lsp-occurrence-write, .cm-lsp-occurrence-tail-write": {
"--lsp-occurrence-bg": "var(--bg-identifier-under-caret-write, color-mix(in srgb, var(--danger) 12%, transparent))",
},
".cm-lsp-occurrence": { backgroundColor: "var(--lsp-occurrence-bg)" },
// First line of a highlight over several lines: from its start column to the right edge,
// over the line's own background (the caret row's).
".cm-lsp-occurrence-tail": {
backgroundImage:
"linear-gradient(to right, transparent var(--lsp-occurrence-from), var(--lsp-occurrence-bg) var(--lsp-occurrence-from))",
".cm-lsp-occurrence-write": {
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.
-125
View File
@@ -1,125 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import type { Highlight, IdeError, Range } from "@/bindings";
type R<T> = Promise<{ status: "ok"; data: T } | { status: "error"; error: IdeError }>;
const ok = <T>(data: T) => Promise.resolve({ status: "ok" as const, data });
const cmd = {
lspHover: vi.fn((): R<unknown> => ok(null)),
lspDocumentLinks: vi.fn(() => ok([])),
cancelRequest: vi.fn(() => ok(null)),
lspDocumentHighlights: vi.fn((): R<Highlight[]> => ok([])),
};
vi.mock("@/platform/ipc", async (orig) => {
const actual = await orig<typeof import("@/platform/ipc")>();
return { ...actual, isTauri: () => true, commands: { ...actual.commands, ...cmd } };
});
const { navigationExtension, highlightUsages } = await import("./navigation");
const { lspDoc } = await import("./lsp");
const range = (line: number, col: number, endLine: number, endCol: number): Range => ({
start: { line, col },
end: { line: endLine, col: endCol },
});
// 0: int f() { 4: }
// 1: return a 5: int g(String a) {
// 2: .b() 6: return a;
// 3: .c(); 7: }
const DOC = "int f() {\n return a\n .b()\n .c();\n}\nint g(String a) {\n return a;\n}";
const views: EditorView[] = [];
function editor(anchor = 0) {
const view = new EditorView({
state: EditorState.create({
doc: DOC,
selection: { anchor },
extensions: [
lspDoc.of({ doc: 7, path: "/p/A.java", language: "java", uiMode: null, readOnly: false }),
navigationExtension(),
],
}),
parent: document.body,
});
views.push(view);
return view;
}
const lines = (v: EditorView) => [...v.contentDOM.querySelectorAll<HTMLElement>(".cm-line")];
const at = (line: number, col: number) => DOC.split("\n").slice(0, line).join("\n").length + (line ? 1 : 0) + col;
const click = (v: EditorView, pos: number) => v.dispatch({ selection: { anchor: pos }, userEvent: "select.pointer" });
beforeEach(() => {
cmd.lspDocumentHighlights.mockReset().mockImplementation(() => ok([]));
});
afterEach(() => views.splice(0).forEach((v) => v.destroy()));
describe("highlights of the element at the caret", () => {
it("paints a highlight over several lines as one block, as IDEA paints a text range", async () => {
cmd.lspDocumentHighlights.mockImplementation(() => ok([{ range: range(1, 4, 3, 13), kind: "text" }]));
const v = editor(at(1, 4));
highlightUsages(v);
await vi.waitFor(() => expect(lines(v)[1]!.classList.contains("cm-lsp-occurrence-tail")).toBe(true));
// From `return` (column 4) to the right edge, the whole middle line, the last line up to `;`.
expect(lines(v)[1]!.style.getPropertyValue("--lsp-occurrence-from")).toBe("calc(2.5px + 4ch)");
expect(lines(v)[2]!.classList.contains("cm-lsp-occurrence")).toBe(true);
const last = lines(v)[3]!.querySelector(".cm-lsp-occurrence")!;
expect(last.textContent).toBe(" .c();");
expect(lines(v)[0]!.className).not.toContain("cm-lsp-occurrence");
expect(lines(v)[4]!.className).not.toContain("cm-lsp-occurrence");
});
it("keeps a single-line highlight on its text", async () => {
cmd.lspDocumentHighlights.mockImplementation(() => ok([{ range: range(6, 11, 6, 12), kind: "read" }]));
const v = editor(at(6, 11));
highlightUsages(v);
await vi.waitFor(() => expect(v.contentDOM.querySelectorAll(".cm-lsp-occurrence-read")).toHaveLength(1));
expect(lines(v)[6]!.classList.contains("cm-lsp-occurrence")).toBe(false);
});
it("asks at once after a click, and after a pause for keyboard moves", async () => {
const v = editor(0);
click(v, at(6, 11));
await vi.waitFor(() => expect(cmd.lspDocumentHighlights).toHaveBeenCalledTimes(1));
v.dispatch({ selection: { anchor: at(6, 4) }, userEvent: "select" });
await new Promise((r) => setTimeout(r, 20));
expect(cmd.lspDocumentHighlights).toHaveBeenCalledTimes(1);
await vi.waitFor(() => expect(cmd.lspDocumentHighlights).toHaveBeenCalledTimes(2));
});
it("shows the usages found before at once when the caret comes back to one", async () => {
const a = [
{ range: range(5, 13, 5, 14), kind: "write" as const },
{ range: range(6, 11, 6, 12), kind: "read" as const },
];
cmd.lspDocumentHighlights.mockImplementationOnce(() => ok(a));
const v = editor(0);
click(v, at(6, 11));
await vi.waitFor(() => expect(v.contentDOM.querySelectorAll(".cm-lsp-occurrence")).toHaveLength(2));
click(v, at(1, 4));
await vi.waitFor(() => expect(v.contentDOM.querySelectorAll(".cm-lsp-occurrence")).toHaveLength(0));
// The server is slow now: the remembered usages of `a` show without waiting for it.
cmd.lspDocumentHighlights.mockImplementation(() => new Promise(() => {}));
click(v, at(5, 13));
await vi.waitFor(() => expect(v.contentDOM.querySelectorAll(".cm-lsp-occurrence")).toHaveLength(2));
});
it("forgets them after an edit", async () => {
cmd.lspDocumentHighlights.mockImplementationOnce(() => ok([{ range: range(6, 11, 6, 12), kind: "read" }]));
const v = editor(0);
click(v, at(6, 11));
await vi.waitFor(() => expect(v.contentDOM.querySelectorAll(".cm-lsp-occurrence")).toHaveLength(1));
v.dispatch({ changes: { from: 0, insert: "\n" } });
click(v, 0);
await vi.waitFor(() => expect(v.contentDOM.querySelectorAll(".cm-lsp-occurrence")).toHaveLength(0));
// Back on `a` with a slow server: the results of the document before the edit are not reused.
cmd.lspDocumentHighlights.mockImplementation(() => new Promise(() => {}));
click(v, at(6, 11) + 1);
await vi.waitFor(() => expect(cmd.lspDocumentHighlights).toHaveBeenCalledTimes(3));
await new Promise((r) => setTimeout(r, 20));
expect(v.contentDOM.querySelectorAll(".cm-lsp-occurrence")).toHaveLength(0);
});
});
+1 -1
View File
@@ -22,7 +22,7 @@ import { gotoTestOrSubject, type TestNavDeps } from "./gotoTest";
import { baseName, memberAt, methodStop, superMethods } from "./structureNav";
import { tx } from "@/platform/i18n";
const EDITOR_WHEN = "textEditorActive && (editorFocus || !toolWindowFocus)";
const EDITOR_WHEN = "textEditorActive && (editorFocus || popupOpen || !toolWindowFocus)";
/** A backend document id for `path` while `fn` runs (open editors are reused). */
async function withDoc<T>(path: string, fn: (doc: number) => Promise<T>): Promise<T> {
+1 -1
View File
@@ -26,7 +26,7 @@ import { ServerStatus } from "./ServerStatus";
import { reportProblems } from "./problemsReport";
import { installWorkspaceEditUndo } from "./workspaceUndo";
const EDITOR_WHEN = "textEditorActive && (editorFocus || !toolWindowFocus)";
const EDITOR_WHEN = "textEditorActive && (editorFocus || popupOpen || !toolWindowFocus)";
function useErrorBadge(): number | null {
return useProblems((s) => {
@@ -5,9 +5,6 @@ import { executeAction } from "@/platform/actions";
import { setContext } from "@/platform/context";
import { registerDatabaseTreeActions } from "./treeActions";
import { tablesKey, useDatabase } from "./store";
import { tableInput } from "./tableInput";
import { useEditorRegistry } from "@/editor/api";
import { provideEditorService, provideToolWindowService, type EditorInput } from "@/workbench/contributions";
vi.mock("@tanstack/react-virtual", () => ({
useVirtualizer: ({ count }: { count: number }) => ({
@@ -111,61 +108,6 @@ describe("routine grouping", () => {
});
});
describe("Select Opened File", () => {
const nextFrame = () => act(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
let active: EditorInput | null = null;
const show = vi.fn();
beforeEach(() => {
provideEditorService({ activeInput: () => active } as never);
provideToolWindowService({ show } as never);
useDatabase.setState({
tables: {
[tablesKey("pg", "public")]: {
status: "ok",
value: [
{ name: "people", kind: "table" },
{ name: "adults", kind: "view" },
],
},
},
schemas: { pg: { status: "ok", value: ["public", "audit"] } },
treeExpanded: new Set(),
});
});
afterEach(() => {
active = null;
useEditorRegistry.setState({ active: null });
});
it("selects the active table editor's view in the views folder", async () => {
render(<DatabaseView />);
active = tableInput("pg", "public", "adults");
await act(() => executeAction("db.selectOpened"));
await nextFrame();
expect(useDatabase.getState().selectedNode?.key).toBe("pg/public/views/adults");
expect(show).toHaveBeenCalledWith("database");
expect(document.activeElement).toBe(screen.getByRole("tree", { name: "Data sources" }));
});
it("selects the nearest shown ancestor of a dropped table, and a console's schema", async () => {
render(<DatabaseView />);
active = tableInput("pg", "public", "dropped");
await act(() => executeAction("db.selectOpened"));
await nextFrame();
expect(useDatabase.getState().selectedNode?.key).toBe("pg/public/tables");
const path = "/project/audit.sql";
useDatabase.setState({ consoles: { [path]: "pg" }, consoleSchemas: { [path]: "audit" } });
active = { kind: "file", key: path, path };
useEditorRegistry.setState({ active: { ctx: { path, language: "sql" } } as never });
await act(() => executeAction("db.selectOpened"));
await nextFrame();
expect(useDatabase.getState().selectedNode?.key).toBe("pg/audit");
});
});
describe("Expand All", () => {
it("opens every loaded node and leaves unloaded ones closed", async () => {
useDatabase.setState({
+4 -5
View File
@@ -143,12 +143,12 @@ export function DatabaseView() {
useEffect(() => {
if (!st.loaded) void loadSources();
}, [st.loaded]);
// Go to Declaration from a console, Select Opened File: the rows exist once the expanded nodes render.
// Go to Declaration from a console: the rows exist once the expanded nodes render.
useEffect(() => {
if (!st.treeReveal) return;
const path = st.treeReveal.path;
const key = st.treeReveal.key;
const frame = requestAnimationFrame(() => {
path.toReversed().some((key) => tree.current?.reveal(key));
tree.current?.reveal(key);
tree.current?.focus();
});
return () => cancelAnimationFrame(frame);
@@ -683,11 +683,10 @@ function DatabaseToolbar() {
);
}
/** Header controls (shown on hover, like IDEA's): Select Opened File, Expand All, Collapse All. */
/** Header controls (shown on hover, like IDEA's): Expand All, Collapse All. */
export function DatabaseHeaderActions() {
return (
<>
<IconButton icon="locate" action="db.selectOpened" size="small" iconSize={16} />
<IconButton icon="expand-all" action="db.expandAll" size="small" iconSize={16} />
<IconButton icon="collapse-all" action="db.collapseAll" size="small" iconSize={16} />
</>
+13 -62
View File
@@ -25,10 +25,9 @@ import {
type RequestId,
type SqlStatement,
} from "@/platform/ipc";
import { activeEditor, editorsFor } from "@/editor/api";
import { editorsFor } from "@/editor/api";
import {
editorService,
maybeEditorService,
notifications,
showServiceView,
toolWindowService,
@@ -45,11 +44,8 @@ import {
idleTargets,
newSourceId,
splitTarget,
targetOf,
} from "./model";
import { tableFolder, type DbNode } from "./nodes";
import { SQL_LANGUAGE } from "./sqlLanguage";
import { TABLE_KIND, type TableRef } from "./tableInput";
import type { ExportData, ExportFormat } from "./export";
import type { DbObjectRef } from "./objects";
import { formatted } from "./sql/format";
@@ -184,7 +180,7 @@ interface DatabaseState {
/** Expanded nodes of the Database tree. */
treeExpanded: Set<string>;
/** A tree node to select and scroll to (Go to Declaration from a console). */
treeReveal: { path: string[]; seq: number } | null;
treeReveal: { key: string; seq: number } | null;
/** Last value entered per query parameter name. */
paramValues: Record<string, string>;
/** Data sources in manual-commit mode. */
@@ -608,14 +604,13 @@ export async function loadDetails(id: string, schema: string, table: string): Pr
/**
* Selects a table (or one of its columns) in the Database tool window,
* loading and expanding the nodes that lead to it. Without `kind` the loaded
* tables tell whether it is a table or a view.
* loading and expanding the nodes that lead to it.
*/
export async function revealInTree(
id: string,
schema: string,
table: string,
kind: DbTableKind | undefined,
kind: DbTableKind,
column?: string,
): Promise<void> {
const loaded = (l: Loaded<unknown> | undefined) => l?.status === "ok";
@@ -637,64 +632,20 @@ export async function revealInTree(
if (!loaded(useDatabase.getState().tables[tablesKey(id, schema)])) await loadTables(id, schema);
if (column && !loaded(useDatabase.getState().details[detailsKey(id, schema, table)]))
await loadDetails(id, schema, table);
const tables = useDatabase.getState().tables[tablesKey(id, schema)];
kind ??= (tables?.status === "ok" ? tables.value.find((t) => t.name === table)?.kind : undefined) ?? "table";
const folder = `${id}/${schema}/${tableFolder(kind)}`;
const tableKey = `${folder}/${table}`;
selectInTree(
[sourceId, ...databaseNode, `${id}/${schema}`, folder, ...(column ? [tableKey, `${tableKey}/columns`] : [])],
column ? `${tableKey}/columns/${column}` : tableKey,
);
}
/**
* Expands `open` and selects node `key` in the Database tool window, showing it.
* A node the tree doesn't show (filtered out, dropped) selects its nearest shown ancestor.
*/
function selectInTree(open: string[], key: string) {
const open = [
sourceId,
...databaseNode,
`${id}/${schema}`,
folder,
...(column ? [tableKey, `${tableKey}/columns`] : []),
];
useDatabase.setState((s) => ({
treeExpanded: new Set([...s.treeExpanded, ...open]),
treeReveal: { path: [...open, key], seq: (s.treeReveal?.seq ?? 0) + 1 },
treeReveal: { key: column ? `${tableKey}/columns/${column}` : tableKey, seq: (s.treeReveal?.seq ?? 0) + 1 },
}));
// Not `show(…, true)`: activation focuses the filter field after the view focuses the tree.
toolWindowService().show(DB_TOOL_WINDOW);
}
/**
* Select Opened File: the active table editor's table, else the schema (or
* database / data source) a SQL console runs in. A console reveals only nodes
* already loaded: it never connects.
*/
export async function revealOpened(): Promise<void> {
const input = maybeEditorService()?.activeInput?.();
if (input?.kind === TABLE_KIND) {
const { id, schema, table } = input.data as TableRef;
return revealInTree(id, schema, table, undefined);
}
const editor = activeEditor();
if (editor?.ctx.language !== SQL_LANGUAGE || (input && input.path !== editor.ctx.path)) return;
const target = consoleSource(editor.ctx.path);
if (!target) return;
const s = useDatabase.getState();
const { id: sourceId, database } = splitTarget(target);
const open: string[] = [];
let key = sourceId;
let nodeTarget = target;
if (listsDatabases(sourceById(sourceId))) {
const dbs = s.databases[sourceId];
const name = database ?? (dbs?.status === "ok" ? dbs.value.current : null);
if (dbs?.status !== "ok" || !name) return selectInTree(open, key);
open.push(key);
key = databaseKey(sourceId, name);
nodeTarget = targetOf(sourceId, name, dbs.value.current);
}
const schema = consoleSchema(editor.ctx.path);
const schemas = s.schemas[nodeTarget];
if (schema && schemas?.status === "ok" && schemas.value.includes(schema)) {
open.push(key);
key = `${nodeTarget}/${schema}`;
}
selectInTree(open, key);
toolWindowService().show(DB_TOOL_WINDOW, true);
}
const completionLoads = new Map<string, Promise<DbCompletion | null>>();
-10
View File
@@ -31,7 +31,6 @@ import {
readableDdl,
refresh,
revealInTree,
revealOpened,
runSql,
sourceById,
tablesKey,
@@ -486,15 +485,6 @@ export function registerDatabaseTreeActions(): () => void {
title: "Separate Procedures and Functions",
run: toggleSeparateProceduresAndFunctions,
}),
tree({
id: "db.selectOpened",
title: "Select Opened File",
icon: "locate",
keywords: ["locate", "scroll from editor", "scroll from source", "select in database view"],
// From the editor: a user-assigned shortcut works outside the Database view too.
keyWhen: undefined,
run: () => void revealOpened(),
}),
tree({
id: "db.expandAll",
title: "Expand All",
+1 -1
View File
@@ -25,7 +25,7 @@ import { loadEmmet } from "./emmet/model";
import { promptAbbreviation, UpdateTagDialogHost } from "./emmet/UpdateTagDialog";
import { tx } from "@/platform/i18n";
const EDITOR_WHEN = "textEditorActive && !editorReadOnly && (editorFocus || !toolWindowFocus)";
const EDITOR_WHEN = "textEditorActive && !editorReadOnly && (editorFocus || popupOpen || !toolWindowFocus)";
const MARKUP_PATH = /\.(html?|xhtml|shtml|vue|svelte|xml)$/i;
async function updateTagWithEmmet() {
@@ -5,12 +5,6 @@ import type { RemoteOp } from "@/bindings";
const mocks = vi.hoisted(() => ({
ops: [] as Array<{ repo: string | null; op: RemoteOp }>,
failing: new Set<string>(),
ask: vi.fn(),
}));
vi.mock("@/workbench/messages", async (importOriginal) => ({
...(await importOriginal<typeof import("@/workbench/messages")>()),
ask: mocks.ask,
}));
vi.mock("@tauri-apps/api/core", async (importOriginal) => ({
@@ -56,8 +50,6 @@ let notes: Note[] = [];
beforeEach(() => {
mocks.ops = [];
mocks.failing = new Set();
mocks.ask.mockReset();
mocks.ask.mockResolvedValue({ button: "force", checked: false });
notes = [];
useVcs.setState({ root: "/p", roots: ["/p/a", "/p/b", "/p/c"], repo: "/p/a", remoteBusy: null, available: false });
provideNotificationService({ notify: (n) => void notes.push(n as Note), expire: () => {} });
@@ -118,66 +110,3 @@ it("pushes each repository to its own remote and target branch, reporting the on
await waitFor(() => expect(notes.map((n) => n.title)).toContain("Push partially failed"));
expect(notes.find((n) => n.title === "Push partially failed")?.body).toContain("Not pushed: b.");
});
const forceOf = (ops: typeof mocks.ops) =>
ops.map(({ repo, op }) => [repo, "force_with_lease" in op ? op.force_with_lease : null]);
const askedTargets = () => mocks.ask.mock.calls.map(([r]) => (r as { body: string }).body.match(/to '(.*)'/)?.[1]);
async function openPushDialog() {
render(<VcsDialogHost />);
await act(() => showPushDialog());
await screen.findByRole("textbox", { name: "Target branch of a" });
}
it("asks to confirm each forced target and pushes nothing when one is declined", async () => {
mocks.ask.mockResolvedValueOnce({ button: "force", checked: false }).mockResolvedValueOnce({ button: null });
await openPushDialog();
fireEvent.change(screen.getByRole("textbox", { name: "Target branch of a" }), { target: { value: "feat-a" } });
fireEvent.click(screen.getByRole("checkbox", { name: "Force (with lease)" }));
fireEvent.click(screen.getByRole("button", { name: "Force Push" }));
await waitFor(() => expect(mocks.ask).toHaveBeenCalledTimes(2));
expect(askedTargets()).toEqual(["origin/feat-a", "origin/feat-x"]);
expect(mocks.ops).toEqual([]);
// Declining keeps the dialog open, as in the single-repository dialog.
expect(screen.getByRole("button", { name: "Force Push" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
});
it("force pushes with lease once every target is confirmed", async () => {
await openPushDialog();
fireEvent.click(screen.getByRole("checkbox", { name: "Force (with lease)" }));
fireEvent.click(screen.getByRole("button", { name: "Force Push" }));
await waitFor(() => expect(mocks.ops).toHaveLength(3));
// The three repositories push to the same target: one confirmation.
expect(askedTargets()).toEqual(["origin/feat-x"]);
expect(forceOf(mocks.ops)).toEqual([
["/p/a", true],
["/p/b", true],
["/p/c", true],
]);
});
it("does not ask about a protected target, which is never forced", async () => {
await openPushDialog();
fireEvent.change(screen.getByRole("textbox", { name: "Target branch of a" }), { target: { value: "main" } });
fireEvent.click(screen.getByRole("checkbox", { name: "Force (with lease)" }));
fireEvent.click(screen.getByRole("button", { name: "Force Push" }));
await waitFor(() => expect(mocks.ops).toHaveLength(3));
expect(askedTargets()).toEqual(["origin/feat-x"]);
expect(forceOf(mocks.ops)).toEqual([
["/p/a", false],
["/p/b", true],
["/p/c", true],
]);
});
it("does not ask without Force", async () => {
await openPushDialog();
fireEvent.click(screen.getByRole("button", { name: "Push" }));
await waitFor(() => expect(mocks.ops).toHaveLength(3));
expect(mocks.ask).not.toHaveBeenCalled();
});
-9
View File
@@ -342,15 +342,6 @@ function MultiPushDialog({ only, onClose }: MultiPushDialogProps) {
// Like the single-repository dialog: tags go only with plain pushes, so a retargeted repository hides them.
const retargeted = picked.some((r) => pushOperation(choiceOf(r, null)).op === "pushBranch");
const push = async () => {
// Like the single-repository dialog: each forced target is confirmed first (once, when several
// repositories push to the same one), and declining one pushes nothing.
const forced = new Set<string>();
for (const r of force ? picked : []) {
const target = targetNameOf(r);
const remote = shownRemoteOf(r);
if (!isProtectedBranch(target)) forced.add(remote ? `${remote}/${target}` : target);
}
for (const target of forced) if (!(await confirmForcePush(target))) return;
onClose();
// Every picked repository is pushed even when another fails; the outcome is summed up.
const session: PushSession = { method: null };
+1 -28
View File
@@ -1,13 +1,5 @@
import { expect, it, vi } from "vitest";
import {
executeAction,
executeActionOrThrow,
getAction,
registerAction,
registerActions,
useActions,
useRunningActions,
} from "./actions";
import { executeAction, executeActionOrThrow, registerAction, useRunningActions } from "./actions";
const running = (id: string) => useRunningActions.getState().running.get(id) ?? 0;
@@ -99,22 +91,3 @@ it("checks argument-specific enablement before dispatch", async () => {
dispose();
}
});
it("registers a batch with one store update and disposes every action", () => {
const updates: number[] = [];
const off = useActions.subscribe((s) => updates.push(s.actions.size));
const dispose = registerActions([
{ id: "test.batch.one", title: "One", run: () => {} },
{ id: "test.batch.two", title: "Two", run: () => {} },
]);
try {
expect(updates).toHaveLength(1);
expect(getAction("test.batch.one")?.title).toBe("One");
expect(getAction("test.batch.two")?.title).toBe("Two");
} finally {
dispose();
off();
}
expect(getAction("test.batch.one")).toBeUndefined();
expect(getAction("test.batch.two")).toBeUndefined();
});
+4 -19
View File
@@ -116,9 +116,10 @@ useI18n.subscribe((s, prev) => {
useActions.setState({ actions: new Map([...sources].map(([id, a]) => [id, localize(a)])) });
});
/** Adds `action` to the written sources; returns a disposer that removes it. */
function addSource(action: Action): () => void {
/** Registers an action; returns a disposer. Re-registering an id replaces it. */
export function registerAction(action: Action): () => void {
sources.set(action.id, action);
useActions.setState((s) => ({ actions: new Map(s.actions).set(action.id, localize(action)) }));
return () => {
if (sources.get(action.id) !== action) return;
sources.delete(action.id);
@@ -130,24 +131,8 @@ function addSource(action: Action): () => void {
};
}
/** Registers an action; returns a disposer. Re-registering an id replaces it. */
export function registerAction(action: Action): () => void {
const dispose = addSource(action);
useActions.setState((s) => ({ actions: new Map(s.actions).set(action.id, localize(action)) }));
return dispose;
}
export function registerActions(actions: Action[]): () => void {
const disposers = actions.map(addSource);
// One state update for the batch: features register dozens of actions at start-up,
// and cloning the whole map per action made that O(n²).
if (actions.length) {
useActions.setState((s) => {
const next = new Map(s.actions);
for (const action of actions) next.set(action.id, localize(action));
return { actions: next };
});
}
const disposers = actions.map(registerAction);
return () => disposers.forEach((d) => d());
}
+1 -1
View File
@@ -223,7 +223,7 @@ it("renames in the project view although the preset shares Alt+Shift+R with the
const dbRename = vi.fn();
const refactorRename = vi.fn();
// The claimants as they ship: `when` + `keyWhen` exactly as registered.
const EDITOR = "textEditorActive && (editorFocus || !toolWindowFocus)";
const EDITOR = "textEditorActive && (editorFocus || popupOpen || !toolWindowFocus)";
const disposers = [
registerAction({
id: "project.rename",
-1
View File
@@ -404,7 +404,6 @@ export const intellijMacos: Record<string, string[]> = {
"db.runSqlScript": [],
"db.sqlScript": [],
"db.goToReferencedTable": [],
"db.selectOpened": [],
"db.expandAll": [],
"db.collapseAll": [],
// Terminal (keyWhen: terminalFocus); after VCS Update / Go to Test so it wins there.
-1
View File
@@ -402,7 +402,6 @@ export const intellijWindows: Record<string, string[]> = {
"db.runSqlScript": [],
"db.sqlScript": [],
"db.goToReferencedTable": [],
"db.selectOpened": [],
"db.expandAll": [],
"db.collapseAll": [],
// Terminal (keyWhen: terminalFocus); after VCS Update / Go to Test so it wins there.
-1
View File
@@ -392,7 +392,6 @@ export const vscodeMacos: Record<string, string[]> = {
"db.runSqlScript": [],
"db.sqlScript": [],
"db.goToReferencedTable": [],
"db.selectOpened": [],
"db.expandAll": [],
"db.collapseAll": [],
// Find in Files popup (findPopupOpen): after the editor's keys, so these win while it is open.
-26
View File
@@ -3,8 +3,6 @@ import { createRef } from "react";
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { Tree, type TreeHandle } from "./Tree";
import { getContext } from "@/platform/context";
import { registerAction } from "@/platform/actions";
import { installKeyboardDispatcher, useKeymap } from "@/platform/keymap";
interface N {
id: string;
@@ -253,30 +251,6 @@ describe("Tree speed search", () => {
expect(labels(tree)).toContain("App.tsx");
});
it("⌘⌫ erases the query and ⌥⌫ its last word instead of running the view's ⌘⌫ action", () => {
const drop = vi.fn();
useKeymap.setState({ preset: "intellij-macos", overrides: { "test.drop": ["Meta+Backspace", "Alt+Backspace"] } });
const dispose = [registerAction({ id: "test.drop", title: "Drop", run: drop }), installKeyboardDispatcher()];
try {
const { tree, lead, key } = setup({ defaultExpanded: ["src", "main"] });
key("ArrowDown");
for (const c of "index.t") key(c);
expect(lead()).toBe("index.ts");
key("Backspace", { altKey: true });
expect(screen.getByRole("status")).toHaveTextContent(/^index\.$/);
key("Backspace", { metaKey: true });
expect(screen.queryByRole("status")).toBeNull();
expect(drop).not.toHaveBeenCalled();
// Without a query the shortcut reaches the view again.
key("Backspace", { metaKey: true });
expect(drop).toHaveBeenCalledOnce();
expect(tree).toHaveFocus();
} finally {
dispose.forEach((d) => d());
useKeymap.setState({ overrides: {} });
}
});
it("filter mode hides non-matching rows but keeps ancestors", () => {
const { tree, key } = setup({ defaultExpanded: ["src", "main"], speedSearchMode: "filter" });
key("ArrowDown");
+6 -17
View File
@@ -31,7 +31,6 @@ import { acquirePopup } from "./popupState";
import styles from "./Tree.module.css";
import { T } from "./T";
import { setContext } from "@/platform/context";
import { registerKeyClaim } from "@/platform/keymap";
import { useGivenTx } from "@/platform/i18n";
import { useTreeReorder, type TreeReorder } from "./treeReorder";
import { startPointerDrag, type DragData } from "./pointerDrag";
@@ -62,8 +61,8 @@ export interface TreeOccurrence {
export interface TreeHandle {
focus(): void;
/** Selects `key` (must be visible, i.e. ancestors expanded) and scrolls to it; false when no row has `key`. */
reveal(key: string): boolean;
/** Selects `key` (must be visible, i.e. ancestors expanded) and scrolls to it. */
reveal(key: string): void;
leadKey(): string | null;
/** Selects the next (1) / previous (-1) row, for a filter field that keeps the focus (Settings search). */
moveSelection(dir: 1 | -1): void;
@@ -368,18 +367,10 @@ export function Tree<T>(props: TreeProps<T>) {
pendingScrollToLead.current = null;
}, [lead, indexOfKey, virtualizer]);
// Speed search owns Escape while active, and ⌫ with any modifier: the query is edited like a
// text field instead of the view's shortcuts running (⌘⌫ is Drop… in the Database view).
// Speed search owns Escape while active.
useEffect(() => {
if (!query) return;
const release = acquirePopup();
const unclaim = registerKeyClaim(
(e) => e.key === "Backspace" && e.target instanceof Node && !!scrollRef.current?.contains(e.target),
);
return () => {
release();
unclaim();
};
return acquirePopup();
}, [query]);
const setExpanded = (row: Row<T>, open: boolean) => {
@@ -429,11 +420,10 @@ export function Tree<T>(props: TreeProps<T>) {
focus: () => scrollRef.current?.focus(),
reveal: (key: string) => {
const i = indexOfKey.get(key);
if (i === undefined) return false;
if (i === undefined) return;
setAnchor(key);
commitSelection(new Set([key]), key);
virtualizer.scrollToIndex(i, { align: "center" });
return true;
},
leadKey: () => lead,
moveSelection: (dir: 1 | -1) => {
@@ -533,8 +523,7 @@ export function Tree<T>(props: TreeProps<T>) {
break;
case "Backspace":
if (!query) return;
// ⌘⌫ erases the query, ⌥⌫ / Ctrl+⌫ its last word, as in a text field.
setQuery(e.metaKey ? "" : e.altKey || e.ctrlKey ? query.replace(/\w*\W*$/, "") : query.slice(0, -1));
setQuery(query.slice(0, -1));
break;
case "ArrowDown":
case "ArrowUp": {
-2
View File
@@ -179,7 +179,6 @@ const ICONS = {
"file-asciidoc": "openidea/fileTypes/asciidoc",
"file-d": "openidea/fileTypes/d",
"file-prisma": "openidea/fileTypes/prisma",
"file-slint": "openidea/fileTypes/slint",
"file-dart": "openidea/fileTypes/dart",
"file-elixir": "openidea/fileTypes/elixir",
"file-erlang": "openidea/fileTypes/erlang",
@@ -1108,7 +1107,6 @@ const EXT_ICONS = table({
"file-asciidoc": "adoc asciidoc",
"file-d": "d di",
"file-prisma": "prisma",
"file-slint": "slint",
"file-dart": "dart",
"file-elixir": "ex exs",
"file-erlang": "erl hrl escript app.src",
-3
View File
@@ -1,3 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g stroke-linejoin="round"><path d="M14 4C14 2.89543 13.1046 2 12 2H4C2.89543 2 2 2.89543 2 4V12C2 13.1046 2.89543 14 4 14H12C13.1046 14 14 13.1046 14 12V4Z" fill="#2379F4"/><path transform="translate(5 3.5)" d="M5.5 1.6C5.1 0.9 4.3 0.5 3.4 0.5H2.6C1.4 0.5 0.5 1.3 0.5 2.4C0.5 3.5 1.3 3.9 2.5 4.2L3.5 4.4C4.7 4.7 5.5 5.1 5.5 6.3C5.5 7.5 4.6 8.5 3.4 8.5H2.6C1.6 8.5 0.9 8 0.5 7.4" stroke="#2B2D30"/></g>
</svg>

Before

Width:  |  Height:  |  Size: 506 B

-3
View File
@@ -1,3 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g stroke-linejoin="round"><path d="M14 4C14 2.89543 13.1046 2 12 2H4C2.89543 2 2 2.89543 2 4V12C2 13.1046 2.89543 14 4 14H12C13.1046 14 14 13.1046 14 12V4Z" fill="#4A90F5"/><path transform="translate(5 3.5)" d="M5.5 1.6C5.1 0.9 4.3 0.5 3.4 0.5H2.6C1.4 0.5 0.5 1.3 0.5 2.4C0.5 3.5 1.3 3.9 2.5 4.2L3.5 4.4C4.7 4.7 5.5 5.1 5.5 6.3C5.5 7.5 4.6 8.5 3.4 8.5H2.6C1.6 8.5 0.9 8 0.5 7.4" stroke="#2B2D30"/></g>
</svg>

Before

Width:  |  Height:  |  Size: 506 B