From 9d670e6262e9375bbfe66979b8db3245930e3d77 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 31 Oct 2026 10:40:24 +0800 Subject: [PATCH] feat(project-tree): enrich JVM project view icons (#512) Project rows only showed generic folder and filename icons, so JVM modules, packages and declarations looked alike. Rows now show Maven and Gradle project and module icons, packages, Spring configuration and Boot application classes, runnable classes, test classes, Java and Kotlin declaration types, and generated, compiled and javadoc output folders. Editor tabs, Goto File and Recent Files use the same resolver. Types come from a bounded syntax parse of visible files, served locally and over remote RPC. Spring stereotypes keep their ordinary type icons, and .class files stay binary. Fixes #347 --- THIRD_PARTY_LICENSES.md | 5 +- crates/ide-host/src/ops/jvm_icons.rs | 102 ++++++++++ crates/ide-host/src/ops/mod.rs | 1 + crates/ide-host/src/rpc.rs | 3 + crates/ide-host/tests/rpc.rs | 16 ++ crates/ide-syntax/src/jvm.rs | 189 ++++++++++++++++++ crates/ide-syntax/src/lib.rs | 2 + crates/ide-syntax/src/tests/jvm.rs | 78 ++++++++ crates/ide-syntax/src/tests/mod.rs | 1 + fixtures/ts-app/.openidea/.gitignore | 3 + src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/syntax.rs | 10 + src/bindings.ts | 14 ++ src/editor/core/view/EditorArea.tsx | 11 +- .../project-tree/ProjectView.module.css | 13 ++ src/features/project-tree/ProjectView.tsx | 46 ++++- src/features/project-tree/folderKind.test.ts | 61 ++++++ src/features/project-tree/folderKind.ts | 38 +++- .../search/everywhere/SearchEverywhere.tsx | 5 +- src/features/search/recent/RecentPopups.tsx | 4 +- src/platform/filePresentation.cache.test.tsx | 101 ++++++++++ src/platform/filePresentation.test.ts | 53 +++++ src/platform/filePresentation.ts | 189 ++++++++++++++++++ src/platform/projectPresentation.test.ts | 63 ++++++ src/platform/projectPresentation.ts | 83 ++++++++ src/ui/expui/gradle/gradle.svg | 5 + src/ui/expui/gradle/gradle@20x20.svg | 5 + src/ui/expui/gradle/gradle@20x20_dark.svg | 5 + src/ui/expui/gradle/gradleSubproject.svg | 6 + src/ui/expui/gradle/gradleSubproject_dark.svg | 6 + src/ui/expui/gradle/gradle_dark.svg | 5 + src/ui/expui/kotlin/objectKotlin.svg | 8 + src/ui/expui/kotlin/objectKotlin_dark.svg | 8 + src/ui/expui/nodes/classAbstract.svg | 6 + src/ui/expui/nodes/classAbstract_dark.svg | 6 + src/ui/expui/nodes/compiledClassesFolder.svg | 6 + .../nodes/compiledClassesFolder_dark.svg | 6 + src/ui/expui/nodes/exception.svg | 5 + src/ui/expui/nodes/exception_dark.svg | 5 + src/ui/expui/nodes/generatedSource.svg | 12 ++ src/ui/expui/nodes/generatedSource_dark.svg | 12 ++ src/ui/expui/nodes/generatedTestRoot.svg | 12 ++ src/ui/expui/nodes/generatedTestRoot_dark.svg | 12 ++ src/ui/expui/nodes/javaDocFolder.svg | 6 + src/ui/expui/nodes/javaDocFolder_dark.svg | 6 + src/ui/expui/nodes/runnableMark.svg | 12 ++ src/ui/expui/nodes/runnableMark_dark.svg | 12 ++ src/ui/expui/nodes/test.svg | 5 + src/ui/expui/nodes/test_dark.svg | 5 + src/ui/icons.tsx | 31 ++- 50 files changed, 1279 insertions(+), 20 deletions(-) create mode 100644 crates/ide-host/src/ops/jvm_icons.rs create mode 100644 crates/ide-syntax/src/jvm.rs create mode 100644 crates/ide-syntax/src/tests/jvm.rs create mode 100644 fixtures/ts-app/.openidea/.gitignore create mode 100644 src/features/project-tree/folderKind.test.ts create mode 100644 src/platform/filePresentation.cache.test.tsx create mode 100644 src/platform/filePresentation.test.ts create mode 100644 src/platform/filePresentation.ts create mode 100644 src/platform/projectPresentation.test.ts create mode 100644 src/platform/projectPresentation.ts create mode 100644 src/ui/expui/gradle/gradle.svg create mode 100644 src/ui/expui/gradle/gradle@20x20.svg create mode 100644 src/ui/expui/gradle/gradle@20x20_dark.svg create mode 100644 src/ui/expui/gradle/gradleSubproject.svg create mode 100644 src/ui/expui/gradle/gradleSubproject_dark.svg create mode 100644 src/ui/expui/gradle/gradle_dark.svg create mode 100644 src/ui/expui/kotlin/objectKotlin.svg create mode 100644 src/ui/expui/kotlin/objectKotlin_dark.svg create mode 100644 src/ui/expui/nodes/classAbstract.svg create mode 100644 src/ui/expui/nodes/classAbstract_dark.svg create mode 100644 src/ui/expui/nodes/compiledClassesFolder.svg create mode 100644 src/ui/expui/nodes/compiledClassesFolder_dark.svg create mode 100644 src/ui/expui/nodes/exception.svg create mode 100644 src/ui/expui/nodes/exception_dark.svg create mode 100644 src/ui/expui/nodes/generatedSource.svg create mode 100644 src/ui/expui/nodes/generatedSource_dark.svg create mode 100644 src/ui/expui/nodes/generatedTestRoot.svg create mode 100644 src/ui/expui/nodes/generatedTestRoot_dark.svg create mode 100644 src/ui/expui/nodes/javaDocFolder.svg create mode 100644 src/ui/expui/nodes/javaDocFolder_dark.svg create mode 100644 src/ui/expui/nodes/runnableMark.svg create mode 100644 src/ui/expui/nodes/runnableMark_dark.svg create mode 100644 src/ui/expui/nodes/test.svg create mode 100644 src/ui/expui/nodes/test_dark.svg diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index 0c711821..6903ad3d 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -12,12 +12,13 @@ icon set, copied unmodified (some renamed) from JetBrains' open-source repositor `python/python-requirements` (`fileTypes/requirements`); `python/pipenv` (`fileTypes/pipenv`); `python/python-pyproject` (`expui/toml.svg` → `fileTypes/pyprojectToml`); `plugins/kotlin/base` (`fileTypes/kotlin`, - `kotlinScript`, `kotlinGradleScript`); `java/openapi` (`fileTypes/maven`); + `kotlinScript`, `kotlinGradleScript`, `icons/expui/objectKotlin` → `kotlin/objectKotlin`); `java/openapi` (`fileTypes/maven`); `plugins/markdown` (`fileTypes/markdown`); `plugins/toml` (`fileTypes/toml`); `plugins/terminal` (`toolwindows/terminal`); `plugins/maven` (`images/expui/toolwindow/maven` → `toolwindows/maven`, `images/expui/build/maven*` → `maven/`); `plugins/gradle` (`icons/expui/gradle` → `toolwindows/gradle`, - `icons/expui/gradleLoadChanges` → `build/gradleLoadChanges`); + `icons/expui/gradleLoadChanges` → `build/gradleLoadChanges`, + `icons/expui/gradle`, `icons/gradleSubproject` → `gradle/`); `platform/external-system-impl` (`icons/newui/task` → `externalSystem/task`). Copyright 2000-2026 JetBrains s.r.o. and contributors. Apache License 2.0 (below). - [intellij-plugins](https://github.com/JetBrains/intellij-plugins) (each plugin diff --git a/crates/ide-host/src/ops/jvm_icons.rs b/crates/ide-host/src/ops/jvm_icons.rs new file mode 100644 index 00000000..a9d24773 --- /dev/null +++ b/crates/ide-host/src/ops/jvm_icons.rs @@ -0,0 +1,102 @@ +//! Bounded, read-only JVM presentation for visible files, also served remotely. +use crate::Host; +use ide_core::{CancellationToken, IdeError, IdeResult, LanguageId}; +use ide_syntax::{JvmPresentation, Snapshot}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::{ + collections::HashMap, + fs::File, + io::Read, + path::PathBuf, + sync::{Mutex, OnceLock}, + time::SystemTime, +}; + +pub const PREFIX_BYTES: u64 = 64 * 1024; +const MAX_BATCH: usize = 64; +const MAX_CACHE: usize = 4096; + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct JvmFilePresentation { + pub path: PathBuf, + pub presentation: JvmPresentation, +} + +#[derive(Clone)] +struct Cached { + modified: SystemTime, + size: u64, + presentation: JvmPresentation, +} + +fn prefix(path: &PathBuf) -> std::io::Result { + let mut bytes = Vec::new(); + File::open(path)?.take(PREFIX_BYTES).read_to_end(&mut bytes)?; + // A truncated final UTF-8 character must not invalidate the entire prefix. + Ok(String::from_utf8_lossy(&bytes).into_owned()) +} + +pub async fn jvm_file_presentations( + host: &Host, + window: &str, + paths: Vec, +) -> IdeResult> { + if paths.len() > MAX_BATCH { + return Err(IdeError::invalid("at most 64 JVM files per batch")); + } + let project = host.project(window)?; + host.blocking(move || { + static CACHE: OnceLock>> = OnceLock::new(); + let cache = CACHE.get_or_init(Default::default); + let mut results = Vec::new(); + for path in paths { + let language = match path.extension().and_then(|e| e.to_str()) { + Some("java") => LanguageId::new("java"), + Some("kt") => LanguageId::new("kotlin"), + _ => continue, + }; + let Ok(meta) = path.metadata() else { continue }; + if !meta.is_file() { + continue; + } + let Ok(modified) = meta.modified() else { continue }; + let hit = + cache.lock().unwrap().get(&path).filter(|c| c.modified == modified && c.size == meta.len()).cloned(); + let presentation = if let Some(hit) = hit { + hit.presentation + } else { + let Ok(source) = prefix(&path) else { continue }; + let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or_default(); + let presentation = project.structure.jvm_presentation( + &Snapshot::text(&language, &source), + stem, + &CancellationToken::new(), + )?; + let mut cache = cache.lock().unwrap(); + if cache.len() >= MAX_CACHE { + cache.clear(); + } + cache.insert(path.clone(), Cached { modified, size: meta.len(), presentation: presentation.clone() }); + presentation + }; + results.push(JvmFilePresentation { path, presentation }); + } + Ok(results) + }) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn huge_files_read_only_a_prefix() { + let path = std::env::temp_dir().join(format!("openidea-icon-prefix-{}.java", std::process::id())); + let file = File::create(&path).unwrap(); + file.set_len(100 * 1024 * 1024).unwrap(); + assert_eq!(prefix(&path).unwrap().len(), PREFIX_BYTES as usize); + std::fs::remove_file(path).unwrap(); + } +} diff --git a/crates/ide-host/src/ops/mod.rs b/crates/ide-host/src/ops/mod.rs index 4baf33ca..854b0cfc 100644 --- a/crates/ide-host/src/ops/mod.rs +++ b/crates/ide-host/src/ops/mod.rs @@ -13,6 +13,7 @@ pub mod env; pub mod fs; pub mod history; pub mod java; +pub mod jvm_icons; pub mod lsp; pub mod nav; pub mod notebook; diff --git a/crates/ide-host/src/rpc.rs b/crates/ide-host/src/rpc.rs index 56055d2d..14f15e80 100644 --- a/crates/ide-host/src/rpc.rs +++ b/crates/ide-host/src/rpc.rs @@ -148,6 +148,9 @@ impl Dispatcher { c.host.close_project(&c.window); Ok(()) }) + .add("jvm_file_presentations", |c| async move { + ops::jvm_icons::jvm_file_presentations(&c.host, &c.window, c.arg("paths")?).await + }) .add("project_roots", |c| async move { ops::project::roots(&c.host, &c.window) }) .add("settings_get", |c| async move { Ok(ops::project::settings(&c.host, &c.window)) }) .add("settings_set", |c| async move { diff --git a/crates/ide-host/tests/rpc.rs b/crates/ide-host/tests/rpc.rs index 3e195abf..8a5947aa 100644 --- a/crates/ide-host/tests/rpc.rs +++ b/crates/ide-host/tests/rpc.rs @@ -81,6 +81,22 @@ async fn serves_projects_files_documents_search_and_runs() { let entries = invoke(&peer, "fs_read_dir", json!({ "path": project })).await.unwrap(); assert!(entries.as_array().unwrap().iter().any(|e| e["name"] == "src"), "{entries}"); + // JVM icons use the same read-only protocol as local windows. + let java = project.join("src/Foo.java"); + let kotlin = project.join("src/Singleton.kt"); + std::fs::write(&java, "abstract class Foo {}").unwrap(); + std::fs::write(&kotlin, "object Singleton {}").unwrap(); + let icons = invoke(&peer, "jvm_file_presentations", json!({ "paths": [java, kotlin] })).await.unwrap(); + assert_eq!(icons[0]["presentation"]["kind"], "abstractClass"); + assert_eq!(icons[1]["presentation"]["kind"], "object"); + // Replacing a file invalidates its cached metadata, including same-size writes. + std::fs::write(&java, "interface Foo {} ").unwrap(); + std::fs::File::open(&java).unwrap().set_modified(std::time::SystemTime::UNIX_EPOCH).unwrap(); + let icons = invoke(&peer, "jvm_file_presentations", json!({ "paths": [java] })).await.unwrap(); + assert_eq!(icons[0]["presentation"]["kind"], "interface"); + let oversized = invoke(&peer, "jvm_file_presentations", json!({ "paths": vec![java.clone(); 65] })).await; + assert!(oversized.is_err()); + // Edit and save a document. let file = project.join("src/main.py"); let doc = invoke(&peer, "doc_open", json!({ "path": file })).await.unwrap(); diff --git a/crates/ide-syntax/src/jvm.rs b/crates/ide-syntax/src/jvm.rs new file mode 100644 index 00000000..9da0f7b4 --- /dev/null +++ b/crates/ide-syntax/src/jvm.rs @@ -0,0 +1,189 @@ +//! Syntactic presentation of the primary top-level JVM declaration. +use crate::{Snapshot, StructureResult, StructureService}; +use ide_core::CancellationToken; +use serde::{Deserialize, Serialize}; +use specta::Type; +use tree_sitter::Node; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum JvmTypeKind { + Class, + AbstractClass, + Interface, + Enum, + Record, + Annotation, + Exception, + Object, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct JvmPresentation { + pub kind: Option, + pub spring_boot: bool, + pub runnable: bool, +} + +fn text<'a>(node: Node, source: &'a str) -> &'a str { + source.get(node.byte_range()).unwrap_or_default() +} + +fn children(node: Node) -> Vec { + node.children(&mut node.walk()).collect() +} + +fn descendant(node: Node<'_>, predicate: &impl Fn(Node<'_>) -> bool) -> bool { + predicate(node) || node.children(&mut node.walk()).any(|n| descendant(n, predicate)) +} + +fn keyword(node: Node, source: &str, word: &str) -> bool { + descendant(node, &|n| { + n.child_count() == 0 && (n.kind() == word || n.kind().ends_with("_modifier") && text(n, source) == word) + }) +} + +fn annotation(node: Node, source: &str, name: &str) -> bool { + node.named_children(&mut node.walk()).any(|n| { + if !matches!(n.kind(), "annotation" | "marker_annotation") { + return false; + } + if let Some(id) = n.child_by_field_name("name") { + return text(id, source).rsplit('.').next() == Some(name); + } + // Kotlin: @ -> constructor_invocation -> user_type (or type -> user_type). + let mut current = n; + while let Some(first) = current.named_children(&mut current.walk()).next() { + if matches!(first.kind(), "use_site_target") { + return false; + } + if first.kind() == "user_type" { + let ids: Vec<_> = + first.named_children(&mut first.walk()).filter(|c| c.kind() == "identifier").collect(); + return ids.last().is_some_and(|id| text(*id, source) == name); + } + current = first; + } + false + }) +} + +fn exception_base(node: Node, source: &str) -> bool { + if matches!(node.kind(), "type_arguments" | "value_arguments" | "explicit_delegation") { + return false; + } + if matches!(node.kind(), "type_identifier" | "user_type" | "scoped_type_identifier") { + let name = if node.kind() == "user_type" || node.kind() == "scoped_type_identifier" { + node.named_children(&mut node.walk()) + .filter(|n| matches!(n.kind(), "identifier" | "type_identifier")) + .last() + .map(|n| text(n, source)) + .unwrap_or_default() + } else { + text(node, source) + }; + return name.ends_with("Exception") || name.ends_with("Error") || name == "Throwable"; + } + node.named_children(&mut node.walk()).any(|n| exception_base(n, source)) +} + +fn members(node: Node<'_>) -> Vec> { + let mut out = Vec::new(); + for child in node.named_children(&mut node.walk()) { + if matches!(child.kind(), "method_declaration" | "function_declaration") { + out.push(child); + } + if matches!(child.kind(), "companion_object" | "class_body" | "enum_body_declarations") { + out.extend(members(child)); + } + } + out +} + +fn is_type(node: Node) -> bool { + matches!( + node.kind(), + "class_declaration" + | "interface_declaration" + | "enum_declaration" + | "record_declaration" + | "annotation_type_declaration" + | "object_declaration" + ) +} + +impl StructureService { + /// Only top-level declarations participate; the filename-matching declaration + /// wins, otherwise the first type does. Strings/comments never supply evidence. + pub fn jvm_presentation( + &self, + snap: &Snapshot, + stem: &str, + cancel: &CancellationToken, + ) -> StructureResult { + self.run(snap, cancel, |cx| { + if !matches!(cx.grammar, "java" | "kotlin") { + return Ok(JvmPresentation::default()); + } + let top = cx.tree.root_node(); + let types: Vec<_> = top.named_children(&mut top.walk()).filter(|n| is_type(*n)).collect(); + let Some(node) = types + .iter() + .find(|n| n.child_by_field_name("name").is_some_and(|id| text(id, snap.text) == stem)) + .or(types.first()) + .copied() + else { + return Ok(JvmPresentation::default()); + }; + let parts = children(node); + let modifiers = parts.iter().find(|n| n.kind() == "modifiers").copied(); + let has = |word| modifiers.is_some_and(|m| keyword(m, snap.text, word)); + let supertype = parts.iter().find(|n| matches!(n.kind(), "superclass" | "delegation_specifiers")); + let exception = supertype.is_some_and(|s| exception_base(*s, snap.text)); + let kind = match node.kind() { + "annotation_type_declaration" => JvmTypeKind::Annotation, + "interface_declaration" => JvmTypeKind::Interface, + "enum_declaration" => JvmTypeKind::Enum, + "record_declaration" => JvmTypeKind::Record, + "object_declaration" => JvmTypeKind::Object, + _ if has("annotation") => JvmTypeKind::Annotation, + _ if has("enum") => JvmTypeKind::Enum, + _ if parts.iter().any(|n| n.kind() == "interface") => JvmTypeKind::Interface, + _ if exception => JvmTypeKind::Exception, + _ if has("abstract") => JvmTypeKind::AbstractClass, + _ => JvmTypeKind::Class, + }; + let spring_boot = modifiers.is_some_and(|m| annotation(m, snap.text, "SpringBootApplication")); + let body = parts.iter().find(|n| n.kind() == "class_body"); + let runnable = body.is_some_and(|b| { + members(*b).into_iter().any(|n| { + if !matches!(n.kind(), "method_declaration" | "function_declaration") + || !n.child_by_field_name("name").is_some_and(|id| text(id, snap.text) == "main") + { + return false; + } + if cx.grammar == "kotlin" { + return children(n) + .iter() + .any(|m| m.kind() == "modifiers" && annotation(*m, snap.text, "JvmStatic")); + } + let modifiers = children(n).into_iter().find(|m| m.kind() == "modifiers"); + let params = n.child_by_field_name("parameters"); + modifiers.is_some_and(|m| keyword(m, snap.text, "static") && keyword(m, snap.text, "public")) + && n.child_by_field_name("type").is_some_and(|t| text(t, snap.text) == "void") + && params.is_some_and(|p| { + let args: Vec<_> = p.named_children(&mut p.walk()).collect(); + args.len() == 1 + && descendant(args[0], &|t| { + t.kind() == "type_identifier" + && matches!(text(t, snap.text), "String" | "java.lang.String") + }) + && (keyword(args[0], snap.text, "[") || keyword(args[0], snap.text, "...")) + }) + }) + }); + Ok(JvmPresentation { kind: Some(kind), spring_boot, runnable }) + }) + } +} diff --git a/crates/ide-syntax/src/lib.rs b/crates/ide-syntax/src/lib.rs index 09253c3a..b7238288 100644 --- a/crates/ide-syntax/src/lib.rs +++ b/crates/ide-syntax/src/lib.rs @@ -19,6 +19,7 @@ //! the query loops honour the [`CancellationToken`]. mod identifiers; +mod jvm; mod kinds; mod lines; mod locals; @@ -28,6 +29,7 @@ mod types; #[cfg(test)] mod tests; +pub use jvm::{JvmPresentation, JvmTypeKind}; pub use types::*; use ide_core::{CancellationToken, DocId, IdeError, LanguageId, Pos, Range}; diff --git a/crates/ide-syntax/src/tests/jvm.rs b/crates/ide-syntax/src/tests/jvm.rs new file mode 100644 index 00000000..fb95ef15 --- /dev/null +++ b/crates/ide-syntax/src/tests/jvm.rs @@ -0,0 +1,78 @@ +use super::*; +use crate::{JvmPresentation, JvmTypeKind::*}; + +fn presentation(language: &str, text: &str) -> JvmPresentation { + service().jvm_presentation(&Snapshot::text(&lang(language), text), "Foo", &none()).unwrap() +} + +#[test] +fn jvm_declaration_kinds() { + for (language, source, kind) in [ + ("java", "public class Foo {}", Class), + ("java", "abstract class Foo {}", AbstractClass), + ("java", "interface Foo {}", Interface), + ("java", "enum Foo { A }", Enum), + ("java", "record Foo(int x) {}", Record), + ("java", "@interface Foo {}", Annotation), + ("java", "class Foo extends java.lang.RuntimeException {}", Exception), + ("kotlin", "class Foo", Class), + ("kotlin", "abstract class Foo", AbstractClass), + ("kotlin", "interface Foo", Interface), + ("kotlin", "enum class Foo { A }", Enum), + ("kotlin", "annotation class Foo", Annotation), + ("kotlin", "class Foo : RuntimeException()", Exception), + ("kotlin", "object Foo {}", Object), + ("kotlin", "data class Foo(val x: Int)", Class), + ] { + assert_eq!(presentation(language, source).kind, Some(kind), "{language}: {source}"); + } +} + +#[test] +fn boot_annotations_are_on_the_primary_type_only() { + for language in ["java", "kotlin"] { + for annotation in ["@SpringBootApplication", "@org.springframework.boot.autoconfigure.SpringBootApplication"] { + assert!(presentation(language, &format!("{annotation} class Foo {{}}")).spring_boot); + } + for source in [ + "// @SpringBootApplication\nclass Foo {}", + "/* @SpringBootApplication */ class Foo {}", + "@Service class Foo {}", + "@Controller class Foo {}", + "@Wrapper(\"@SpringBootApplication\") class Foo {}", + "class Outer { @SpringBootApplication class Nested {} } class Foo {}", + ] { + assert!(!presentation(language, source).spring_boot, "{source}"); + } + } + assert!(!presentation("java", "class Foo { String s = \"@SpringBootApplication\"; }").spring_boot); + assert!(!presentation("kotlin", "class Foo { val s = \"@SpringBootApplication\" }").spring_boot); + assert!(!presentation("java", "class Foo { @SpringBootApplication void method() {} }").spring_boot); + assert!(!presentation("java", "@Wrapper(@SpringBootApplication) class Foo {}").spring_boot); + assert_eq!(presentation("java", "class Foo extends Box {}").kind, Some(Class)); + assert_eq!(presentation("kotlin", "class Foo : Box()").kind, Some(Class)); + assert_eq!(presentation("java", "// no type\n"), JvmPresentation::default()); + assert_eq!(presentation("kotlin", "val text = \"class Foo\""), JvmPresentation::default()); + assert_eq!(presentation("java", "interface Other {} class Foo {} ").kind, Some(Class)); +} + +#[test] +fn runnable_methods() { + assert!(presentation("java", "class Foo { public static void main(String[] args) {} }").runnable); + assert!(presentation("java", "class Foo { public static void main(String... args) {} }").runnable); + assert!(!presentation("java", "class Foo { void main() {} }").runnable); + assert!(!presentation("java", "class Foo { class Nested { public static void main(String[] a) {} } }").runnable); + assert!(!presentation("java", "class Foo { String s = \"public static void main(String[] args) {}\"; }").runnable); + assert!( + presentation( + "kotlin", + "class Foo { + companion object { + @JvmStatic + fun main(args: Array) {} + } + }" + ) + .runnable + ); +} diff --git a/crates/ide-syntax/src/tests/mod.rs b/crates/ide-syntax/src/tests/mod.rs index 3c677d41..fe753dc4 100644 --- a/crates/ide-syntax/src/tests/mod.rs +++ b/crates/ide-syntax/src/tests/mod.rs @@ -1,6 +1,7 @@ //! Test helpers: sources are plain strings; positions are named by the n-th //! whole-word occurrence of an identifier (1-based), so fixtures stay readable. +mod jvm; mod locals_langs; mod structure; diff --git a/fixtures/ts-app/.openidea/.gitignore b/fixtures/ts-app/.openidea/.gitignore new file mode 100644 index 00000000..44ade528 --- /dev/null +++ b/fixtures/ts-app/.openidea/.gitignore @@ -0,0 +1,3 @@ +# Per-user IDE state +/shelf/ +/changelists.json diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index ab138820..121f2eab 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -151,6 +151,7 @@ pub fn builder() -> tauri_specta::Builder { doc::doc_folding, doc::doc_highlights, doc::doc_selection_ranges, + syntax::jvm_file_presentations, syntax::syntax_support, syntax::syntax_node_at, syntax::syntax_expression_at, diff --git a/src-tauri/src/commands/syntax.rs b/src-tauri/src/commands/syntax.rs index d3292cee..ebf8585c 100644 --- a/src-tauri/src/commands/syntax.rs +++ b/src-tauri/src/commands/syntax.rs @@ -112,3 +112,13 @@ pub async fn syntax_structural_ranges( ) -> IdeResult { ide_host::ops::syntax::syntax_structural_ranges(&state.host, window.label(), doc, pos, request).await } + +#[tauri::command] +#[specta::specta] +pub async fn jvm_file_presentations( + window: Window, + state: State<'_, AppState>, + paths: Vec, +) -> IdeResult> { + ide_host::ops::jvm_icons::jvm_file_presentations(&state.host, window.label(), paths).await +} diff --git a/src/bindings.ts b/src/bindings.ts index 559ec669..b34885d0 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -226,6 +226,7 @@ export const commands = { * Large documents fall back to word / line / document ranges. */ docSelectionRanges: (doc: DocId, offset: number) => typedError(__TAURI_INVOKE("doc_selection_ranges", { doc, offset })), + jvmFilePresentations: (paths: string[]) => typedError(__TAURI_INVOKE("jvm_file_presentations", { paths })), /** * What the structure service supports for a language (`tree`: every query, * `locals`: scope analysis). @@ -3737,6 +3738,19 @@ export type JdkVendor = { name: string, }; +export type JvmFilePresentation = { + path: string, + presentation: JvmPresentation, +}; + +export type JvmPresentation = { + kind: JvmTypeKind | null, + springBoot: boolean, + runnable: boolean, +}; + +export type JvmTypeKind = "class" | "abstractClass" | "interface" | "enum" | "record" | "annotation" | "exception" | "object"; + /** Editor › Code Style › › Tabs and Indents (unset fields inherit). */ export type LanguageCodeStyle = LanguageCodeStyle_Serialize | LanguageCodeStyle_Deserialize; diff --git a/src/editor/core/view/EditorArea.tsx b/src/editor/core/view/EditorArea.tsx index 8b65856d..e91bebf3 100644 --- a/src/editor/core/view/EditorArea.tsx +++ b/src/editor/core/view/EditorArea.tsx @@ -1,3 +1,4 @@ +import { useFilePresentationIcons } from "@/platform/filePresentation"; /** * The editor area: split layout of panes, each with IDEA-style tabs (preview * tab in italics, pinned tabs, modified dot, file icons coloured by file @@ -145,6 +146,9 @@ function PaneView({ paneId }: { paneId: string }) { const { showExtension, markModified, showCloseButton, showFileIcon, showDirectoryForNonUnique, showFullPathOnHover } = tabPrefs; + const fileIcons = useFilePresentationIcons( + showFileIcon ? tabs.flatMap((t) => (t.input.kind === "file" && t.input.path ? [t.input.path] : [])) : [], + ); const items: TabItem[] = useMemo(() => { const names = tabs.map((t) => tabTitle(t.input, kinds)); return tabs.map((t, i) => { @@ -167,7 +171,11 @@ function PaneView({ paneId }: { paneId: string }) { ), text: dupe ? `${name} — ${dupe}` : name, - icon: showFileIcon ? tabIcon(t.input, kinds) : undefined, + icon: showFileIcon + ? t.input.kind === "file" && t.input.path + ? fileIcons.get(t.input.path) + : tabIcon(t.input, kinds) + : undefined, italic: t.preview, modified: dirty[i], closable: showCloseButton, @@ -178,6 +186,7 @@ function PaneView({ paneId }: { paneId: string }) { }; }); }, [ + fileIcons, tabs, kinds, dirty, diff --git a/src/features/project-tree/ProjectView.module.css b/src/features/project-tree/ProjectView.module.css index e947b4ec..c23ebaf4 100644 --- a/src/features/project-tree/ProjectView.module.css +++ b/src/features/project-tree/ProjectView.module.css @@ -125,3 +125,16 @@ padding: 8px 12px; color: var(--fg-subtle); } + +.iconStack { + position: relative; + display: inline-flex; + flex: none; + width: 16px; + height: 16px; +} +.runnable { + position: absolute; + inset: 0; + pointer-events: none; +} diff --git a/src/features/project-tree/ProjectView.tsx b/src/features/project-tree/ProjectView.tsx index d9e2e8a8..f54af515 100644 --- a/src/features/project-tree/ProjectView.tsx +++ b/src/features/project-tree/ProjectView.tsx @@ -24,7 +24,7 @@ import { setContext } from "@/platform/context"; import { tx, useTxTemplate } from "@/platform/i18n"; import { isTauri } from "@/platform/ipc"; import type { RootFolder } from "@/platform/languageSettings"; -import { useEffectiveRoots } from "@/platform/projectRoots"; +import { useDetectedRoots, useEffectiveRoots } from "@/platform/projectRoots"; import { macStroke, strokeOf } from "@/platform/keymap"; import { OWNS_FILE_DROP, prepareMenuGroups, useFileDecoration } from "@/workbench/contributions"; import { @@ -44,6 +44,9 @@ import { } from "@/ui"; import { contextMenuItems, menuGroupContext, newMenuItems } from "./contextMenu"; import { openNewPopup } from "./newPopupModel"; +import { presentationIcon, useJvmPresentation } from "@/platform/filePresentation"; +import { moduleFolderIcon, useProjectPresentation, type BuildPresentation } from "@/platform/projectPresentation"; +import { relativePath } from "@/platform/project"; import { folderKind } from "./folderKind"; import { useHomeDir } from "./home"; import { keyLike, nodeKeyFor, visibleInfo, type ProjectNode } from "./model"; @@ -109,11 +112,23 @@ const getLabel = (n: ProjectNode) => n.label; const isExpandable = (n: ProjectNode) => n.dir && !n.placeholder; const loadChildren = (n: ProjectNode) => loadDir(n.path); -function iconFor(node: ProjectNode, roots: readonly RootFolder[], root: string | null): string { +function iconFor( + node: ProjectNode, + roots: readonly RootFolder[], + root: string | null, + build: BuildPresentation, + detected: readonly RootFolder[], +): string { if (node.placeholder) return node.placeholder === "dir" ? "folder" : "file"; if (node.icon) return node.icon; - if (node.root) return "module"; - if (node.dir) return folderIconName(folderKind(node, roots, root)); + if (node.dir) { + const kind = folderKind(node, roots, root, build, detected); + if (kind !== "plain") return folderIconName(kind); + const rel = root ? (node.path === root ? "" : relativePath(root, node.path)) : null; + const module = rel === null ? null : moduleFolderIcon(rel, node.root, build.modules); + if (module) return module; + return node.root ? "module" : "folder"; + } return fileIconName(node.label); } @@ -246,7 +261,13 @@ const Row = memo(function Row({ const deco = useFileDecoration(node.path); const roots = useEffectiveRoots(); const root = useProjectTree((s) => s.root); - const icon = iconFor(node, roots, root); + const detected = useDetectedRoots(); + const build = useProjectPresentation(); + const file = useJvmPresentation(node.dir || node.placeholder ? "" : node.path, node.entry?.mtimeMs); + const icon = + node.dir || node.placeholder || node.icon + ? iconFor(node, roots, root, build, detected) + : presentationIcon(node.path, file, roots, root, build.spring); const isEditing = !!editing && (node.placeholder ? editing.kind === "new" : editing.kind === "rename" && editing.key === node.key); const faded = node.ignored || deco.faded; @@ -263,11 +284,16 @@ const Row = memo(function Row({ onDoubleClick={(e) => onDoubleClick(node, e)} onPointerDown={(e) => onPointerDown(node, e)} > - + + + {!!file?.runnable && icon !== "spring-boot" && icon !== "jvm-test" && ( + + )} + {isEditing && editing ? ( + folderKind({ path: `/p/${rel}`, excluded }, rs, "/p", build); +describe("JVM folder presentation", () => { + it.each([ + ["src/main/java", "source"], + ["src/test/java", "test"], + ["src/main/resources", "resources"], + ["src/test/resources", "testResources"], + ["src/main/java/com/example", "package"], + ["src/test/java/com", "package"], + ["src/main/resources/templates", "plain"], + ["plain", "plain"], + ])("%s: %s", (path, expected) => expect(kind(path)).toBe(expected)); + it.each([ + ["target/generated-sources", "generatedSource"], + ["target/generated-test-sources", "generatedTest"], + ["app/build/generated", "generatedSource"], + ["app/build/classes/kotlin", "compiled"], + ["target/classes", "compiled"], + ["target/site/apidocs", "javadoc"], + ["app/build/docs/javadoc", "javadoc"], + ])("%s: %s", (path, expected) => { + expect(kind(path)).toBe(expected); + expect(kind(path, true)).toBe("excluded"); + expect(outputFolderKind(path, new Map())).toBeNull(); + }); + it("shows packages below custom marked JVM source roots", () => { + expect(kind("custom/com/example", false, [{ path: "custom", kind: "source", detectedFrom: null }])).toBe("package"); + }); + it("keeps marks and generated source roots ahead of output and exclusion", () => { + expect( + kind("target/generated-sources", true, [ + { path: "target/generated-sources", kind: "source", detectedFrom: "pom.xml" }, + ]), + ).toBe("source"); + expect(kind("target/classes", false, [{ path: "target/classes", kind: "test", detectedFrom: null }])).toBe("test"); + expect( + kind("src/main/java/com", false, [...roots, { path: "src/main/java/com", kind: "excluded", detectedFrom: null }]), + ).toBe("excluded"); + }); + it.each(["generated-sources", "src/main/java/classes", "other/build/generated", "build/generated"])( + "does not guess output roles for %s", + (path) => expect(outputFolderKind(path, build.modules)).toBeNull(), + ); +}); diff --git a/src/features/project-tree/folderKind.ts b/src/features/project-tree/folderKind.ts index 194238ee..b8799417 100644 --- a/src/features/project-tree/folderKind.ts +++ b/src/features/project-tree/folderKind.ts @@ -1,8 +1,10 @@ /** Folder icon kind in the project view, from the effective roots. */ -import { rootRole, type FolderRole, type RootFolder } from "@/platform/languageSettings"; +import { rootFor, rootRole, type FolderRole, type RootFolder } from "@/platform/languageSettings"; import { basename, relativePath } from "@/platform/project"; import type { FolderKind } from "@/ui"; import type { ProjectNode } from "./model"; +import { packageKindAt } from "./packages"; +import type { BuildPresentation } from "@/platform/projectPresentation"; const ROLE_KIND: Record = { source: "source", @@ -20,9 +22,39 @@ export function folderKind( node: Pick, roots: readonly RootFolder[], root: string | null, + build?: BuildPresentation, + detected: readonly RootFolder[] = [], ): FolderKind { - if (node.excluded) return "excluded"; - const role = root ? rootRole(roots, relativePath(root, node.path)) : null; + const rel = root ? (node.path === root ? "" : relativePath(root, node.path)) : null; + const role = rel === null ? null : rootRole(roots, rel); + const exact = roots.find((r) => r.path === rel); + const output = rel === null ? null : outputFolderKind(rel, build?.modules ?? new Map()); + if (role && (exact?.detectedFrom === null || output === "generatedSource" || output === "generatedTest")) + return ROLE_KIND[role]; + const containing = rel === null ? null : rootFor(roots, rel); + if (node.excluded || containing?.kind === "excluded") return "excluded"; if (role) return ROLE_KIND[role]; + if (output) return output; + if (rel !== null && (containing?.kind === "source" || containing?.kind === "test")) { + const inModule = [...(build?.modules.keys() ?? [])].some((dir) => !dir || rel.startsWith(`${dir}/`)); + if (inModule || packageKindAt(rel, roots, detected) === "jvm") return "package"; + } return basename(node.path) === ".github" ? "github" : "plain"; } + +/** Output roles are relative to detected JVM modules, never arbitrary package names. */ +export function outputFolderKind(rel: string, modules: BuildPresentation["modules"]): FolderKind | null { + const parts = rel.split("/"); + for (let i = 0; i < parts.length - 1; i++) { + const output = parts[i]; + if (output !== "target" && output !== "build") continue; + const tool = modules.get(parts.slice(0, i).join("/")); + if (tool !== (output === "target" ? "maven" : "gradle")) continue; + const path = parts.slice(i + 1).join("/"); + if (/^generated-test-sources(\/|$)/.test(path) || /^generated\/.*\btest\b/.test(path)) return "generatedTest"; + if (/^(generated-sources|generated)(\/|$)/.test(path)) return "generatedSource"; + if (/^(classes|test-classes)(\/|$)/.test(path)) return "compiled"; + if (/^(site\/apidocs|apidocs|docs\/javadoc|javadoc)(\/|$)/.test(path)) return "javadoc"; + } + return null; +} diff --git a/src/features/search/everywhere/SearchEverywhere.tsx b/src/features/search/everywhere/SearchEverywhere.tsx index 0ddb04ed..86b116fc 100644 --- a/src/features/search/everywhere/SearchEverywhere.tsx +++ b/src/features/search/everywhere/SearchEverywhere.tsx @@ -1,3 +1,4 @@ +import { useFilePresentation } from "@/platform/filePresentation"; import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent } from "react"; import clsx from "clsx"; import { actionToggled, executeAction, getAction } from "@/platform/actions"; @@ -16,7 +17,6 @@ import { SymbolBadge, Select, Toggle, - fileIconName, listNavigate, type MenuItem, T, @@ -830,9 +830,10 @@ function FileRow({ line: number | null; }) { const deco = useFileDecoration(path); + const { icon } = useFilePresentation(path); return ( <> - + diff --git a/src/features/search/recent/RecentPopups.tsx b/src/features/search/recent/RecentPopups.tsx index 20220c24..efab57b6 100644 --- a/src/features/search/recent/RecentPopups.tsx +++ b/src/features/search/recent/RecentPopups.tsx @@ -1,3 +1,4 @@ +import { useFilePresentation } from "@/platform/filePresentation"; import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent as ReactMouseEvent } from "react"; import clsx from "clsx"; import { editorsFor, getNavigationHistory } from "@/editor/api"; @@ -353,9 +354,10 @@ function ToolRow({ tw, indices }: { tw: ToolWindowContribution; indices: number[ function RecentFileRow({ item, current }: { item: RecentFileItem; current: boolean }) { const deco = useFileDecoration(item.editor ? "" : item.path); + const { icon } = useFilePresentation(item.editor ? "" : item.path); return ( <> - + ({ + resolve: vi.fn(), + event: undefined as undefined | ((e: { created: string[]; modified: string[]; removed: string[] }) => void), +})); +vi.mock("./ipc", async (original) => ({ + ...(await original()), + isTauri: () => true, + commands: { jvmFilePresentations: backend.resolve }, + onIdeEvent: (name: string, handler: typeof backend.event) => { + if (name === "fsChanged") backend.event = handler; + return () => {}; + }, +})); +vi.mock("./projectPresentation", () => ({ useProjectPresentation: () => ({ spring: false, modules: new Map() }) })); +import { useFilePresentation, useFilePresentationIcons } from "./filePresentation"; +const answer = (path: string, kind = "interface") => ({ + path, + presentation: { kind, springBoot: false, runnable: false }, +}); +const tick = async () => { + await act(async () => { + await vi.runOnlyPendingTimersAsync(); + }); +}; + +describe("visible file resolver", () => { + beforeEach(() => { + vi.useFakeTimers(); + backend.resolve + .mockReset() + .mockImplementation(async (paths: string[]) => ({ status: "ok", data: paths.map((p) => answer(p)) })); + }); + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + it("shows a plain icon immediately and batches mounted paths only", async () => { + const { result } = renderHook(() => useFilePresentationIcons(["/p/Foo.java", "/p/Bar.kt", "/p/config.yml"])); + expect(result.current.get("/p/Foo.java")).toBe("file-java"); + expect(backend.resolve).not.toHaveBeenCalled(); + await tick(); + expect(backend.resolve).toHaveBeenCalledOnce(); + expect(backend.resolve.mock.calls[0]?.[0]).toEqual(["/p/Foo.java", "/p/Bar.kt"]); + expect(result.current.get("/p/Foo.java")).toBe("interface"); + }); + it("drops unmounted requests and splits large sets into bounded batches", async () => { + const dropped = renderHook(() => useFilePresentation("/p/Invisible.java")); + dropped.unmount(); + const { result } = renderHook(() => + useFilePresentationIcons(Array.from({ length: 130 }, (_, i) => `/p/F${i}.java`)), + ); + await tick(); + await tick(); + await tick(); + expect(backend.resolve.mock.calls.map(([paths]) => paths.length)).toEqual([64, 64, 2]); + expect(result.current.get("/p/F129.java")).toBe("interface"); + expect(backend.resolve.mock.calls.flatMap(([paths]) => paths)).not.toContain("/p/Invisible.java"); + }); + it("invalidates on watcher changes and rejects an in-flight stale result", async () => { + let finish!: (value: unknown) => void; + backend.resolve.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const { result } = renderHook(() => useFilePresentation("/p/Foo.java")); + await tick(); + act(() => backend.event?.({ created: [], modified: ["/p/Foo.java"], removed: [] })); + await act(async () => { + finish({ status: "ok", data: [answer("/p/Foo.java", "class")] }); + }); + expect(result.current.icon).toBe("file-java"); + await tick(); + expect(result.current.icon).toBe("interface"); + expect(backend.resolve).toHaveBeenCalledTimes(2); + }); + it("rechecks changed mtimes while preserving cached results for unchanged rows", async () => { + const { result, rerender } = renderHook(({ mtime }) => useFilePresentation("/p/Foo.java", mtime), { + initialProps: { mtime: 1 }, + }); + await tick(); + rerender({ mtime: 1 }); + await tick(); + expect(backend.resolve).toHaveBeenCalledOnce(); + rerender({ mtime: 2 }); + await tick(); + expect(backend.resolve).toHaveBeenCalledTimes(2); + expect(result.current.icon).toBe("interface"); + }); + it("caches failures instead of continuously retrying visible batches", async () => { + backend.resolve.mockRejectedValue(new Error("offline")); + renderHook(() => useFilePresentationIcons(Array.from({ length: 65 }, (_, i) => `/p/F${i}.java`))); + await tick(); + await tick(); + await tick(); + expect(backend.resolve).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/platform/filePresentation.test.ts b/src/platform/filePresentation.test.ts new file mode 100644 index 00000000..066b001e --- /dev/null +++ b/src/platform/filePresentation.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { presentationIcon } from "./filePresentation"; +import type { RootFolder } from "./languageSettings"; +import type { JvmPresentation } from "./ipc"; + +const roots: RootFolder[] = [ + { path: "src/main/java", kind: "source", detectedFrom: "pom.xml" }, + { path: "src/test/java", kind: "test", detectedFrom: "pom.xml" }, + { path: "src/main/resources", kind: "resources", detectedFrom: "pom.xml" }, + { path: "src/test/resources", kind: "testResources", detectedFrom: "pom.xml" }, +]; +const type = (kind: JvmPresentation["kind"]): JvmPresentation => ({ kind, springBoot: false, runnable: false }); +const icon = (path: string, info: JvmPresentation | null = null, spring = false) => + presentationIcon(`/p/${path}`, info, roots, "/p", spring); + +describe("JVM file presentation", () => { + it.each([ + ["class", "class"], + ["abstractClass", "class-abstract"], + ["interface", "interface"], + ["enum", "enum"], + ["record", "record"], + ["annotation", "annotation"], + ["exception", "exception"], + ["object", "kotlin-object"], + ] as const)("maps %s to %s", (kind, expected) => expect(icon("Foo.java", type(kind))).toBe(expected)); + it.each(["LIMIT.java", "AUDIT.java", "Contest.java"])("keeps the type icon of %s", (path) => + expect(icon(path, type("class"))).toBe("class"), + ); + it.each(["FooTest.java", "FooTests.java", "FooIT.java", "FooTest.kt", "src/test/java/Anything.java"])( + "marks test classes: %s", + (path) => expect(icon(path, type("class"))).toBe("jvm-test"), + ); + it("keeps files without declarations and binaries as file types", () => { + expect(icon("Foo.java", type(null))).toBe("file-java"); + expect(icon("FooTest.java", type(null))).toBe("file-java"); + expect(icon("Foo.kt")).toBe("file-kotlin"); + expect(icon("Foo.class")).toBe("file-binary"); + expect(icon("Contest.java", type("class"))).toBe("class"); + }); + it.each(["application.yml", "application-dev.yaml", "bootstrap.properties", "bootstrap-local.yml"])( + "requires Spring or a resource root: %s", + (name) => { + expect(icon(`src/main/resources/${name}`)).toBe("spring-config"); + expect(icon(`src/test/resources/${name}`)).toBe("spring-config"); + expect(icon(name, null, true)).toBe("spring-config"); + expect(icon(name)).not.toBe("spring-config"); + }, + ); + it("uses the entire Boot icon on application classes", () => { + expect(icon("App.java", { ...type("class"), springBoot: true, runnable: true })).toBe("spring-boot"); + }); +}); diff --git a/src/platform/filePresentation.ts b/src/platform/filePresentation.ts new file mode 100644 index 00000000..26ffe295 --- /dev/null +++ b/src/platform/filePresentation.ts @@ -0,0 +1,189 @@ +/** Visible-file JVM presentation: one bounded IPC batch, shared cache and watcher. */ +import { useEffect, useMemo } from "react"; +import { create } from "zustand"; +import { fileIconName, type IconName } from "@/ui/icons"; +import { call, commands, isTauri, onIdeEvent, type JvmPresentation } from "./ipc"; +import { basename, relativePath, useProject } from "./project"; +import { rootFor, type RootFolder } from "./languageSettings"; +import { useEffectiveRoots } from "./projectRoots"; +import { useProjectPresentation } from "./projectPresentation"; + +const JVM = /\.(java|kt)$/; +const MAX_CACHE = 4096; +const pending = new Map(); +const visible = new Map(); +const versions = new Map(); +const mtimes = new Map(); +const cache = create<{ entries: ReadonlyMap }>(() => ({ entries: new Map() })); +let timer: ReturnType | undefined; +let running = false; +let epoch = 0; +let dispose: (() => void) | undefined; +let users = 0; + +function enqueue(path: string) { + if (!JVM.test(path) || cache.getState().entries.has(path) || !isTauri()) return; + pending.set(path, versions.get(path) ?? 0); + if (!running && !timer) + timer = setTimeout(() => { + timer = undefined; + void flush(); + }, 0); +} +async function flush() { + if (running) return; + const batch = [...pending].filter(([p]) => visible.has(p)).slice(0, 64); + pending.clear(); + if (!batch.length) return; + running = true; + const gen = epoch; + try { + const results = await call(commands.jvmFilePresentations(batch.map(([p]) => p))); + if (gen !== epoch) return; + const entries = new Map(cache.getState().entries); + for (const [path, version] of batch) { + if ((versions.get(path) ?? 0) !== version) continue; + entries.delete(path); + entries.set(path, results.find((r) => r.path === path)?.presentation ?? null); + } + while (entries.size > MAX_CACHE) entries.delete(entries.keys().next().value!); + cache.setState({ entries }); + } catch { + if (gen === epoch) { + const entries = new Map(cache.getState().entries); + for (const [path, version] of batch) if ((versions.get(path) ?? 0) === version) entries.set(path, null); + cache.setState({ entries }); + } + } finally { + running = false; + for (const path of versions.keys()) if (!visible.has(path)) versions.delete(path); + // Requests that arrived during IPC stay bounded to the mounted consumers. + for (const p of visible.keys()) if (!batch.some(([b]) => b === p)) enqueue(p); + if (pending.size && !timer) + timer = setTimeout(() => { + timer = undefined; + void flush(); + }, 0); + } +} + +export function invalidateFilePresentations(paths: readonly string[]) { + const entries = new Map(cache.getState().entries); + for (const key of new Set([...entries.keys(), ...visible.keys()])) { + if (!paths.some((p) => key === p || key.startsWith(p + "/"))) continue; + entries.delete(key); + versions.set(key, (versions.get(key) ?? 0) + 1); + } + cache.setState({ entries }); + for (const p of visible.keys()) enqueue(p); +} +function mount(path: string) { + if (!JVM.test(path)) return () => {}; + if (users++ === 0) { + cache.setState({ entries: new Map() }); + mtimes.clear(); + const fs = onIdeEvent("fsChanged", (e) => invalidateFilePresentations([...e.created, ...e.modified, ...e.removed])); + const project = useProject.subscribe((s, prev) => { + if (s.project === prev.project) return; + epoch++; + versions.clear(); + pending.clear(); + cache.setState({ entries: new Map() }); + }); + dispose = () => { + fs(); + project(); + }; + } + visible.set(path, (visible.get(path) ?? 0) + 1); + enqueue(path); + return () => { + const count = (visible.get(path) ?? 1) - 1; + if (count) visible.set(path, count); + else { + visible.delete(path); + pending.delete(path); + } + if (--users === 0) { + epoch++; + pending.clear(); + dispose?.(); + } + }; +} + +export function presentationIcon( + path: string, + info: JvmPresentation | null | undefined, + roots: readonly RootFolder[], + root: string | null, + spring: boolean, +): IconName { + const name = basename(path); + const role = root ? rootFor(roots, relativePath(root, path))?.kind : null; + if ( + /^(application|bootstrap).*\.(yml|yaml|properties)$/.test(name) && + (spring || role === "resources" || role === "testResources") + ) + return "spring-config"; + if (!info?.kind) return fileIconName(name); + if (info.springBoot) return "spring-boot"; + if (/(Tests?|[a-z\d]IT)\.(java|kt)$/.test(name) || role === "test") return "jvm-test"; + const kind: Record, IconName> = { + class: "class", + abstractClass: "class-abstract", + interface: "interface", + enum: "enum", + record: "record", + annotation: "annotation", + exception: "exception", + object: "kotlin-object", + }; + return kind[info.kind]; +} + +export function useJvmPresentation(path: string, mtime?: number | null) { + useEffect(() => (path ? mount(path) : undefined), [path]); + // Directory refresh metadata also invalidates results if a watcher event was missed. + useEffect(() => { + if (mtime != null && JVM.test(path)) { + const previous = mtimes.get(path); + mtimes.set(path, mtime); + if (previous !== undefined && previous !== mtime) invalidateFilePresentations([path]); + if (mtimes.size > MAX_CACHE) mtimes.delete(mtimes.keys().next().value!); + } + }, [path, mtime]); + return cache((s) => s.entries.get(path)); +} + +export function useFilePresentation(path: string, mtime?: number | null) { + const info = useJvmPresentation(path, mtime); + const roots = useEffectiveRoots(); + const root = useProject((s) => s.project?.root ?? null); + const build = useProjectPresentation(); + const icon = presentationIcon(path, info, roots, root, build.spring); + return { icon, runnable: !!info?.runnable && icon !== "spring-boot" && icon !== "jvm-test" }; +} + +/** Tab strips resolve their mounted file tabs through the same batch as tree rows. */ +export function useFilePresentationIcons(paths: readonly string[]) { + const key = JSON.stringify(paths); + useEffect(() => { + const disposers = (JSON.parse(key) as string[]).map(mount); + return () => disposers.forEach((dispose) => dispose()); + }, [key]); + const entries = cache((s) => s.entries); + const roots = useEffectiveRoots(); + const root = useProject((s) => s.project?.root ?? null); + const build = useProjectPresentation(); + return useMemo( + () => + new Map( + (JSON.parse(key) as string[]).map((path) => [ + path, + presentationIcon(path, entries.get(path), roots, root, build.spring), + ]), + ), + [key, entries, roots, root, build.spring], + ); +} diff --git a/src/platform/projectPresentation.test.ts b/src/platform/projectPresentation.test.ts new file mode 100644 index 00000000..54f6524d --- /dev/null +++ b/src/platform/projectPresentation.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from "vitest"; +import { buildPresentation, moduleFolderIcon } from "./projectPresentation"; +import type { BuildModel } from "./ipc"; + +const model = (partial: unknown) => ({ maven: null, gradle: null, ...(partial as object) }) as BuildModel; +describe("build presentation context", () => { + it.each(["maven", "gradle"] as const)("uses distinct %s root and nested module glyphs", (tool) => { + const modules = new Map([ + ["", tool], + ["app", tool], + ]); + expect(moduleFolderIcon("", true, modules)).toBe(`${tool}-project`); + expect(moduleFolderIcon("app", false, modules)).toBe(`${tool}-module`); + expect(moduleFolderIcon("plain", false, modules)).toBeNull(); + }); + it("includes Maven aggregators and modules without source roots", () => { + const context = buildPresentation( + model({ + maven: { + projects: [ + { dir: "", dependencies: [], plugins: [] }, + { dir: "app", dependencies: [{ groupId: "org.springframework.boot" }], plugins: [] }, + ], + }, + }), + ); + expect([...context.modules]).toEqual([ + ["", "maven"], + ["app", "maven"], + ]); + expect(context.spring).toBe(true); + }); + it("includes Gradle root and nested project directories", () => { + const context = buildPresentation( + model({ + gradle: { + projects: [ + { dir: "", tasks: [] }, + { dir: "lib/core", tasks: [] }, + ], + }, + }), + ); + expect([...context.modules]).toEqual([ + ["", "gradle"], + ["lib/core", "gradle"], + ]); + expect(context.spring).toBe(false); + }); + it("detects Spring build plugins and Gradle Boot tasks", () => { + expect( + buildPresentation( + model({ + maven: { projects: [{ dir: "", dependencies: [], plugins: [{ groupId: "org.springframework.boot" }] }] }, + }), + ).spring, + ).toBe(true); + expect(buildPresentation(model({ gradle: { projects: [{ dir: "", tasks: [{ name: "bootRun" }] }] } })).spring).toBe( + true, + ); + }); + it("keeps plain folders plain", () => expect(buildPresentation(model({})).modules.size).toBe(0)); +}); diff --git a/src/platform/projectPresentation.ts b/src/platform/projectPresentation.ts new file mode 100644 index 00000000..9e064815 --- /dev/null +++ b/src/platform/projectPresentation.ts @@ -0,0 +1,83 @@ +/** Read-only build context shared by file and folder presentation consumers. */ +import { useEffect } from "react"; +import { create } from "zustand"; +import { call, commands, isTauri, onIdeEvent, type BuildModel } from "./ipc"; +import { useProject } from "./project"; + +export interface BuildPresentation { + modules: ReadonlyMap; + spring: boolean; +} +const EMPTY: BuildPresentation = { modules: new Map(), spring: false }; +export const useBuildPresentation = create(() => EMPTY); + +export function buildPresentation(model: BuildModel): BuildPresentation { + const modules = new Map(); + for (const p of model.maven?.projects ?? []) modules.set(p.dir, "maven"); + for (const p of model.gradle?.projects ?? []) modules.set(p.dir, "gradle"); + const spring = + (model.maven?.projects ?? []).some( + (p) => + p.dependencies.some((d) => d.groupId.startsWith("org.springframework")) || + p.plugins.some((p) => p.groupId.startsWith("org.springframework")), + ) || (model.gradle?.projects ?? []).some((p) => p.tasks.some((t) => t.name === "bootRun")); + return { modules, spring }; +} + +export function moduleFolderIcon(rel: string, root: boolean, modules: BuildPresentation["modules"]): string | null { + const tool = modules.get(rel); + return tool ? `${tool}-${root ? "project" : "module"}` : null; +} + +let users = 0; +let generation = 0; +let dispose: (() => void) | undefined; +async function load() { + const gen = ++generation; + if (!useProject.getState().project || !isTauri()) { + useBuildPresentation.setState(EMPTY); + return; + } + try { + const model = await call(commands.buildModel()); + if (generation === gen) useBuildPresentation.setState(buildPresentation(model)); + } catch { + /* Presentation keeps its filename fallback. */ + } +} + +export function useProjectPresentation(): BuildPresentation { + useEffect(() => { + if (users++ === 0) { + void load(); + const project = useProject.subscribe((s, prev) => { + if (s.project === prev.project) return; + useBuildPresentation.setState(EMPTY); + void load(); + }); + const roots = onIdeEvent("projectRootsChanged", () => void load()); + let timer: ReturnType | undefined; + const files = onIdeEvent("fsChanged", (e) => { + if ( + ![...e.created, ...e.modified, ...e.removed].some((p) => + /(?:^|\/)(pom\.xml|(?:build|settings)\.gradle(?:\.kts)?)$/.test(p), + ) + ) + return; + clearTimeout(timer); + timer = setTimeout(() => void load(), 100); + }); + dispose = () => { + project(); + roots(); + files(); + clearTimeout(timer); + generation++; + }; + } + return () => { + if (--users === 0) dispose?.(); + }; + }, []); + return useBuildPresentation(); +} diff --git a/src/ui/expui/gradle/gradle.svg b/src/ui/expui/gradle/gradle.svg new file mode 100644 index 00000000..29f3e59e --- /dev/null +++ b/src/ui/expui/gradle/gradle.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/expui/gradle/gradle@20x20.svg b/src/ui/expui/gradle/gradle@20x20.svg new file mode 100644 index 00000000..8a44d0cd --- /dev/null +++ b/src/ui/expui/gradle/gradle@20x20.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/expui/gradle/gradle@20x20_dark.svg b/src/ui/expui/gradle/gradle@20x20_dark.svg new file mode 100644 index 00000000..819cf4b4 --- /dev/null +++ b/src/ui/expui/gradle/gradle@20x20_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/expui/gradle/gradleSubproject.svg b/src/ui/expui/gradle/gradleSubproject.svg new file mode 100644 index 00000000..f5cd5ab0 --- /dev/null +++ b/src/ui/expui/gradle/gradleSubproject.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/ui/expui/gradle/gradleSubproject_dark.svg b/src/ui/expui/gradle/gradleSubproject_dark.svg new file mode 100644 index 00000000..b568277a --- /dev/null +++ b/src/ui/expui/gradle/gradleSubproject_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/ui/expui/gradle/gradle_dark.svg b/src/ui/expui/gradle/gradle_dark.svg new file mode 100644 index 00000000..37c055ab --- /dev/null +++ b/src/ui/expui/gradle/gradle_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/expui/kotlin/objectKotlin.svg b/src/ui/expui/kotlin/objectKotlin.svg new file mode 100644 index 00000000..8393e60c --- /dev/null +++ b/src/ui/expui/kotlin/objectKotlin.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/ui/expui/kotlin/objectKotlin_dark.svg b/src/ui/expui/kotlin/objectKotlin_dark.svg new file mode 100644 index 00000000..240bbdf8 --- /dev/null +++ b/src/ui/expui/kotlin/objectKotlin_dark.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/ui/expui/nodes/classAbstract.svg b/src/ui/expui/nodes/classAbstract.svg new file mode 100644 index 00000000..212d1502 --- /dev/null +++ b/src/ui/expui/nodes/classAbstract.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/ui/expui/nodes/classAbstract_dark.svg b/src/ui/expui/nodes/classAbstract_dark.svg new file mode 100644 index 00000000..6cf5dbf2 --- /dev/null +++ b/src/ui/expui/nodes/classAbstract_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/ui/expui/nodes/compiledClassesFolder.svg b/src/ui/expui/nodes/compiledClassesFolder.svg new file mode 100644 index 00000000..8b054bd7 --- /dev/null +++ b/src/ui/expui/nodes/compiledClassesFolder.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/ui/expui/nodes/compiledClassesFolder_dark.svg b/src/ui/expui/nodes/compiledClassesFolder_dark.svg new file mode 100644 index 00000000..9402ce5d --- /dev/null +++ b/src/ui/expui/nodes/compiledClassesFolder_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/ui/expui/nodes/exception.svg b/src/ui/expui/nodes/exception.svg new file mode 100644 index 00000000..ab5bae75 --- /dev/null +++ b/src/ui/expui/nodes/exception.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/expui/nodes/exception_dark.svg b/src/ui/expui/nodes/exception_dark.svg new file mode 100644 index 00000000..710a292e --- /dev/null +++ b/src/ui/expui/nodes/exception_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/expui/nodes/generatedSource.svg b/src/ui/expui/nodes/generatedSource.svg new file mode 100644 index 00000000..e75a1b6d --- /dev/null +++ b/src/ui/expui/nodes/generatedSource.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/ui/expui/nodes/generatedSource_dark.svg b/src/ui/expui/nodes/generatedSource_dark.svg new file mode 100644 index 00000000..2f9a7be6 --- /dev/null +++ b/src/ui/expui/nodes/generatedSource_dark.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/ui/expui/nodes/generatedTestRoot.svg b/src/ui/expui/nodes/generatedTestRoot.svg new file mode 100644 index 00000000..158e678e --- /dev/null +++ b/src/ui/expui/nodes/generatedTestRoot.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/ui/expui/nodes/generatedTestRoot_dark.svg b/src/ui/expui/nodes/generatedTestRoot_dark.svg new file mode 100644 index 00000000..b67cfa3b --- /dev/null +++ b/src/ui/expui/nodes/generatedTestRoot_dark.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/ui/expui/nodes/javaDocFolder.svg b/src/ui/expui/nodes/javaDocFolder.svg new file mode 100644 index 00000000..9c997080 --- /dev/null +++ b/src/ui/expui/nodes/javaDocFolder.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/ui/expui/nodes/javaDocFolder_dark.svg b/src/ui/expui/nodes/javaDocFolder_dark.svg new file mode 100644 index 00000000..d0c779ee --- /dev/null +++ b/src/ui/expui/nodes/javaDocFolder_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/ui/expui/nodes/runnableMark.svg b/src/ui/expui/nodes/runnableMark.svg new file mode 100644 index 00000000..1cd8fbe2 --- /dev/null +++ b/src/ui/expui/nodes/runnableMark.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/ui/expui/nodes/runnableMark_dark.svg b/src/ui/expui/nodes/runnableMark_dark.svg new file mode 100644 index 00000000..3bc267ca --- /dev/null +++ b/src/ui/expui/nodes/runnableMark_dark.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/ui/expui/nodes/test.svg b/src/ui/expui/nodes/test.svg new file mode 100644 index 00000000..db47ea54 --- /dev/null +++ b/src/ui/expui/nodes/test.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/expui/nodes/test_dark.svg b/src/ui/expui/nodes/test_dark.svg new file mode 100644 index 00000000..bd319b9b --- /dev/null +++ b/src/ui/expui/nodes/test_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index 603c8f1d..6b6c329b 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -41,6 +41,20 @@ const ICONS = { "folder-library": "nodes/libraryFolder", "folder-github": "nodes/folderGithub", module: "nodes/module", + "folder-generated-source": "nodes/generatedSource", + "folder-generated-test": "nodes/generatedTestRoot", + "folder-compiled": "nodes/compiledClassesFolder", + "folder-javadoc": "nodes/javaDocFolder", + "class-abstract": "nodes/classAbstract", + record: "nodes/record", + annotation: "nodes/annotation", + exception: "nodes/exception", + "jvm-test": "nodes/test", + "runnable-mark": "nodes/runnableMark", + "kotlin-object": "kotlin/objectKotlin", + "spring-config": "openidea/runConfigurations/springBoot", + "gradle-project": "gradle/gradle", + "gradle-module": "gradle/gradleSubproject", "spring-boot": "openidea/runConfigurations/springBoot", file: "fileTypes/text", "file-unknown": "fileTypes/unknown", @@ -917,7 +931,12 @@ export type FolderKind = | "excluded" | "library" | "github" - | "module"; + | "module" + | "package" + | "generatedSource" + | "generatedTest" + | "compiled" + | "javadoc"; /** Folder icon by role (expui has no separate "open" folder glyph). */ export function folderIconName(kind: FolderKind = "plain"): IconName { @@ -938,6 +957,16 @@ export function folderIconName(kind: FolderKind = "plain"): IconName { return "folder-github"; case "module": return "module"; + case "package": + return "package"; + case "generatedSource": + return "folder-generated-source"; + case "generatedTest": + return "folder-generated-test"; + case "compiled": + return "folder-compiled"; + case "javadoc": + return "folder-javadoc"; default: return "folder"; }