mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-13 18:37:39 -10:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93cccb9bb5 | ||
|
|
40494f2920 | ||
|
|
c016007a2d | ||
|
|
69ce1ab13d | ||
|
|
fcc8583f36 | ||
|
|
b368f21eb6 | ||
|
|
929866bbc5 | ||
|
|
3c781296d6 | ||
|
|
4aed449dc0 | ||
|
|
55431f3965 | ||
|
|
d4b14a36c7 | ||
|
|
d2c565b19d | ||
|
|
01a60ef164 | ||
|
|
b0b768d25e | ||
|
|
3240bfc423 | ||
|
|
15182507cf |
No files matched your search
@@ -309,9 +309,13 @@ 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.
|
||||
@@ -352,6 +356,30 @@ 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 || {
|
||||
@@ -2136,6 +2164,24 @@ 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());
|
||||
|
||||
@@ -2554,7 +2554,8 @@ 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();
|
||||
f.host.post_events(Some("main"), vec![WorkbenchEvent::EditorFocused(Some("src/notes.txt".into()))]);
|
||||
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.flush_events_at(now + Duration::from_secs(3));
|
||||
assert!(f.host.wait_events(Duration::from_secs(10)));
|
||||
let item = |host: &PluginHost| {
|
||||
@@ -2992,13 +2993,18 @@ 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", 3));
|
||||
assert_eq!((t.columns[0].as_str(), t.total_rows), ("Column 1", 4));
|
||||
|
||||
// Undeclared views are refused before any code runs.
|
||||
let err = f.host.render_view("openidea.csv-editor", "other", None, inv).unwrap_err();
|
||||
|
||||
@@ -223,6 +223,10 @@ 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.
|
||||
@@ -585,9 +589,10 @@ impl Peer {
|
||||
.expect("spawn jsonrpc notification thread");
|
||||
}
|
||||
|
||||
// Reader: parses and dispatches; never runs handler code itself.
|
||||
// Reader: parses and dispatches; runs no handler code beyond `normalize_incoming`.
|
||||
{
|
||||
let inner = inner.clone();
|
||||
let handler = handler.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(reader);
|
||||
let seen_message = AtomicBool::new(false);
|
||||
@@ -610,7 +615,7 @@ impl Peer {
|
||||
match msg {
|
||||
Ok(Some(body)) => {
|
||||
seen_message.store(true, Ordering::Relaxed);
|
||||
dispatch(&inner, ¬if_tx, &body);
|
||||
dispatch(&inner, &*handler, ¬if_tx, &body);
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
@@ -783,15 +788,16 @@ async fn handle_request(inner: Arc<Inner>, handler: Arc<dyn Handler>, id: Value,
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch(inner: &Arc<Inner>, notif_tx: &std::sync::mpsc::Sender<Incoming>, body: &[u8]) {
|
||||
fn dispatch(inner: &Arc<Inner>, handler: &dyn Handler, notif_tx: &std::sync::mpsc::Sender<Incoming>, body: &[u8]) {
|
||||
tracing::trace!(target: "ide_jsonrpc::wire", "<-- {}", String::from_utf8_lossy(body));
|
||||
let msg: Value = match serde_json::from_slice(body) {
|
||||
let mut 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;
|
||||
|
||||
@@ -541,3 +541,29 @@ 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"));
|
||||
}
|
||||
@@ -391,6 +391,10 @@ 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 {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! `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;
|
||||
|
||||
@@ -22,6 +24,53 @@ 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('\\', "/")
|
||||
@@ -95,6 +144,28 @@ 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() {
|
||||
|
||||
@@ -65,5 +65,26 @@ fn bench(c: &mut Criterion) {
|
||||
g.finish();
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench);
|
||||
// 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_main!(benches);
|
||||
@@ -143,18 +143,22 @@ pub(crate) fn run(req: Request<'_>) -> IdeResult<SearchSummary> {
|
||||
}
|
||||
|
||||
if !ctx.ctl.should_stop() {
|
||||
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 }
|
||||
})
|
||||
});
|
||||
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 }
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let cancelled = ctx.ctl.cancelled.load(Relaxed) || req.cancel.is_cancelled();
|
||||
@@ -176,7 +180,11 @@ 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], mut visitor: impl FnMut() -> VisitFile<'s> + Send) {
|
||||
pub(crate) fn walk_files<'s>(
|
||||
roots: &[PathBuf],
|
||||
skip_dir: &'s (dyn Fn(&Path) -> bool + Sync),
|
||||
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 {
|
||||
@@ -204,6 +212,9 @@ pub(crate) fn walk_files<'s>(roots: &[PathBuf], mut visitor: impl FnMut() -> Vis
|
||||
};
|
||||
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 };
|
||||
@@ -595,3 +606,39 @@ 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());
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ pub fn scope_files(
|
||||
on_disk.push(path);
|
||||
}
|
||||
}
|
||||
walk_files(&on_disk, || {
|
||||
walk_files(&on_disk, &|path| mask.has_directory_exclusions() && mask.excludes_dir(&rel_path(root, path)), || {
|
||||
let found = &found;
|
||||
let keep = &keep;
|
||||
Box::new(move |path| {
|
||||
|
||||
@@ -12,6 +12,7 @@ pub struct FileMask {
|
||||
exclude_name: GlobSet,
|
||||
exclude_path: GlobSet,
|
||||
has_include: bool,
|
||||
exclude_dirs: GlobSet,
|
||||
}
|
||||
|
||||
impl FileMask {
|
||||
@@ -20,6 +21,7 @@ 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();
|
||||
@@ -32,10 +34,20 @@ 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)?,
|
||||
@@ -43,6 +55,20 @@ 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);
|
||||
@@ -133,6 +159,60 @@ 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();
|
||||
|
||||
@@ -797,3 +797,42 @@ 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"]);
|
||||
}
|
||||
@@ -1410,10 +1410,16 @@ 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);
|
||||
if final_dir.exists() {
|
||||
std::fs::remove_dir_all(&final_dir)?;
|
||||
}
|
||||
std::fs::rename(&pkg, &final_dir)?;
|
||||
// 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);
|
||||
});
|
||||
let target = final_dir.join(&rel);
|
||||
let bin = final_dir.join("bin");
|
||||
if is_bundle(main) {
|
||||
@@ -1428,8 +1434,6 @@ 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()) })
|
||||
}
|
||||
|
||||
@@ -1563,6 +1567,25 @@ 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")]
|
||||
{
|
||||
|
||||
@@ -916,3 +916,22 @@ 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");
|
||||
}
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
446156fc345df7c8252592f73129f3996139e5980c6ed9a296a9cbecc334eb17
|
||||
ec93db9b37437986167b4040714f5cda5cb7a2fc138e6c2dc2a9447faaf5a802
|
||||
@@ -31,10 +31,19 @@ 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 = endpoint.starts_with(OPENAI);
|
||||
let openai = is_openai(&endpoint);
|
||||
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") }
|
||||
}
|
||||
@@ -195,6 +204,29 @@ 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"]);
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
b072d07c4aa4a972a71fffba355ea215da7860b8f5527c6074055f52c52a29be
|
||||
1bd90bca250bb06ab3b0f27b045110c722685d894ce79d23648cb7c795d2c033
|
||||
@@ -106,10 +106,16 @@ fn project_of(path: &str) -> Option<Project> {
|
||||
host::projects()
|
||||
.ok()?
|
||||
.into_iter()
|
||||
.filter(|p| p.root().is_ok_and(|r| path.starts_with(&r)))
|
||||
.filter(|p| p.root().is_ok_and(|r| is_under(path, &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/") {
|
||||
@@ -311,6 +317,14 @@ 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 [
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
a78162e7c4c6ebd3a22c06737fd35948e11b0dd8949571617bc099ba6b4423f0
|
||||
c4c86cef9512ac03193e7d89d39b6ee41665c2d4721a78f9415d406289e9ece1
|
||||
@@ -143,8 +143,9 @@ 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(row as usize + skip);
|
||||
let insert = |before: u32| match record(before) {
|
||||
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) {
|
||||
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.
|
||||
@@ -166,8 +167,8 @@ impl CsvEditor {
|
||||
}
|
||||
}
|
||||
}
|
||||
ViewEvent::InsertRow(before) => insert(*before),
|
||||
ViewEvent::Button(b) if b == "add-row" => insert(u32::MAX),
|
||||
ViewEvent::InsertRow(before) => insert(Some(*before)),
|
||||
ViewEvent::Button(b) if b == "add-row" => insert(None),
|
||||
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 } from "./render";
|
||||
import { ENGINES, type Engine, exportName, problemsOf, renderOptions, withoutLinks } 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 = result.output;
|
||||
canvas.innerHTML = withoutLinks(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 } from "./render";
|
||||
import { ENGINES, exportName, graphvizColor, problemsOf, renderOptions, withoutLinks } from "./render";
|
||||
|
||||
describe("the preview's rendering", () => {
|
||||
it("renders with the theme's colour as a default the graph can override", async () => {
|
||||
@@ -42,4 +42,22 @@ 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="x"");
|
||||
expect(preview.match(/<g id="node\d+"/g)).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -66,3 +66,13 @@ 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 @@
|
||||
6d84abcc58c1894203506fa89855a6eade61f14add0ed72fdd7af9e3cd1298f6
|
||||
0d7731de73514b950d8579762029d98c46136f55bfdb2fa44e256cc9a9955a0f
|
||||
@@ -58,8 +58,16 @@ 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}") }];
|
||||
@@ -82,7 +90,8 @@ pub fn render(comment: Comment, lines: &[String]) -> String {
|
||||
out.push_str(open);
|
||||
out.push('\n');
|
||||
for l in lines {
|
||||
out.push_str(&format!(" * {l}\n"));
|
||||
// `*/` would end the comment early.
|
||||
out.push_str(&format!(" * {}\n", l.replace("*/", "* /")));
|
||||
}
|
||||
out.push_str(close);
|
||||
out.push('\n');
|
||||
@@ -170,10 +179,16 @@ fn project_of(path: &str) -> Option<Project> {
|
||||
host::projects()
|
||||
.ok()?
|
||||
.into_iter()
|
||||
.filter(|p| p.root().is_ok_and(|r| path.starts_with(&r)))
|
||||
.filter(|p| p.root().is_ok_and(|r| is_under(path, &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;
|
||||
|
||||
@@ -305,6 +320,15 @@ 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);
|
||||
@@ -324,6 +348,12 @@ 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);
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
a303522c605ccc9804e67f85326778a380951ce14022a70bc37ba3ae63b76f79
|
||||
959cb2ab750211bd1cc894a4d804acb2dd16e95c1e65b267e7eccbb64cbdd8da
|
||||
@@ -9,11 +9,15 @@ use openidea_plugin::text::{offset, position};
|
||||
use openidea_plugin::{
|
||||
Error, Invocation, Location, Plugin, Position, Project, Range, TextQuery, host, register_plugin,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
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"];
|
||||
@@ -85,27 +89,77 @@ pub struct MapperJava {
|
||||
pub result_ids: Vec<(usize, Named)>,
|
||||
}
|
||||
|
||||
fn range_of(text: &str, start: usize, end: usize) -> Range {
|
||||
Range::new(position(text, start), position(text, end))
|
||||
/// 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))?))
|
||||
}
|
||||
|
||||
pub fn parse_xml(text: &str) -> MapperXml {
|
||||
let mut out = MapperXml::default();
|
||||
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 positions = Positions::new(text);
|
||||
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();
|
||||
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) })
|
||||
// `<` 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) })
|
||||
};
|
||||
if name == "mapper" {
|
||||
out.namespace = named("namespace");
|
||||
@@ -163,7 +217,8 @@ fn skip_string(bytes: &[u8], mut i: usize) -> usize {
|
||||
_ => i += 1,
|
||||
}
|
||||
}
|
||||
i
|
||||
// A `\` as the last byte steps past the end.
|
||||
i.min(bytes.len())
|
||||
}
|
||||
|
||||
/// The index just past the `//…` or `/*…*/` comment opening at `bytes[i]`.
|
||||
@@ -227,7 +282,7 @@ fn scan_declaration(stmt: &str) -> Option<Declaration> {
|
||||
}
|
||||
b'(' => {
|
||||
let before = stmt[..i].trim_end();
|
||||
let start = before.rfind(|c: char| !ident(c)).map_or(0, |p| p + 1);
|
||||
let start = before.char_indices().rev().find(|&(_, c)| !ident(c)).map_or(0, |(p, c)| p + c.len_utf8());
|
||||
if start == before.len() {
|
||||
return None;
|
||||
}
|
||||
@@ -463,11 +518,8 @@ fn annotation_string_arg(args: &str, key: &str) -> Option<(String, usize, usize)
|
||||
depth = depth.saturating_sub(1);
|
||||
i += 1;
|
||||
}
|
||||
_ 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 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 &args[i..j] == key {
|
||||
let mut k = j;
|
||||
while k < bytes.len() && bytes[k].is_ascii_whitespace() {
|
||||
@@ -548,8 +600,9 @@ 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: range_of(text, i + 10 + indent, i + 10 + indent + simple.len()) });
|
||||
Some(Named { name: simple.clone(), range: positions.range(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();
|
||||
@@ -599,22 +652,21 @@ 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: range_of(text, list_base + ps, list_base + pe) })
|
||||
.map(|(name, ps, pe)| Param { name, range: positions.range(list_base + ps, list_base + pe) })
|
||||
.collect();
|
||||
let method = methods.len();
|
||||
methods.push((
|
||||
Named { name: stmt[decl.name.0..decl.name.1].to_owned(), range: range_of(text, s, e) },
|
||||
params,
|
||||
));
|
||||
methods.push((method_name, 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(range_of(text, stmt_start + ss, stmt_start + se));
|
||||
strings.push(positions.range(stmt_start + ss, stmt_start + se));
|
||||
sql_params.extend(ps.into_iter().map(|(n, ms, me)| Named {
|
||||
name: n,
|
||||
range: range_of(text, stmt_start + ms, stmt_start + me),
|
||||
range: positions.range(stmt_start + ms, stmt_start + me),
|
||||
}));
|
||||
}
|
||||
if !strings.is_empty() {
|
||||
@@ -623,10 +675,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: range_of(text, stmt_start + rs, stmt_start + re) },
|
||||
name: Named { name: v, range: positions.range(stmt_start + rs, stmt_start + re) },
|
||||
}));
|
||||
result_ids.extend(defs.into_iter().map(|(v, ds, de)| {
|
||||
(method, Named { name: v, range: range_of(text, stmt_start + ds, stmt_start + de) })
|
||||
(method, Named { name: v, range: positions.range(stmt_start + ds, stmt_start + de) })
|
||||
}));
|
||||
}
|
||||
stmt_start = k + 1;
|
||||
@@ -641,8 +693,13 @@ pub fn parse_java(text: &str) -> MapperJava {
|
||||
|
||||
/// `namespace="com.acme.UserMapper"` in an XML file, either quote.
|
||||
fn namespace_query(namespace: &str) -> TextQuery {
|
||||
let escaped: String =
|
||||
namespace.chars().flat_map(|c| matches!(c, '.' | '$').then_some('\\').into_iter().chain([c])).collect();
|
||||
let mut escaped = String::with_capacity(namespace.len());
|
||||
for c in namespace.chars() {
|
||||
if r"\.+*?()|[]{}^$#&-~".contains(c) {
|
||||
escaped.push('\\');
|
||||
}
|
||||
escaped.push(c);
|
||||
}
|
||||
TextQuery {
|
||||
pattern: format!(r#"namespace\s*=\s*["']{escaped}["']"#),
|
||||
regex: true,
|
||||
@@ -656,14 +713,22 @@ fn project_of(path: &str) -> Option<Project> {
|
||||
host::projects()
|
||||
.ok()?
|
||||
.into_iter()
|
||||
.filter(|p| p.root().is_ok_and(|r| path.starts_with(&r)))
|
||||
.filter(|p| p.root().is_ok_and(|r| is_under(path, &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 {
|
||||
@@ -687,15 +752,20 @@ 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)?)));
|
||||
self.xml_index
|
||||
.entry(root)
|
||||
.or_default()
|
||||
.insert(namespace.to_owned(), (Instant::now(), hit.as_ref().map(|(path, _)| path.clone())));
|
||||
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())));
|
||||
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()?);
|
||||
@@ -722,6 +792,14 @@ 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)
|
||||
}
|
||||
@@ -751,7 +829,7 @@ impl Navigator {
|
||||
}
|
||||
typed
|
||||
};
|
||||
let start = Position::new(at.line, at.character - typed.encode_utf16().count() as u32);
|
||||
let start = replace_start(at, typed)?;
|
||||
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
|
||||
@@ -796,6 +874,7 @@ 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)));
|
||||
@@ -914,7 +993,7 @@ impl Providers for Navigator {
|
||||
&& STATEMENTS.iter().any(|t| line.contains(&format!("<{t}")))
|
||||
{
|
||||
let typed = &line[i + 4..];
|
||||
let start = Position::new(at.line, at.character - typed.encode_utf16().count() as u32);
|
||||
let Some(start) = replace_start(at, typed) else { return Vec::new() };
|
||||
return java
|
||||
.methods
|
||||
.iter()
|
||||
@@ -932,7 +1011,7 @@ impl Providers for Navigator {
|
||||
&& line.contains("<include")
|
||||
{
|
||||
let typed = &line[i + 7..];
|
||||
let start = Position::new(at.line, at.character - typed.encode_utf16().count() as u32);
|
||||
let Some(start) = replace_start(at, typed) else { return Vec::new() };
|
||||
return xml
|
||||
.sql_fragments
|
||||
.iter()
|
||||
@@ -949,12 +1028,12 @@ impl Providers for Navigator {
|
||||
&& !before[i..].contains('}')
|
||||
{
|
||||
let typed = &before[i + 2..];
|
||||
let statement =
|
||||
xml.statements.iter().rev().find(|s| offset(&doc.text, s.range.start).is_some_and(|o| o < cursor));
|
||||
let here = position(&doc.text, cursor);
|
||||
let statement = xml.statements.iter().rev().find(|s| s.range.start < here);
|
||||
let Some((_, params)) = statement.and_then(|s| java.methods.iter().find(|(m, _)| m.name == s.name)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let start = Position::new(at.line, at.character - typed.encode_utf16().count() as u32);
|
||||
let Some(start) = replace_start(at, typed) else { return Vec::new() };
|
||||
return params
|
||||
.iter()
|
||||
.filter(|p| p.name.starts_with(typed))
|
||||
@@ -971,16 +1050,12 @@ 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 {
|
||||
let (Some(start), Some(end)) = (offset(&doc.text, s.start), offset(&doc.text, s.end)) else {
|
||||
continue;
|
||||
};
|
||||
if !(start <= cursor && cursor <= end) {
|
||||
continue;
|
||||
}
|
||||
for s in sql.strings.iter().filter(|s| s.start <= here && here <= s.end) {
|
||||
let Some(start) = offset(&doc.text, s.start) else { continue };
|
||||
let seg = &doc.text[start..cursor];
|
||||
let mut token: Option<usize> = None;
|
||||
for t in ["#{", "${"] {
|
||||
@@ -993,7 +1068,7 @@ fn annotation_param_completion(doc: &Document, at: Position) -> Vec<CompletionIt
|
||||
if typed.contains('}') || !typed.chars().all(|c| ident(c) || c == '.') {
|
||||
continue;
|
||||
}
|
||||
let start = Position::new(at.line, at.character - typed.encode_utf16().count() as u32);
|
||||
let Some(start) = replace_start(at, typed) else { continue };
|
||||
return params
|
||||
.iter()
|
||||
.filter(|p| p.name.starts_with(typed))
|
||||
@@ -1477,4 +1552,89 @@ 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);
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
b7b4e6be26ff08cc0e7fa791cb956acd9afa09905720d2c0c93acdae5638a35e
|
||||
d5ec377869ded4ec0bf533d07d51610aec7a6813d43ead98a261b3f54e7d0655
|
||||
@@ -94,7 +94,23 @@ pub fn evaluate(run: &Run) -> Value {
|
||||
}
|
||||
}
|
||||
let replaced = run.replace.as_ref().map(|r| {
|
||||
let mut out = re.replace_all(&run.text, r.as_str()).into_owned();
|
||||
// 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..]);
|
||||
}
|
||||
if out.len() > MAX_REPLACED {
|
||||
let mut cut = MAX_REPLACED;
|
||||
while !out.is_char_boundary(cut) {
|
||||
@@ -241,6 +257,13 @@ 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]
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
4f585c3a1a3ae01a89811e05d27fc39121dd8e74210c1281d04ff31556d393b5
|
||||
ebbd24e08c19953895464567cbaf1335b9847e98e8a8da8a4e0b8c2886c1abd6
|
||||
@@ -347,6 +347,8 @@ 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,
|
||||
}
|
||||
|
||||
@@ -366,7 +368,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, body: Body::File }];
|
||||
let mut frames = vec![Frame { symbol: None, parent: 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;
|
||||
@@ -375,7 +377,9 @@ 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);
|
||||
let parent = frames.iter().rev().find_map(|f| f.symbol);
|
||||
// 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 add = |symbols: &mut Vec<Symbol>, name_at: usize, kind: SymbolKind, detail: Option<&str>| {
|
||||
let name = code[name_at];
|
||||
symbols.push(Symbol {
|
||||
@@ -395,7 +399,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, body: inner });
|
||||
frames.push(Frame { symbol, parent: symbol.or(parent), body: inner });
|
||||
stmt_start = i + 1;
|
||||
}
|
||||
(Kind::Punct, "}") => {
|
||||
@@ -512,7 +516,8 @@ fn outline(text: &str) -> Vec<Symbol> {
|
||||
|
||||
fn folding(text: &str) -> Vec<Fold> {
|
||||
let lexemes = lex(text);
|
||||
let line_of = |offset: usize| text[..offset].bytes().filter(|&b| b == b'\n').count() as u32;
|
||||
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 skip: Vec<Range<usize>> = lexemes
|
||||
.iter()
|
||||
.filter(|l| matches!(l.kind, Kind::Str | Kind::Comment | Kind::DocComment))
|
||||
@@ -686,4 +691,19 @@ 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));
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
81637f9a265e6bcb9839b1019b0d58b43f80535fb1fca044716904aef0c12724
|
||||
997e2ac7520a561f22d12ad7013ed576262424ffa8acd69fab219f68d00c92f1
|
||||
@@ -230,7 +230,8 @@ 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) } else { format!("{next:0width$}") };
|
||||
let text =
|
||||
if next < 0 { format!("-{:0width$}", next.unsigned_abs()) } else { format!("{next:0width$}") };
|
||||
out.push_str(&text);
|
||||
}
|
||||
Err(_) => out.push_str(&digits),
|
||||
@@ -338,7 +339,8 @@ fn html_unescape(s: &str) -> String {
|
||||
while let Some(i) = rest.find('&') {
|
||||
out.push_str(&rest[..i]);
|
||||
rest = &rest[i..];
|
||||
let Some(end) = rest[..rest.len().min(12)].find(';') else {
|
||||
// `;` is ASCII, so where it is found is a char boundary.
|
||||
let Some(end) = rest.bytes().take(12).position(|b| b == b';') else {
|
||||
out.push('&');
|
||||
rest = &rest[1..];
|
||||
continue;
|
||||
@@ -416,6 +418,7 @@ mod tests {
|
||||
assert_eq!(base64_encode(b"Man"), "TWFu");
|
||||
assert_eq!(url_encode("a b&c"), "a%20b%26c");
|
||||
assert_eq!(html_unescape("<a> AB &bogus; &"), "<a> AB &bogus; &");
|
||||
assert_eq!(html_unescape("R&D部门的预算 &aaaaaaaaaa€"), "R&D部门的预算 &aaaaaaaaaa€");
|
||||
assert_eq!(json_escape("a\"b\n"), "a\\\"b\\n");
|
||||
}
|
||||
|
||||
@@ -426,6 +429,8 @@ 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]
|
||||
|
||||
@@ -95,9 +95,12 @@ 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. */
|
||||
export const TEXT_WHEN = "editorTextFocus || textEditorActive && popupOpen";
|
||||
const EDITOR_WHEN = "textEditorActive && (editorFocus || popupOpen || !toolWindowFocus)";
|
||||
/**
|
||||
* 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)";
|
||||
const TABS_WHEN = "editorTabsOpen && !toolWindowFocus";
|
||||
|
||||
function activeSlot(): EditorSlot | null {
|
||||
@@ -666,7 +669,7 @@ export function registerEditorActions(): () => void {
|
||||
id,
|
||||
title,
|
||||
category: "Edit",
|
||||
when: `${TEXT_WHEN} || findBarFocus || findBarOpen && popupOpen`,
|
||||
when: `${TEXT_WHEN} || findBarFocus || findBarOpen && popupOpen && !toolWindowFocus`,
|
||||
run: ({ arg, ctx }) => {
|
||||
const view = typeof arg === "string" || ctx.findBarFocus ? findPanelTarget(arg)?.view() : null;
|
||||
if (view) {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 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 };
|
||||
}),
|
||||
);
|
||||
@@ -5,7 +5,7 @@ import { indentUnit } from "@codemirror/language";
|
||||
import { codeLenses } from "@/editor/api";
|
||||
import type { EditorSettings } from "@/bindings";
|
||||
import { codeLensExtension } from "./codeLens";
|
||||
import { guideVars, indentGuides, rainbowIndentGuides } from "./indentGuides";
|
||||
import { braceGuide, caretGuide, 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(2.5px + 4ch) 0, calc(2.5px + 8ch) 0",
|
||||
positions: "calc(0.5px + 4ch) 0, calc(0.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(2.5px + 4ch) 0");
|
||||
expect(line(v, 2).style.getPropertyValue("--openidea-guide-positions")).toBe("calc(0.5px + 4ch) 0");
|
||||
expect(line(v, 3).style.getPropertyValue("--openidea-guide-positions")).toBe(
|
||||
"calc(2.5px + 4ch) 0, calc(2.5px + 8ch) 0",
|
||||
"calc(0.5px + 4ch) 0, calc(0.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(2.5px + 4ch) 0, calc(2.5px + 8ch) 0",
|
||||
"calc(0.5px + 4ch) 0, calc(0.5px + 8ch) 0",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -77,18 +77,66 @@ 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(2.5px + 4ch) 0");
|
||||
expect(fnLens!.style.getPropertyValue("--openidea-guide-positions")).toBe("calc(0.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(2.5px + 4ch) 0, calc(2.5px + 8ch) 0",
|
||||
positions: "calc(0.5px + 4ch) 0, calc(0.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})`));
|
||||
@@ -108,7 +156,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(2.5px + 4ch) 0, calc(2.5px + 8ch) 0");
|
||||
expect(positions(line(v, 4))).toBe("calc(0.5px + 4ch) 0, calc(0.5px + 8ch) 0");
|
||||
expect(images(line(v, 6))).toBe("var(--openidea-guide-1)");
|
||||
});
|
||||
|
||||
@@ -122,7 +170,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(2.5px + 8ch) 0");
|
||||
expect(positions(deepest(tabs)).split(", ")[1]).toBe("calc(0.5px + 8ch) 0");
|
||||
});
|
||||
|
||||
it("does not count a partial indent unit as a level", () => {
|
||||
@@ -163,7 +211,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(2.5px + 4ch) 0, calc(2.5px + 8ch) 0");
|
||||
expect(positions(lens())).toBe("calc(0.5px + 4ch) 0, calc(0.5px + 8ch) 0");
|
||||
view.dispatch({ effects: guides.reconfigure(indentGuides) });
|
||||
expect(images(lens())).toBe("var(--openidea-guide), var(--openidea-guide)");
|
||||
});
|
||||
|
||||
@@ -6,13 +6,17 @@
|
||||
* 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-*`.
|
||||
* guides each depth gets its own image, coloured from `--syn-rainbow-*`. The
|
||||
* guide the caret is on takes the selected colour (`caretGuide`).
|
||||
*/
|
||||
import { Facet, RangeSetBuilder, type EditorState, type Extension } from "@codemirror/state";
|
||||
import { countColumn, Facet, RangeSetBuilder, type EditorState, type Extension } from "@codemirror/state";
|
||||
import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from "@codemirror/view";
|
||||
import { getIndentUnit } from "@codemirror/language";
|
||||
import { getIndentUnit, matchBrackets } 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;
|
||||
|
||||
@@ -22,26 +26,41 @@ 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. `.cm-openidea-guides` lines and code lens rows turn them into
|
||||
* backgrounds (theme.ts).
|
||||
* 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).
|
||||
*/
|
||||
export function guideVars(levels: number, step: number, rainbow = false): { images: string; positions: string } | null {
|
||||
export function guideVars(
|
||||
levels: number,
|
||||
step: number,
|
||||
rainbow = false,
|
||||
active = 0,
|
||||
brace = 0,
|
||||
): { 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(rainbow ? `var(--openidea-guide-${((level - 1) % GUIDE_COLORS) + 1})` : "var(--openidea-guide)");
|
||||
positions.push(`calc(${LINE_INSET}px + ${level * step}ch) 0`);
|
||||
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`);
|
||||
}
|
||||
return { images: images.join(", "), positions: positions.join(", ") };
|
||||
}
|
||||
|
||||
const cache = new Map<string, Decoration | null>();
|
||||
function guideDeco(levels: number, step: number, rainbow: boolean) {
|
||||
const key = `${levels}:${step}:${rainbow ? 1 : 0}`;
|
||||
function guideDeco(levels: number, step: number, rainbow: boolean, active: number, brace: number) {
|
||||
const key = `${levels}:${step}:${rainbow ? 1 : 0}:${active}:${brace}`;
|
||||
let d = cache.get(key);
|
||||
if (d === undefined) {
|
||||
const vars = guideVars(levels, step, rainbow);
|
||||
const vars = guideVars(levels, step, rainbow, active, brace);
|
||||
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);
|
||||
@@ -71,10 +90,77 @@ 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;
|
||||
@@ -88,7 +174,7 @@ function build(view: EditorView): DecorationSet {
|
||||
} else {
|
||||
levels = Math.floor(cols / step);
|
||||
}
|
||||
const deco = guideDeco(levels, step, rainbow);
|
||||
const deco = guideDeco(levels, step, rainbow, on(caret, line.number), on(brace, line.number));
|
||||
if (deco) b.add(line.from, line.from, deco);
|
||||
}
|
||||
pos = line.to + 1;
|
||||
@@ -107,6 +193,7 @@ 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)
|
||||
|
||||
@@ -47,6 +47,7 @@ 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";
|
||||
@@ -376,6 +377,7 @@ export function baseExtensions(o: SetupOptions): Extension {
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
multiCaretMouse,
|
||||
columnModeExtension,
|
||||
bracketBlockSelect,
|
||||
visibleGoalColumns,
|
||||
occurrenceSearch,
|
||||
overwriteField,
|
||||
|
||||
@@ -137,6 +137,13 @@ 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) => {
|
||||
|
||||
@@ -9,9 +9,10 @@ import { registerActions } from "@/platform/actions";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { useRemote } from "@/platform/remote";
|
||||
import { hideContextMenu, resolveMenuItems, useContextMenuStore } from "@/ui";
|
||||
import { getContext, setContext } from "@/platform/context";
|
||||
import { evaluateWhen, 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;
|
||||
|
||||
@@ -464,3 +465,21 @@ 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);
|
||||
});
|
||||
});
|
||||
@@ -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 || popupOpen || !toolWindowFocus)";
|
||||
export const EDITOR_WHEN = "textEditorActive && (editorFocus || !toolWindowFocus)";
|
||||
|
||||
/**
|
||||
* Languages whose servers offer Generate (constructors, accessors, overrides…).
|
||||
|
||||
+120
-22
@@ -10,7 +10,16 @@ import {
|
||||
* click), the multiple-targets chooser, and document highlights (occurrences
|
||||
* of the symbol at the caret, plus the sticky find.highlightUsages variant).
|
||||
*/
|
||||
import { EditorSelection, Prec, StateEffect, StateField, type Extension, type Text } from "@codemirror/state";
|
||||
import {
|
||||
countColumn,
|
||||
EditorSelection,
|
||||
Prec,
|
||||
StateEffect,
|
||||
StateField,
|
||||
type Extension,
|
||||
type Text,
|
||||
} from "@codemirror/state";
|
||||
import { LINE_INSET } from "@/editor/core/cm/lineInset";
|
||||
import {
|
||||
Decoration,
|
||||
EditorView,
|
||||
@@ -54,6 +63,7 @@ import {
|
||||
SYNTAX_NOTE,
|
||||
declarationFallback,
|
||||
indexDefinitions,
|
||||
isDeclarationName,
|
||||
isSelfTarget,
|
||||
localHighlights,
|
||||
type DeclarationTarget,
|
||||
@@ -351,6 +361,15 @@ 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,
|
||||
@@ -365,7 +384,10 @@ async function gotoResolved(
|
||||
const before = view.state.doc;
|
||||
const plugin = await contributedNavigation(view, offset).catch(() => null);
|
||||
if (view.state.doc !== before || superseded()) return;
|
||||
if (plugin) {
|
||||
// ⌘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.targets.length === 1) await navigateTo(plugin.targets[0]!);
|
||||
else {
|
||||
const items = await locationItems(plugin.targets);
|
||||
@@ -1031,6 +1053,8 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1038,31 +1062,94 @@ 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: "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" }),
|
||||
text: Decoration.mark({ class: KIND_CLASS.text }),
|
||||
read: Decoration.mark({ class: KIND_CLASS.read }),
|
||||
write: Decoration.mark({ class: KIND_CLASS.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, sticky: false }),
|
||||
create: () => ({ deco: Decoration.none, ranges: [], sticky: false }),
|
||||
update(value, tr) {
|
||||
for (const e of tr.effects)
|
||||
if (e.is(setHighlights))
|
||||
if (e.is(setHighlights)) {
|
||||
const items = e.value.items.filter((i) => i.from < i.to);
|
||||
return {
|
||||
deco: Decoration.set(
|
||||
e.value.items.filter((i) => i.from < i.to).map((i) => marks[i.kind].range(i.from, i.to)),
|
||||
true,
|
||||
),
|
||||
deco: highlightDeco(tr.state.doc, tr.state.tabSize, items),
|
||||
ranges: items.map(({ from, to }) => ({ from, to })),
|
||||
sticky: e.value.sticky,
|
||||
};
|
||||
}
|
||||
// Kept (mapped) until recomputed; ⌘⇧F7 highlights survive edits until Escape.
|
||||
if (tr.docChanged) return { deco: value.deco.map(tr.changes), sticky: value.sticky };
|
||||
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,
|
||||
};
|
||||
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,
|
||||
@@ -1092,6 +1179,7 @@ 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);
|
||||
@@ -1129,7 +1217,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).deco.size) apply([]);
|
||||
if (view.state.field(highlightField).ranges.length) apply([]);
|
||||
}
|
||||
|
||||
async function syntaxHighlights(view: EditorView, doc: EditorDocContext, head: number) {
|
||||
@@ -1148,10 +1236,7 @@ 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;
|
||||
let onUsage = false;
|
||||
st?.deco.between(head, head, (from, to) => {
|
||||
if (from <= head && head <= to) onUsage = true;
|
||||
});
|
||||
const onUsage = !!st?.ranges.some(({ from, to }) => from <= head && head <= to);
|
||||
if (st?.sticky && onUsage) {
|
||||
clearHighlights(view);
|
||||
return;
|
||||
@@ -1165,8 +1250,7 @@ export function highlightUsages(view: EditorView) {
|
||||
*/
|
||||
export function gotoHighlightedUsage(view: EditorView, dir: 1 | -1): boolean {
|
||||
const st = view.state.field(highlightField, false);
|
||||
const ranges: { from: number; to: number }[] = [];
|
||||
st?.deco.between(0, view.state.doc.length, (from, to) => void ranges.push({ from, to }));
|
||||
const ranges = [...(st?.ranges ?? [])].sort((a, b) => a.from - b.from);
|
||||
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;
|
||||
@@ -1178,7 +1262,8 @@ export function gotoHighlightedUsage(view: EditorView, dir: 1 | -1): boolean {
|
||||
|
||||
const highlightPlugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
private request = debounce(() => void fetchHighlights(this.view, false), 300);
|
||||
// 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);
|
||||
constructor(private view: EditorView) {}
|
||||
update(u: ViewUpdate) {
|
||||
// Recomputed after caret moves and, like IDEA's daemon, after edits.
|
||||
@@ -1190,7 +1275,7 @@ const highlightPlugin = ViewPlugin.fromClass(
|
||||
}
|
||||
const clear = () => {
|
||||
this.request.cancel();
|
||||
if (st.deco.size)
|
||||
if (st.ranges.length)
|
||||
queueMicrotask(() => this.view.dispatch({ effects: setHighlights.of({ items: [], sticky: false }) }));
|
||||
};
|
||||
// Code Editing › "Usages of element at caret".
|
||||
@@ -1198,7 +1283,20 @@ const highlightPlugin = ViewPlugin.fromClass(
|
||||
clear();
|
||||
return;
|
||||
}
|
||||
this.request();
|
||||
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();
|
||||
}
|
||||
destroy() {
|
||||
this.request.cancel();
|
||||
|
||||
@@ -45,6 +45,7 @@ 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");
|
||||
@@ -416,6 +417,48 @@ 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")]));
|
||||
|
||||
@@ -63,6 +63,14 @@ 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) => {
|
||||
|
||||
+11
-4
@@ -288,11 +288,18 @@ export const lspTheme = EditorView.theme({
|
||||
textUnderlineOffset: "2px",
|
||||
cursor: "pointer",
|
||||
},
|
||||
".cm-lsp-occurrence": {
|
||||
backgroundColor: "var(--bg-identifier-under-caret, color-mix(in srgb, var(--accent) 14%, transparent))",
|
||||
".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-write": {
|
||||
backgroundColor: "var(--bg-identifier-under-caret-write, color-mix(in srgb, var(--danger) 12%, 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-nav-info": { padding: "4px 8px" },
|
||||
// Chooser highlight (tempHighlight.ts): the expression / occurrences a choice would take.
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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 || popupOpen || !toolWindowFocus)";
|
||||
const EDITOR_WHEN = "textEditorActive && (editorFocus || !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> {
|
||||
|
||||
@@ -26,7 +26,7 @@ import { ServerStatus } from "./ServerStatus";
|
||||
import { reportProblems } from "./problemsReport";
|
||||
import { installWorkspaceEditUndo } from "./workspaceUndo";
|
||||
|
||||
const EDITOR_WHEN = "textEditorActive && (editorFocus || popupOpen || !toolWindowFocus)";
|
||||
const EDITOR_WHEN = "textEditorActive && (editorFocus || !toolWindowFocus)";
|
||||
|
||||
function useErrorBadge(): number | null {
|
||||
return useProblems((s) => {
|
||||
|
||||
@@ -5,6 +5,9 @@ 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 }) => ({
|
||||
@@ -108,6 +111,61 @@ 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({
|
||||
|
||||
@@ -143,12 +143,12 @@ export function DatabaseView() {
|
||||
useEffect(() => {
|
||||
if (!st.loaded) void loadSources();
|
||||
}, [st.loaded]);
|
||||
// Go to Declaration from a console: the rows exist once the expanded nodes render.
|
||||
// Go to Declaration from a console, Select Opened File: the rows exist once the expanded nodes render.
|
||||
useEffect(() => {
|
||||
if (!st.treeReveal) return;
|
||||
const key = st.treeReveal.key;
|
||||
const path = st.treeReveal.path;
|
||||
const frame = requestAnimationFrame(() => {
|
||||
tree.current?.reveal(key);
|
||||
path.toReversed().some((key) => tree.current?.reveal(key));
|
||||
tree.current?.focus();
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
@@ -683,10 +683,11 @@ function DatabaseToolbar() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Header controls (shown on hover, like IDEA's): Expand All, Collapse All. */
|
||||
/** Header controls (shown on hover, like IDEA's): Select Opened File, 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} />
|
||||
</>
|
||||
|
||||
@@ -25,9 +25,10 @@ import {
|
||||
type RequestId,
|
||||
type SqlStatement,
|
||||
} from "@/platform/ipc";
|
||||
import { editorsFor } from "@/editor/api";
|
||||
import { activeEditor, editorsFor } from "@/editor/api";
|
||||
import {
|
||||
editorService,
|
||||
maybeEditorService,
|
||||
notifications,
|
||||
showServiceView,
|
||||
toolWindowService,
|
||||
@@ -44,8 +45,11 @@ 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";
|
||||
@@ -180,7 +184,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: { key: string; seq: number } | null;
|
||||
treeReveal: { path: string[]; seq: number } | null;
|
||||
/** Last value entered per query parameter name. */
|
||||
paramValues: Record<string, string>;
|
||||
/** Data sources in manual-commit mode. */
|
||||
@@ -604,13 +608,14 @@ 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.
|
||||
* loading and expanding the nodes that lead to it. Without `kind` the loaded
|
||||
* tables tell whether it is a table or a view.
|
||||
*/
|
||||
export async function revealInTree(
|
||||
id: string,
|
||||
schema: string,
|
||||
table: string,
|
||||
kind: DbTableKind,
|
||||
kind: DbTableKind | undefined,
|
||||
column?: string,
|
||||
): Promise<void> {
|
||||
const loaded = (l: Loaded<unknown> | undefined) => l?.status === "ok";
|
||||
@@ -632,20 +637,64 @@ 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}`;
|
||||
const open = [
|
||||
sourceId,
|
||||
...databaseNode,
|
||||
`${id}/${schema}`,
|
||||
folder,
|
||||
...(column ? [tableKey, `${tableKey}/columns`] : []),
|
||||
];
|
||||
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) {
|
||||
useDatabase.setState((s) => ({
|
||||
treeExpanded: new Set([...s.treeExpanded, ...open]),
|
||||
treeReveal: { key: column ? `${tableKey}/columns/${column}` : tableKey, seq: (s.treeReveal?.seq ?? 0) + 1 },
|
||||
treeReveal: { path: [...open, key], seq: (s.treeReveal?.seq ?? 0) + 1 },
|
||||
}));
|
||||
toolWindowService().show(DB_TOOL_WINDOW, true);
|
||||
// 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);
|
||||
}
|
||||
|
||||
const completionLoads = new Map<string, Promise<DbCompletion | null>>();
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
readableDdl,
|
||||
refresh,
|
||||
revealInTree,
|
||||
revealOpened,
|
||||
runSql,
|
||||
sourceById,
|
||||
tablesKey,
|
||||
@@ -485,6 +486,15 @@ 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",
|
||||
|
||||
@@ -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 || popupOpen || !toolWindowFocus)";
|
||||
const EDITOR_WHEN = "textEditorActive && !editorReadOnly && (editorFocus || !toolWindowFocus)";
|
||||
const MARKUP_PATH = /\.(html?|xhtml|shtml|vue|svelte|xml)$/i;
|
||||
|
||||
async function updateTagWithEmmet() {
|
||||
|
||||
@@ -5,6 +5,12 @@ 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) => ({
|
||||
@@ -50,6 +56,8 @@ 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: () => {} });
|
||||
@@ -110,3 +118,66 @@ 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();
|
||||
});
|
||||
@@ -342,6 +342,15 @@ 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 };
|
||||
|
||||
@@ -269,8 +269,15 @@ const caret = "var(--caret, var(--fg-strong))";
|
||||
|
||||
/** Block caret and command line in theme tokens (the package's defaults are fixed colours). */
|
||||
const vimTheme = EditorView.theme({
|
||||
// A solid block with the character under it in the editor's background colour, as IdeaVim's
|
||||
// block caret: a translucent fill over light text on a dark theme read as a brighter letter.
|
||||
"& .cm-vimCursorLayer .cm-fat-cursor": {
|
||||
background: `color-mix(in srgb, ${caret} 60%, transparent)`,
|
||||
background: caret,
|
||||
},
|
||||
// The package sets the letter's own colour inline; a partial block (replace, operator-pending)
|
||||
// draws no letter.
|
||||
"&.cm-focused .cm-vimCursorLayer .cm-fat-cursor:not([style*='color: transparent'])": {
|
||||
color: "var(--bg-editor, var(--bg-island, var(--bg-app))) !important",
|
||||
},
|
||||
"&:not(.cm-focused) .cm-vimCursorLayer .cm-fat-cursor": {
|
||||
background: "none",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { executeAction, executeActionOrThrow, registerAction, useRunningActions } from "./actions";
|
||||
import {
|
||||
executeAction,
|
||||
executeActionOrThrow,
|
||||
getAction,
|
||||
registerAction,
|
||||
registerActions,
|
||||
useActions,
|
||||
useRunningActions,
|
||||
} from "./actions";
|
||||
|
||||
const running = (id: string) => useRunningActions.getState().running.get(id) ?? 0;
|
||||
|
||||
@@ -91,3 +99,22 @@ 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();
|
||||
});
|
||||
+19
-4
@@ -116,10 +116,9 @@ useI18n.subscribe((s, prev) => {
|
||||
useActions.setState({ actions: new Map([...sources].map(([id, a]) => [id, localize(a)])) });
|
||||
});
|
||||
|
||||
/** Registers an action; returns a disposer. Re-registering an id replaces it. */
|
||||
export function registerAction(action: Action): () => void {
|
||||
/** Adds `action` to the written sources; returns a disposer that removes it. */
|
||||
function addSource(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);
|
||||
@@ -131,8 +130,24 @@ export function registerAction(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(registerAction);
|
||||
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 };
|
||||
});
|
||||
}
|
||||
return () => disposers.forEach((d) => d());
|
||||
}
|
||||
|
||||
|
||||
@@ -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 || popupOpen || !toolWindowFocus)";
|
||||
const EDITOR = "textEditorActive && (editorFocus || !toolWindowFocus)";
|
||||
const disposers = [
|
||||
registerAction({
|
||||
id: "project.rename",
|
||||
|
||||
@@ -404,6 +404,7 @@ 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.
|
||||
|
||||
@@ -402,6 +402,7 @@ 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.
|
||||
|
||||
@@ -392,6 +392,7 @@ 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.
|
||||
|
||||
@@ -3,6 +3,8 @@ 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;
|
||||
@@ -251,6 +253,30 @@ 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");
|
||||
|
||||
+17
-6
@@ -31,6 +31,7 @@ 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";
|
||||
@@ -61,8 +62,8 @@ export interface TreeOccurrence {
|
||||
|
||||
export interface TreeHandle {
|
||||
focus(): void;
|
||||
/** Selects `key` (must be visible, i.e. ancestors expanded) and scrolls to it. */
|
||||
reveal(key: string): void;
|
||||
/** Selects `key` (must be visible, i.e. ancestors expanded) and scrolls to it; false when no row has `key`. */
|
||||
reveal(key: string): boolean;
|
||||
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;
|
||||
@@ -367,10 +368,18 @@ export function Tree<T>(props: TreeProps<T>) {
|
||||
pendingScrollToLead.current = null;
|
||||
}, [lead, indexOfKey, virtualizer]);
|
||||
|
||||
// Speed search owns Escape while active.
|
||||
// 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).
|
||||
useEffect(() => {
|
||||
if (!query) return;
|
||||
return acquirePopup();
|
||||
const release = acquirePopup();
|
||||
const unclaim = registerKeyClaim(
|
||||
(e) => e.key === "Backspace" && e.target instanceof Node && !!scrollRef.current?.contains(e.target),
|
||||
);
|
||||
return () => {
|
||||
release();
|
||||
unclaim();
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
const setExpanded = (row: Row<T>, open: boolean) => {
|
||||
@@ -420,10 +429,11 @@ export function Tree<T>(props: TreeProps<T>) {
|
||||
focus: () => scrollRef.current?.focus(),
|
||||
reveal: (key: string) => {
|
||||
const i = indexOfKey.get(key);
|
||||
if (i === undefined) return;
|
||||
if (i === undefined) return false;
|
||||
setAnchor(key);
|
||||
commitSelection(new Set([key]), key);
|
||||
virtualizer.scrollToIndex(i, { align: "center" });
|
||||
return true;
|
||||
},
|
||||
leadKey: () => lead,
|
||||
moveSelection: (dir: 1 | -1) => {
|
||||
@@ -523,7 +533,8 @@ export function Tree<T>(props: TreeProps<T>) {
|
||||
break;
|
||||
case "Backspace":
|
||||
if (!query) return;
|
||||
setQuery(query.slice(0, -1));
|
||||
// ⌘⌫ 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));
|
||||
break;
|
||||
case "ArrowDown":
|
||||
case "ArrowUp": {
|
||||
|
||||
@@ -179,6 +179,7 @@ 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",
|
||||
@@ -1107,6 +1108,7 @@ 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",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g stroke-linejoin="round"><path d="M14 4C14 2.89543 13.1046 2 12 2H4C2.89543 2 2 2.89543 2 4V12C2 13.1046 2.89543 14 4 14H12C13.1046 14 14 13.1046 14 12V4Z" fill="#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>
|
||||
|
After Width: | Height: | Size: 506 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g stroke-linejoin="round"><path d="M14 4C14 2.89543 13.1046 2 12 2H4C2.89543 2 2 2.89543 2 4V12C2 13.1046 2.89543 14 4 14H12C13.1046 14 14 13.1046 14 12V4Z" fill="#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>
|
||||
|
After Width: | Height: | Size: 506 B |
Reference in new issue
Block a user