Compare commits

..
Author SHA1 Message Date
l0ng-ai 93cccb9bb5 fix(navigation): show usages on a declaration a plugin's index links elsewhere
A plugin's document index can link a declaration's name elsewhere: MyBatis Duo links a mapper method to its XML statement. Go to Declaration followed such a link at once, in the editor and in the host, so ⌘B on the method jumped to the XML instead of showing its usages as it does on any declaration (and did for plugins that answer through a definition provider). When the syntax tree puts the caret on a declaration's name, the link is no longer followed directly: the language server's answer comes along and the usages show, the plugin's target among them. Elsewhere plugin links are followed as before, without waiting for the language server.

Fixes #3207
2026-11-14 02:43:03 +08:00
l0ng-ai 40494f2920 fix(lsp): accept file URIs a language server leaves unencoded (#3234)
jdtls on Windows sends a project path as is in its URIs (file:///H:/项目/…). lsp-types rejects such a URI, so the whole response failed: Find Usages, Go to Declaration, references, implementations and call hierarchy fell back to a text search for any project under a non-ASCII folder. Every message from a language server now has the characters URIs don't allow (non-ASCII, spaces, brackets…) percent-encoded in its file: URIs, string values and object keys (a workspace edit's changes) alike, before it is read.

Fixes #3205
2026-11-14 02:29:59 +08:00
l0ng-ai c016007a2d fix(ui): give .slint files their own icon (#3230)
Slint files showed the generic file icon, and `icons.test.tsx` (every detected language file has a specific icon) failed on main.
2026-11-14 02:18:56 +08:00
Mr0Bread 69ce1ab13d fix(editor): keep editor actions off a tool window's speed search (#3227)
Text and editor actions also run while a popup is open, so Find Action can run them on the editor. A tool window's speed search opens a popup layer too while focus stays in the tool window, so ⌘⌫ in the Project view's speed search ran Delete Line on the editor behind it. The popup branch of TEXT_WHEN, the Find occurrence actions and EDITOR_WHEN (navigation, LSP, code insight and template actions) now also require that no tool window has focus; Find Action and menus render outside tool windows and keep working.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-14 02:15:19 +08:00
Mr0Bread fcc8583f36 fix(ui): ⌘⌫ erases a tree's speed search query (#3226)
While a tree's speed search has a query, the keymap dispatcher ran ⌫ shortcuts before the tree saw the key: in the Database view ⌘⌫ opened Drop or Remove Data Source. The shared Tree now claims ⌫ with any modifier while a query is active and edits the query like a text field: ⌘⌫ erases it, ⌥⌫ / Ctrl+⌫ erase the last word. Without a query the view's shortcuts run as before.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-14 02:15:07 +08:00
acaoandamphetamlne b368f21eb6 perf(search): prune explicitly excluded subtrees (#3221)
Search and scope enumeration walked into directories that an exclusion mask covers entirely and dropped their files one by one. A directory is now skipped before its children are read when an exclusion with a trailing `/**` (e.g. `**/node_modules/**`) covers it; every file below such a directory is already excluded by the same mask, so results are unchanged. Other masks keep filtering individual files.

Co-authored-by: amphetamlne <[email protected]>
2026-11-14 02:13:05 +08:00
l0ng-ai 929866bbc5 fix(tools): honour Cancel until a downloaded tool replaces the installed one (#3228)
After unpacking, the IDE removed the previous install, moved the new one into place and cleared the quarantine flag without checking for Cancel or reporting progress, so the install log kept saying Extracting… and Cancel did nothing; on a slower disk, deleting a large tool such as kotlin-lsp (about 1 GB) took seconds. Cancel is now checked right before the swap, as JDK downloads already do; the quarantine flag is cleared before it, and the previous install is moved aside and deleted in the background.

Refs #2925
2026-11-14 02:12:33 +08:00
Mr0Bread 3c781296d6 feat(database): Select Opened File in the Database tool window header (#3223)
Adds Select Opened File (db.selectOpened) to the Database tool window header and Find Action. For a table editor it selects the table or view, loading and expanding the path to it; for a SQL console or SQL file it selects the schema the console runs in (or its database / data source when the schema isn't loaded) without connecting.

The reveal path shared with Go to Declaration now selects the nearest shown ancestor when the target row isn't shown (filtered out or dropped), and keeps focus in the tree instead of the filter field.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-14 01:55:52 +08:00
l0ng-ai 4aed449dc0 fix(editor): keep the usage highlight class names out of the text catalog check (#3225)
The usage highlight's class for plain occurrences sat under a `text` key, which the text catalog check reads as UI text, so `textCatalog.test.ts` failed on main. The class names are now built from one constant.
2026-11-14 01:51:25 +08:00
Akbar Husain 55431f3965 perf(startup): batch action registration (#3222)
registerActions() registered actions one at a time, and each registerAction() cloned the whole action map, so registering a feature's actions at start-up was quadratic. A batch now adds every source and updates the store once; replacement, disposal and localisation are unchanged.

Co-authored-by: akbar1214 <[email protected]>
2026-11-14 01:50:31 +08:00
l0ng-ai d4b14a36c7 test(plugins): focus an absolute path in the git-toolbox schedule test (#3224)
Editor focus events are routed by the focused file's project, so a relative path is dropped before it reaches the plugin and the test never woke Git Toolbox. Editors always report absolute paths; the test now does too.
2026-11-14 01:46:28 +08:00
rohitkumardandaandrohitdanda d2c565b19d fix(plugins): harden the official plugins against crafted input (#3219)
Several official plugins trusted crafted input: paths outside the project were accepted where only project files were meant, some loops and replacements had no upper bound, and CSV Add Row wrapped its row index on wasm32.

- ai-completion: only treat a real OpenAI host as OpenAI
- checkstyle, license-header, mybatis: keep resolved paths under the project root
- license-header: keep the generated header on one line where one is expected
- csv-editor: Add Row appends on wasm32 instead of overwriting the header
- regex-tester: cap the replaced output
- solidity: linearisation stops on cyclic inheritance
- mybatis: bound the mappers and statements it indexes
- string-tools, graphviz: guard the remaining crafted-input cases
- Rebuilt the changed components from source.

Co-authored-by: rohitdanda <[email protected]>
2026-11-14 01:42:03 +08:00
zhangdagouandPLZY 01a60ef164 fix(editor): make usage highlighting follow the caret at once and paint multi-line usages as a block (#3220)
Usage highlighting at the caret waited a fixed 300 ms after every caret move, so clicking around lagged. A click now asks at once and keyboard moves wait 100 ms; with the document unchanged, a caret landing on usages found recently shows them immediately while they are refreshed. A highlight over several lines (a return statement's exit point) is painted as a block: from its start to the right edge, whole lines between, and the last line up to its end.

Co-authored-by: PLZY <[email protected]>
2026-11-14 01:33:42 +08:00
zhangdagouandPLZY b0b768d25e feat(editor): highlight the caret's indent guide and select a block by double-clicking its brace (#3213)
The indent guide in the caret's column, within the block the caret line belongs to, takes the selected indent guide colour, and guides sit slightly left of their column so a caret there is to the right of the line. With the caret at a brace of a multi-line block, that block's guide takes the matched-brace colour. A double-click that puts the caret next to { or } (also past the end of a line ending in {) selects the block, both braces included; a double-click on a word still selects the word.

Co-authored-by: PLZY <[email protected]>
2026-11-14 01:30:56 +08:00
l0ng-ai 3240bfc423 fix(vim): draw the Normal-mode block cursor solid, with the character in the background colour (#3218)
The focused block cursor was a 60% caret-coloured fill. On dark themes the caret is light
grey, so it read as a slightly brighter character and the cursor was easy to lose. It is now a
solid block in the caret colour with the character under it in the editor background colour,
as IdeaVim's block caret; the unfocused 1px outline and partial blocks are unchanged.

Fixes #3197
2026-11-14 01:27:32 +08:00
Mansur Akhmadiandmansur-akhmadi 15182507cf fix(vcs): confirm force pushes in the multi-repository Push dialog (#3215)
With Force (with lease) ticked, the multi-repository Push dialog force pushed every picked repository without the Force Push confirmation the single-repository dialog shows. Each forced target is now confirmed first, once when several repositories push to the same target; protected targets are never forced and not asked about, "Don't warn about this target" is respected, and declining pushes nothing and keeps the dialog open.

Closes #3214

Co-authored-by: mansur-akhmadi <[email protected]>
2026-11-14 01:24:29 +08:00
zhangdagouandPLZY 38bd8fbd16 fix(vcs): let the Commit diff go on past the last change into unversioned files (#3211)
A diff opened from the Commit tool window (double-click / Enter, and the preview that follows the selection) stopped at the last file of its group, so F7 never reached the new files under Unversioned Files. Its chain now covers every file of the tree in tree order, as the Commit diff preview does; Show Diff (Ctrl+D) still keeps to the file's group.

Co-authored-by: PLZY <[email protected]>
2026-11-14 01:22:54 +08:00
l0ng-ai 2859ec9106 feat(templates): New ▸ C# Class with the project's namespace (#3216)
Class, Interface, Struct, Enum and Record. The namespace is the nearest .csproj's
RootNamespace (else its file name) plus the folders below it. Projects on C# 10 or later
(.NET 6+, or LangVersion 10/latest) get a file-scoped namespace; .NET Framework, Unity,
.NET Standard and .NET Core projects get a namespace block.

Fixes #3190
2026-11-14 01:17:26 +08:00
78 changed files with 1782 additions and 196 deletions

No files matched your search

+46
View File
@@ -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());
+8 -2
View File
@@ -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();
+10 -4
View File
@@ -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, &notif_tx, &body);
dispatch(&inner, &*handler, &notif_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;
+26
View File
@@ -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"));
}
+4
View File
@@ -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 {
+71
View File
@@ -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() {
+22 -1
View File
@@ -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);
+60 -13
View File
@@ -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());
}
}
+1 -1
View File
@@ -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| {
+80
View File
@@ -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();
+39
View File
@@ -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"]);
}
+29 -6
View File
@@ -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")]
{
+19
View File
@@ -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");
}
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
446156fc345df7c8252592f73129f3996139e5980c6ed9a296a9cbecc334eb17
ec93db9b37437986167b4040714f5cda5cb7a2fc138e6c2dc2a9447faaf5a802
+33 -1
View File
@@ -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"]);
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
b072d07c4aa4a972a71fffba355ea215da7860b8f5527c6074055f52c52a29be
1bd90bca250bb06ab3b0f27b045110c722685d894ce79d23648cb7c795d2c033
+15 -1
View File
@@ -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 [
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
a78162e7c4c6ebd3a22c06737fd35948e11b0dd8949571617bc099ba6b4423f0
c4c86cef9512ac03193e7d89d39b6ee41665c2d4721a78f9415d406289e9ece1
+5 -4
View File
@@ -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=&quot;x&quot;");
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
View File
@@ -1 +1 @@
6d84abcc58c1894203506fa89855a6eade61f14add0ed72fdd7af9e3cd1298f6
0d7731de73514b950d8579762029d98c46136f55bfdb2fa44e256cc9a9955a0f
+32 -2
View File
@@ -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);
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
a303522c605ccc9804e67f85326778a380951ce14022a70bc37ba3ae63b76f79
959cb2ab750211bd1cc894a4d804acb2dd16e95c1e65b267e7eccbb64cbdd8da
+211 -51
View File
@@ -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);
}
}
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
b7b4e6be26ff08cc0e7fa791cb956acd9afa09905720d2c0c93acdae5638a35e
d5ec377869ded4ec0bf533d07d51610aec7a6813d43ead98a261b3f54e7d0655
+24 -1
View File
@@ -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]
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
4f585c3a1a3ae01a89811e05d27fc39121dd8e74210c1281d04ff31556d393b5
ebbd24e08c19953895464567cbaf1335b9847e98e8a8da8a4e0b8c2886c1abd6
+24 -4
View File
@@ -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));
}
}
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
81637f9a265e6bcb9839b1019b0d58b43f80535fb1fca044716904aef0c12724
997e2ac7520a561f22d12ad7013ed576262424ffa8acd69fab219f68d00c92f1
+7 -2
View File
@@ -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("&lt;a&gt; &#x41;&#66; &bogus; &"), "<a> AB &bogus; &");
assert_eq!(html_unescape("R&D部门的预算 &aaaaaaaaaa€"), "R&D部门的预算 &aaaaaaaaaa€");
assert_eq!(json_escape("a\"b\n"), "a\\\"b\\n");
}
@@ -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]
+7 -4
View File
@@ -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();
});
});
+53
View File
@@ -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 };
}),
);
+58 -10
View File
@@ -5,7 +5,7 @@ import { indentUnit } from "@codemirror/language";
import { codeLenses } from "@/editor/api";
import type { EditorSettings } from "@/bindings";
import { codeLensExtension } from "./codeLens";
import { 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)");
});
+99 -12
View File
@@ -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)
+2
View File
@@ -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,
+7
View File
@@ -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) => {
+20 -1
View File
@@ -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);
});
});
+1 -1
View File
@@ -30,7 +30,7 @@ import {
import { evaluateWhen } from "@/platform/context";
/** Editor actions apply to the active text editor unless a tool window or popup owns the keyboard. */
export const EDITOR_WHEN = "textEditorActive && (editorFocus || popupOpen || !toolWindowFocus)";
export const EDITOR_WHEN = "textEditorActive && (editorFocus || !toolWindowFocus)";
/**
* Languages whose servers offer Generate (constructors, accessors, overrides…).
+120 -22
View File
@@ -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();
+43
View File
@@ -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")]));
+8
View File
@@ -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
View File
@@ -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.
+125
View File
@@ -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);
});
});
+1 -1
View File
@@ -22,7 +22,7 @@ import { gotoTestOrSubject, type TestNavDeps } from "./gotoTest";
import { baseName, memberAt, methodStop, superMethods } from "./structureNav";
import { tx } from "@/platform/i18n";
const EDITOR_WHEN = "textEditorActive && (editorFocus || 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> {
+1 -1
View File
@@ -26,7 +26,7 @@ import { ServerStatus } from "./ServerStatus";
import { reportProblems } from "./problemsReport";
import { installWorkspaceEditUndo } from "./workspaceUndo";
const EDITOR_WHEN = "textEditorActive && (editorFocus || 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({
+5 -4
View File
@@ -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} />
</>
+62 -13
View File
@@ -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>>();
+10
View File
@@ -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",
+1 -1
View File
@@ -25,7 +25,7 @@ import { loadEmmet } from "./emmet/model";
import { promptAbbreviation, UpdateTagDialogHost } from "./emmet/UpdateTagDialog";
import { tx } from "@/platform/i18n";
const EDITOR_WHEN = "textEditorActive && !editorReadOnly && (editorFocus || popupOpen || !toolWindowFocus)";
const EDITOR_WHEN = "textEditorActive && !editorReadOnly && (editorFocus || !toolWindowFocus)";
const MARKUP_PATH = /\.(html?|xhtml|shtml|vue|svelte|xml)$/i;
async function updateTagWithEmmet() {
+5 -3
View File
@@ -82,6 +82,7 @@ import {
DIFF_KIND,
changeNodeDiff,
changeNodeDiffData,
changeTreeDiffData,
diffInput,
openDiff,
openVcsDiff,
@@ -205,7 +206,8 @@ const bottomLeft = (el: HTMLElement) => {
/**
* Activates a change (Enter, double-click, Show Diff); F7 continues through the other
* files of its group, in tree order. Enter and double-click follow IDEA's Show on
* files of its group, in tree order (Enter and double-click: of the whole tree, on into the
* unversioned files, as IDEA's Commit diff preview). Enter and double-click follow IDEA's Show on
* Double-Click (opensDiffOnActivate): the source file, or the diff where the commit
* interface puts it (openVcsDiff: a preview editor tab, or under the modal interface the
* front diff window, behind the modal Commit dialog, #1863). `forceDiff` is Show Diff and
@@ -217,7 +219,7 @@ function openNode(n: ChangeNode, roots: readonly ChangeNode[], forceDiff = false
void editorService().openFile(n.path, { focus: true });
return;
}
void openVcsDiff(changeNodeDiffData(roots, n), { preview: true });
void openVcsDiff((forceDiff ? changeNodeDiffData : changeTreeDiffData)(roots, n), { preview: true });
}
/** The setting behind Show on Double-Click (`None` follows the commit interface). */
@@ -630,7 +632,7 @@ export function ChangesTree({ mode }: { mode: "commit" | "local" | "dialog" }) {
const active = maybeEditorService()?.activeInput?.();
if (active?.kind !== DIFF_KIND || active.key !== followedDiff.current) return;
if (diffInput(changeNodeDiff(node)).key === active.key) return;
void openDiff(changeNodeDiffData(roots, node), { preview: true, focus: false });
void openDiff(changeTreeDiffData(roots, node), { preview: true, focus: false });
};
// Reveal only after the ancestors have expanded, without moving keyboard focus.
+37 -1
View File
@@ -1,5 +1,14 @@
import { describe, expect, it } from "vitest";
import { chainNeighbour, revisionChangeDiffData, withChain, type DiffData } from "../diffInput";
import type { FileStatus } from "@/bindings";
import { buildChangeTree, changeFileNodes } from "../changes";
import {
chainNeighbour,
changeNodeDiffData,
changeTreeDiffData,
revisionChangeDiffData,
withChain,
type DiffData,
} from "../diffInput";
const diff = (path: string): DiffData => ({ path, left: { type: "rev", rev: "HEAD" }, right: { type: "worktree" } });
@@ -24,6 +33,33 @@ describe("diff chain", () => {
});
});
describe("the changes tree's diff chain", () => {
const fs = (rel: string, staged: FileStatus["staged"], unstaged: FileStatus["unstaged"]): FileStatus => ({
path: `/r/${rel}`,
relPath: rel,
staged,
unstaged,
renamedFrom: null,
});
const status = [fs("a.ts", null, "modified"), fs("b.ts", "modified", null), fs("new.ts", null, "untracked")];
it("goes on past the last change into the unversioned files, as IDEA's Commit diff does", () => {
const tree = buildChangeTree(status, "/r", false);
const last = changeFileNodes(tree).find((n) => n.path === "/r/b.ts")!;
const data = changeTreeDiffData(tree, last);
expect(data.chain?.map((d) => d.path)).toEqual(["/r/a.ts", "/r/b.ts", "/r/new.ts"]);
expect(chainNeighbour(data, 1)).toMatchObject({ path: "/r/new.ts" });
// Show Diff keeps to the file's group.
expect(chainNeighbour(changeNodeDiffData(tree, last), 1)).toBeNull();
});
it("walks every staging group in tree order", () => {
const tree = buildChangeTree(status, "/r", false, undefined, [], true);
const staged = changeFileNodes(tree).find((n) => n.group === "staged")!;
expect(chainNeighbour(changeTreeDiffData(tree, staged), 1)).toMatchObject({ path: "/r/a.ts" });
});
});
describe("combined changes of several commits", () => {
const base = { oldPath: null, before: "b".repeat(40), after: "a".repeat(40) };
@@ -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();
});
+15 -1
View File
@@ -5,7 +5,7 @@ import { workflowSettings } from "@/platform/workflowSettings";
import { editorService, type EditorInput } from "@/workbench/contributions";
import { baseName, effectiveKind } from "./model";
import { readWorkingText } from "./ops";
import { groupFileNodes, renamedFromPath, stagingSide, type ChangeNode } from "./changes";
import { changeFileNodes, groupFileNodes, renamedFromPath, stagingSide, type ChangeNode } from "./changes";
import { currentRepoRoot, statusOf } from "./store";
import { repositoryPath } from "./repositoryPaths";
@@ -188,6 +188,20 @@ export function changeNodeDiffData(roots: readonly ChangeNode[], node: ChangeNod
);
}
/**
* The Commit window's diff of a file: every file of the tree (in tree order) as the F7 chain,
* so past the last change it goes on into the unversioned files, as IDEA's Commit diff
* preview does (`ChangesViewDiffPreviewProcessor.iterateAllChanges`).
*/
export function changeTreeDiffData(roots: readonly ChangeNode[], node: ChangeNode): DiffData {
const all = changeFileNodes(roots);
const nodes = all.includes(node) ? all : [node];
return withChain(
nodes.map((n) => changeNodeDiff(n)),
nodes.indexOf(node),
);
}
/** Default local-changes diff for a working file: HEAD (or empty) vs the working copy. */
export function workingDiffData(path: string, line?: number, side?: "staged" | "unstaged" | null): DiffData {
if (side === "staged") {
+9
View File
@@ -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 };
+8 -1
View File
@@ -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",
+28 -1
View File
@@ -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
View File
@@ -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());
}
+1 -1
View File
@@ -223,7 +223,7 @@ it("renames in the project view although the preset shares Alt+Shift+R with the
const dbRename = vi.fn();
const refactorRename = vi.fn();
// The claimants as they ship: `when` + `keyWhen` exactly as registered.
const EDITOR = "textEditorActive && (editorFocus || popupOpen || !toolWindowFocus)";
const EDITOR = "textEditorActive && (editorFocus || !toolWindowFocus)";
const disposers = [
registerAction({
id: "project.rename",
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+26
View File
@@ -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
View File
@@ -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": {
+2
View File
@@ -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",
+3
View File
@@ -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

+3
View File
@@ -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