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"; }