diff --git a/changes/jetbrains-package-window.md b/changes/jetbrains-package-window.md
new file mode 100644
index 00000000..c7024b96
--- /dev/null
+++ b/changes/jetbrains-package-window.md
@@ -0,0 +1,10 @@
+bump: minor
+type: feat
+The JetBrains plugin (1.6.0) has a Ludic tool window for the package, like the Gradle one:
+- an overview of the package (what it is, entry, version, dependencies, scripts, hooks, app);
+- its commands, scripts and hooks, run by double-click, with each command's hooks shown;
+- its dependencies against package.lock.ludic: the locked version, whether it is fetched
+ and linked, the indirect ones, and Fetch, Update, Verify, Vendor, Add and Remove;
+- the app preview the `app` lines describe, and the asset roots with missing ones marked.
+The bar over package.ludic now appears only when something needs doing (fetch, or no
+toolchain), instead of carrying every command.
diff --git a/tools/editors/jetbrains/README.md b/tools/editors/jetbrains/README.md
index 84768eae..cb407f99 100644
--- a/tools/editors/jetbrains/README.md
+++ b/tools/editors/jetbrains/README.md
@@ -17,7 +17,7 @@ packages cleanly can still fail to load:
scheduled-for-removal API usages.
- `./gradlew runIde` boots a real IDEA 2025.3 with the plugin installed. The
sandbox log line to look for is
- `Loaded custom plugins: LSP4IJ (0.20.1), Ludic (1.5.0)` in
+ `Loaded custom plugins: LSP4IJ (0.20.1), Ludic (1.6.0)` in
`.intellijPlatform/sandbox/Ludic/IC-2025.3/log/idea.log`.
**If the plugin does not appear in Settings -> Plugins after installing**, the
@@ -35,7 +35,7 @@ as the error message, which is not obvious the first time you see it:
```bash
export JAVA_HOME=$(/usr/libexec/java_home -v 21) # macOS
cd tools/editors/jetbrains
-./gradlew buildPlugin # -> build/distributions/Ludic-1.5.0.zip
+./gradlew buildPlugin # -> build/distributions/Ludic-1.6.0.zip
./gradlew runIde # try it in a sandbox IDE
```
@@ -70,6 +70,12 @@ LSP4IJ is a required dependency; the IDE offers to install it for you.
- **Project**: File | New | Project | Ludic (runs `ludic new`), File | New |
Ludic File (program, module, test), Tools | Ludic (run, build, test, fmt,
get, pack, bundle, clean, doctor).
+- **Ludic tool window** (right side, like Gradle's): the package's overview; its
+ commands, scripts and hooks, run by double-click; its dependencies against
+ `package.lock.ludic` (locked version, fetched or not, indirect ones) with Fetch,
+ Update, Verify, Vendor, Add and Remove; the app preview; the asset roots. The bar
+ over `package.ludic` only appears when something needs doing (fetch, or no
+ toolchain found).
There is no debugger: `ludicc` does not emit debug info yet.
diff --git a/tools/editors/jetbrains/gradle.properties b/tools/editors/jetbrains/gradle.properties
index 2951def1..80501a1a 100644
--- a/tools/editors/jetbrains/gradle.properties
+++ b/tools/editors/jetbrains/gradle.properties
@@ -1,6 +1,6 @@
pluginGroup = io.ludic.ide
pluginName = Ludic
-pluginVersion = 1.5.0
+pluginVersion = 1.6.0
# 2024.2 (242) is the oldest platform LSP4IJ 0.20+ installs on, so it is the
# floor here too: claiming older would let an IDE accept this plugin and then
diff --git a/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/project/LudicAppPreview.kt b/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/project/LudicAppPreview.kt
new file mode 100644
index 00000000..f3d141dc
--- /dev/null
+++ b/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/project/LudicAppPreview.kt
@@ -0,0 +1,175 @@
+package io.ludic.ide.project
+
+import com.intellij.openapi.util.text.StringUtil
+import java.io.File
+
+private fun esc(s: String) = StringUtil.escapeXmlEntities(s)
+
+private fun StringBuilder.row(k: String, v: String) {
+ append("
").append(k).append("
").append(v).append(" ")
+}
+
+private fun resolve(dir: File?, value: String): File? {
+ if (value.isBlank()) return null
+ val f = File(value)
+ return if (f.isAbsolute) f else dir?.let { File(it, value) }
+}
+
+/** The app a manifest's `app` lines describe, as `ludic bundle` would build it. */
+object LudicAppPreview {
+ fun html(m: LudicManifest, dir: File?, highlight: String? = null): String {
+ val app = m.app
+ val name = appName(m, dir)
+ fun img(key: String, size: Int): String? {
+ val f = app[key]?.let { resolve(dir, it) }?.takeIf { it.isFile } ?: return null
+ return " "
+ }
+ fun mark(k: String, v: String) = if (k == highlight) "$v " else v
+ return buildString {
+ append("").append(esc(name)).append(".app ")
+ append("")
+ append(img("icon", 64) ?: "")
+ append(" ").append(esc(name)).append("
")
+ append(esc(app["id"] ?: "(no bundle id)")).append(" version ")
+ append(esc(app["version"] ?: m.version ?: "0.1.0"))
+ app["category"]?.let { append(" ").append(esc(it.removePrefix("public.app-category."))) }
+ app["min_macos"]?.let { append(" macOS ").append(esc(it)).append("+") }
+ append("
")
+ app["splash"]?.let { s ->
+ val f = resolve(dir, s)
+ val bg = app["splash_bg"] ?: "#000000"
+ append("
Splash
")
+ if (f?.isFile == true) append(" ") else append(" missing: ").append(esc(s))
+ append("
")
+ }
+ append("
")
+ for ((k, help) in LudicManifest.APP_KEYS) {
+ val v = app[k] ?: continue
+ val shown = if (k == "splash_bg") " ${esc(v)}" else esc(v)
+ row(mark(k, k), mark(k, shown) + "${esc(help)} ")
+ }
+ append("
")
+ m.entry?.let { append("entry ").append(esc(it)).append("
") }
+ }
+ }
+
+ fun appName(m: LudicManifest, dir: File?) = m.app["name"] ?: m.module?.substringAfterLast('/') ?: dir?.name ?: "app"
+}
+
+/** The Ludic tool window's details pane: one page per kind of node. */
+object LudicPackageHtml {
+
+ fun overview(p: LudicPackageInfo): String = buildString {
+ val m = p.manifest
+ append("").append(esc(m.module ?: p.dir.name)).append(" ")
+ val kind = m.kind ?: "source"
+ val what = when {
+ m.app.isNotEmpty() -> "A game or app, shipped as
${esc(LudicAppPreview.appName(m, p.dir))} by
ludic bundle."
+ m.entry != null -> "A program:
ludic run starts
${esc(m.entry)}."
+ else -> "A library other packages import."
+ }
+ append("
").append(what).append("
")
+ LudicManifest.KINDS[kind]?.let { append("
").append(esc(kind)).append(" : ").append(esc(it)).append("
") }
+ append("
")
+ row("Version", esc(m.version ?: "(none)") + (m.version?.let { " published by tag v${esc(it)} " } ?: ""))
+ m.entry?.let { row("Entry", pathCell(p, it)) }
+ row("Location", "${esc(p.dir.path)}")
+ row("Dependencies", depSummary(p))
+ if (m.scripts.isNotEmpty()) row("Scripts", m.scripts.joinToString(" ") { "${esc(it.name)}" })
+ if (m.hooks.isNotEmpty()) row("Hooks", m.hooks.joinToString(" ") { "${esc(it.phase)} ${esc(it.event)}: ${esc(it.command)}" })
+ if (m.packs.isNotEmpty()) row("Asset roots", "${m.packs.size}, " + m.packs.count { resolve(p.dir, it)?.exists() != true }.let { if (it == 0) "all present" else "$it missing " })
+ m.app["name"]?.let { row("App", "${esc(it)} ${esc(m.app["id"] ?: "")} ") }
+ append("
")
+ }
+
+ private fun depSummary(p: LudicPackageInfo): String {
+ val reqs = p.manifest.requires
+ if (reqs.isEmpty()) return "none" + (if (p.transitive.isNotEmpty()) "" else " — everything comes with the toolchain")
+ val bad = reqs.count { p.status(it) != LudicPackageInfo.DepStatus.OK }
+ val base = "${reqs.size} direct, ${p.transitive.size} through them"
+ return p.staleReason?.let { "$base${esc(it)} " } ?: if (bad == 0) "$base, all fetched" else "$base, $bad need fetching "
+ }
+
+ private fun pathCell(p: LudicPackageInfo, v: String): String {
+ val ok = resolve(p.dir, v)?.exists() == true
+ return "${esc(v)}" + if (ok) "" else " missing "
+ }
+
+ fun dependency(p: LudicPackageInfo, req: LudicManifest.Require): String = buildString {
+ append("require \"").append(esc(req.module)).append("\" \"").append(esc(req.version)).append("\" ")
+ append("Needs at least ").append(esc(req.version)).append(" ; Minimum Version Selection picks the lowest version that satisfies every requirement.
")
+ append("")
+ row("Status", esc(p.status(req).label))
+ p.lockedOf(req.module)?.let { lockedRows(it) }
+ row("On disk", p.linkedDir(req.module)?.let { "${esc(it.path)}" } ?: "not linked — Fetch")
+ append("
")
+ }
+
+ fun locked(p: LudicPackageInfo, l: LudicManifest.Locked): String = buildString {
+ append("").append(esc(l.module)).append(" ")
+ append("Pulled in by a dependency, not required here directly.
")
+ lockedRows(l)
+ row("On disk", p.linkedDir(l.module)?.let { "${esc(it.path)}" } ?: "not linked — Fetch")
+ append("
")
+ }
+
+ private fun StringBuilder.lockedRows(l: LudicManifest.Locked) {
+ row("Locked", esc(l.version))
+ row("Kind", esc(l.kind))
+ if (l.provides.isNotEmpty()) row("Provides", l.provides.joinToString { "${esc(it)}.*" })
+ row("Hash", "${esc(l.hash)}")
+ }
+
+ fun lock(p: LudicPackageInfo): String = buildString {
+ append("").append(LudicManifest.LOCK_NAME).append(" ")
+ append("
Written by ludic get: the exact version and content hash of every package the build uses, so every checkout builds the same thing. Commit it; do not edit it.
")
+ val state = when {
+ p.manifest.requires.isEmpty() && p.locked.isNullOrEmpty() -> "Nothing to lock: this package has no dependencies."
+ p.staleReason != null -> "
${esc(p.staleReason!!)} Fetch to bring it up to date."
+ else -> "Up to date with package.ludic."
+ }
+ append("
").append(state).append("
")
+ p.locked?.forEach { row(esc(it.module), "${esc(it.version)} ${esc(it.hash.take(12))}… ") }
+ append("
")
+ }
+
+ fun command(p: LudicPackageInfo, c: LudicPackageInfo.Command): String = buildString {
+ append("ludic ").append(c.verb).append(" ").append(esc(c.help)).append("
")
+ if (c.verb in setOf("run", "build", "bundle")) p.manifest.entry?.let { append("
Entry: ").append(esc(it)).append("
") }
+ hooks(p, c.verb)
+ append("
")
+ }
+
+ fun script(p: LudicPackageInfo, s: LudicManifest.Script): String = buildString {
+ val how = if (s.name in LudicManifest.BUILTIN_COMMANDS) "ludic script ${s.name}" else "ludic ${s.name}"
+ append("").append(esc(how)).append(" ")
+ append("
Runs ").append(esc(s.command)).append(" from the package root; extra arguments are passed on.
")
+ hooks(p, s.name)
+ append("
")
+ }
+
+ fun hook(h: LudicManifest.Hook): String = buildString {
+ append("hook ").append(esc(h.phase)).append(' ').append(esc(h.event)).append(" ")
+ append("
").append(esc(h.command)).append("
")
+ LudicManifest.HOOK_PHASES[h.phase]?.let { append("
").append(esc(it)).append("
") }
+ append("
Runs through the shell in the package root with LUDIC_EVENT, LUDIC_PHASE and LUDIC_PACKAGE_ROOT set.
")
+ }
+
+ fun assets(p: LudicPackageInfo): String = buildString {
+ append("")
+ .append(esc(LudicManifest.DIRECTIVES["pack"] ?: "")).append("
")
+ p.manifest.packs.forEach { v ->
+ val f = resolve(p.dir, v)
+ row("${esc(v)}", if (f?.exists() != true) "missing " else if (f.isDirectory) "directory" else "file")
+ }
+ append("
")
+ }
+
+ private fun StringBuilder.hooks(p: LudicPackageInfo, event: String) {
+ val hs = p.hooksOf(event)
+ if (hs.isEmpty()) return
+ append("Hooks:
")
+ hs.forEach { append("").append(esc(it.phase)).append(": ").append(esc(it.command)).append(" ") }
+ append(" ")
+ }
+}
diff --git a/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/project/LudicManifestEditing.kt b/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/project/LudicManifestEditing.kt
index de131289..10f0e994 100644
--- a/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/project/LudicManifestEditing.kt
+++ b/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/project/LudicManifestEditing.kt
@@ -352,39 +352,7 @@ class LudicManifestDocumentation : AbstractDocumentationProvider() {
/** The app the `app` lines describe, as `ludic bundle` would build it. */
private fun appPreview(file: PsiFile, highlight: String?): String {
- val m = LudicManifest.parse(file.text)
- val app = m.app
- val name = app["name"] ?: m.module?.substringAfterLast('/') ?: file.virtualFile?.parent?.name ?: "app"
- fun img(key: String, size: Int): String? {
- val f = app[key]?.let { resolveManifestPath(file, it) }?.takeIf { it.isFile } ?: return null
- return " "
- }
- fun mark(k: String, v: String) = if (k == highlight) "$v " else v
- return buildString {
- append("").append(esc(name)).append(".app ")
- append("")
- append(img("icon", 64) ?: "")
- append(" ").append(esc(name)).append("
")
- append(esc(app["id"] ?: "(no bundle id)")).append(" version ")
- append(esc(app["version"] ?: m.version ?: "0.1.0"))
- app["category"]?.let { append(" ").append(esc(it.removePrefix("public.app-category."))) }
- app["min_macos"]?.let { append(" macOS ").append(esc(it)).append("+") }
- append("
")
- app["splash"]?.let { s ->
- val f = resolveManifestPath(file, s)
- val bg = app["splash_bg"] ?: "#000000"
- append("
Splash
")
- if (f?.isFile == true) append(" ") else append(" missing: ").append(esc(s))
- append("
")
- }
- append("
")
- for ((k, help) in LudicManifest.APP_KEYS) {
- val v = app[k] ?: continue
- val shown = if (k == "splash_bg") " ${esc(v)}" else esc(v)
- row(mark(k, k), mark(k, shown) + "${esc(help)} ")
- }
- append("
")
- m.entry?.let { append("entry ").append(esc(it)).append("
") }
- }
+ val dir = file.virtualFile?.parent?.path?.let(::File)
+ return LudicAppPreview.html(LudicManifest.parse(file.text), dir, highlight)
}
}
diff --git a/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/project/LudicPackageModel.kt b/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/project/LudicPackageModel.kt
new file mode 100644
index 00000000..0ac55ea5
--- /dev/null
+++ b/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/project/LudicPackageModel.kt
@@ -0,0 +1,100 @@
+package io.ludic.ide.project
+
+import com.intellij.openapi.fileEditor.FileDocumentManager
+import com.intellij.openapi.vfs.LocalFileSystem
+import java.io.File
+
+/**
+ * One package as the Ludic tool window shows it: the manifest (read from the
+ * open editor when there is one, so unsaved edits show), its lock, and where
+ * each dependency is linked.
+ */
+class LudicPackageInfo(val dir: File, val manifest: LudicManifest, val locked: List?) {
+
+ val name: String get() = manifest.module?.substringAfterLast('/') ?: dir.name
+ val manifestFile: File get() = File(dir, LudicManifest.FILE_NAME)
+ val lockFile: File get() = File(dir, LudicManifest.LOCK_NAME)
+
+ /** Why the dependencies need fetching, or null when they look current. */
+ val staleReason: String? get() = LudicManifest.staleReason(dir, manifest)
+
+ /** Hooks that wrap [event] (a command or a script name). */
+ fun hooksOf(event: String) = manifest.hooks.filter { it.event == event }
+
+ fun lockedOf(module: String) = locked?.firstOrNull { it.module == module }
+
+ /** Locked modules nobody in this manifest asked for: pulled in by the dependencies. */
+ val transitive: List
+ get() = locked.orEmpty().filter { l -> manifest.requires.none { it.module == l.module } }
+
+ /** The directory a module is linked at (vendor/ wins, as in the compiler), or null. */
+ fun linkedDir(module: String): File? {
+ val modules = File(dir, System.getenv("LUDIC_MODULES") ?: "ludic_modules")
+ return listOf(File(dir, "vendor/$module"), File(modules, module)).firstOrNull { it.isDirectory }
+ }
+
+ fun status(req: LudicManifest.Require): DepStatus {
+ val l = lockedOf(req.module) ?: return DepStatus.NOT_LOCKED
+ if (linkedDir(req.module) == null) return DepStatus.NOT_LINKED
+ return if (LudicManifest.isSemver(l.version) && LudicManifest.isSemver(req.version) && versionLess(l.version, req.version))
+ DepStatus.BELOW_MINIMUM else DepStatus.OK
+ }
+
+ enum class DepStatus(val label: String) {
+ OK("locked"), NOT_LOCKED("not in the lock"), NOT_LINKED("not linked"), BELOW_MINIMUM("locked below the minimum"),
+ }
+
+ companion object {
+ private val SKIP = setOf("ludic_modules", "vendor", "build", "node_modules", "out", "dist")
+ private const val MAX = 24
+
+ /** Every package.ludic under [base], the root one first, at most two levels down. */
+ fun discover(base: File): List {
+ val found = mutableListOf()
+ fun walk(d: File, depth: Int) {
+ if (found.size >= MAX) return
+ if (File(d, LudicManifest.FILE_NAME).isFile) found += d
+ if (depth == 0) return
+ d.listFiles()?.filter { it.isDirectory && !it.name.startsWith('.') && it.name !in SKIP }
+ ?.sortedBy { it.name }?.forEach { walk(it, depth - 1) }
+ }
+ walk(base, 2)
+ return found.mapNotNull(::load)
+ }
+
+ fun load(dir: File): LudicPackageInfo? {
+ val text = read(File(dir, LudicManifest.FILE_NAME)) ?: return null
+ val lock = read(File(dir, LudicManifest.LOCK_NAME))?.let { LudicManifest.parseLock(it) }
+ return LudicPackageInfo(dir, LudicManifest.parse(text), lock)
+ }
+
+ /** The file's text, from its open document when it has one. */
+ private fun read(f: File): String? {
+ if (!f.isFile) return null
+ val vf = LocalFileSystem.getInstance().findFileByIoFile(f)
+ val doc = vf?.let { FileDocumentManager.getInstance().getCachedDocument(it) }
+ return doc?.text ?: runCatching { f.readText() }.getOrNull()
+ }
+
+ fun versionLess(a: String, b: String): Boolean {
+ val x = a.substringBefore('-').substringBefore('+').split('.').map { it.toIntOrNull() ?: 0 }
+ val y = b.substringBefore('-').substringBefore('+').split('.').map { it.toIntOrNull() ?: 0 }
+ for (i in 0 until 3) if (x.getOrElse(i) { 0 } != y.getOrElse(i) { 0 }) return x.getOrElse(i) { 0 } < y.getOrElse(i) { 0 }
+ return false
+ }
+
+ /** The built-in commands the tool window offers, with what each does. */
+ val COMMANDS = listOf(
+ Command("Run", "run", "Build the entry program and start it."),
+ Command("Build", "build", "Compile the entry program into build/."),
+ Command("Test", "test", "Compile and run every test block under the package."),
+ Command("Bundle", "bundle", "Build the shippable app from the app lines and the packed assets."),
+ Command("Pack", "pack", "Walk the pack roots into a .lpak asset archive."),
+ Command("Format", "fmt", "Format every .ludic file in the package."),
+ Command("Clean", "clean", "Remove build/."),
+ Command("Doctor", "doctor", "Check the toolchain: clang, the install root, the store."),
+ )
+ }
+
+ data class Command(val title: String, val verb: String, val help: String)
+}
diff --git a/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/project/LudicPackageSupport.kt b/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/project/LudicPackageSupport.kt
index 740d802f..014e3303 100644
--- a/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/project/LudicPackageSupport.kt
+++ b/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/project/LudicPackageSupport.kt
@@ -5,6 +5,7 @@ import com.intellij.icons.AllIcons
import com.intellij.ide.IconProvider
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
+import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.editor.Document
import com.intellij.openapi.fileEditor.FileDocumentManager
import com.intellij.openapi.fileEditor.FileEditor
@@ -18,6 +19,7 @@ import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.openapi.vfs.newvfs.BulkFileListener
import com.intellij.openapi.vfs.newvfs.events.VFileEvent
+import com.intellij.openapi.wm.ToolWindowManager
import com.intellij.psi.PsiDocumentManager
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiFile
@@ -72,12 +74,11 @@ internal data class ManifestLine(val index: Int, val range: TextRange, val text:
}
/**
- * The bar across the top of package.ludic and package.lock.ludic.
- *
- * On the manifest: the package's identity and the commands that act on it,
- * turning into a prompt to fetch when the lock is missing or older than the
- * manifest, the way a build-file sync does. On the lock: a reminder that it is
- * generated, with the commands that regenerate and check it.
+ * The bar across the top of package.ludic and package.lock.ludic, shown only
+ * when something needs doing: the toolchain is missing, or the dependencies
+ * need fetching (the way a build-file sync prompts). Everything else — the
+ * package overview, commands, scripts, dependencies, app — lives in the Ludic
+ * tool window, which the lock's bar also points to.
*/
class LudicPackageNotificationProvider : EditorNotificationProvider, DumbAware {
override fun collectNotificationData(project: Project, file: VirtualFile): Function? {
@@ -89,40 +90,20 @@ class LudicPackageNotificationProvider : EditorNotificationProvider, DumbAware {
val text = FileDocumentManager.getInstance().getCachedDocument(file)?.charsSequence
?: runCatching { String(file.contentsToByteArray(), Charsets.UTF_8) }.getOrNull() ?: return null
- val manifest = LudicManifest.parse(text)
- val stale = LudicManifest.staleReason(File(dir), manifest)
+ val stale = LudicManifest.staleReason(File(dir), LudicManifest.parse(text))
+ val reason = bannerReason(cli != null, stale) ?: return null
return Function { editor ->
- val panel = if (stale != null) EditorNotificationPanel(editor, EditorNotificationPanel.Status.Warning)
- else EditorNotificationPanel(editor, EditorNotificationPanel.Status.Info)
+ val panel = EditorNotificationPanel(editor, EditorNotificationPanel.Status.Warning)
panel.icon(LudicPackageIcons.MANIFEST)
- val name = manifest.module ?: File(dir).name
- val ver = manifest.version?.let { " $it" } ?: ""
- val deps = manifest.requires.size.let { if (it == 1) "1 dependency" else "$it dependencies" }
- val entry = manifest.entry?.let { " · entry $it" } ?: ""
- panel.text = when {
- cli == null -> "Ludic package $name$ver — the 'ludic' command was not found."
- stale != null -> "$stale Fetch the dependencies so builds and code insight see them."
- else -> "Ludic package $name$ver · $deps$entry"
- }
+ panel.text = reason
if (cli != null) {
- val get = if (stale != null) "Fetch Dependencies" else "Fetch"
- panel.createActionLabel(get) { runLudic(project, LudicCommand.CUSTOM, "get", dir) }
- if (manifest.requires.isNotEmpty()) {
- panel.createActionLabel("Update All") { runLudic(project, LudicCommand.CUSTOM, "update", dir) }
- panel.createActionLabel("Verify") { runLudic(project, LudicCommand.CUSTOM, "verify", dir) }
- }
- panel.createActionLabel("Run") { runLudic(project, LudicCommand.RUN, "", dir) }
- panel.createActionLabel("Test") { runLudic(project, LudicCommand.TEST, "", dir) }
- panel.createActionLabel("Build") { runLudic(project, LudicCommand.BUILD, "", dir) }
- panel.createActionLabel("Bundle") { runLudic(project, LudicCommand.CUSTOM, "bundle", dir) }
- manifest.scripts.take(4).forEach { s ->
- panel.createActionLabel("▶ ${s.name}") { runLudic(project, LudicCommand.CUSTOM, "script ${s.name}", dir) }
- }
+ panel.createActionLabel("Fetch Dependencies") { runLudic(project, LudicCommand.CUSTOM, "get", dir) }
} else {
panel.createActionLabel("Set Paths…") {
ShowSettingsUtil.getInstance().showSettingsDialog(project, LudicConfigurable::class.java)
}
}
+ panel.createActionLabel("Ludic Tool Window") { LudicToolWindowFactory.show(project) }
panel
}
}
@@ -136,13 +117,19 @@ class LudicPackageNotificationProvider : EditorNotificationProvider, DumbAware {
OpenFileDescriptor(project, it).navigate(true)
}
}
- if (hasCli) {
- panel.createActionLabel("Regenerate") { runLudic(project, LudicCommand.CUSTOM, "get", dir) }
- panel.createActionLabel("Verify Hashes") { runLudic(project, LudicCommand.CUSTOM, "verify", dir) }
- panel.createActionLabel("Vendor") { runLudic(project, LudicCommand.CUSTOM, "vendor", dir) }
- }
+ if (hasCli) panel.createActionLabel("Regenerate") { runLudic(project, LudicCommand.CUSTOM, "get", dir) }
+ panel.createActionLabel("Ludic Tool Window") { LudicToolWindowFactory.show(project) }
panel
}
+
+ companion object {
+ /** The manifest bar's message, or null when there is nothing to prompt for. */
+ fun bannerReason(hasCli: Boolean, stale: String?): String? = when {
+ !hasCli -> "The 'ludic' command was not found, so this package cannot be run or fetched."
+ stale != null -> "$stale Fetch the dependencies so builds and code insight see them."
+ else -> null
+ }
+ }
}
/** Keeps the bars and the library list current as manifests, locks and linked packages change. */
@@ -152,7 +139,14 @@ class LudicManifestWatcher(private val project: Project) : BulkFileListener {
val n = e.path.substringAfterLast('/')
n == LudicManifest.FILE_NAME || n == LudicManifest.LOCK_NAME || n == "ludic_modules" || n == "vendor"
}
- if (relevant && !project.isDisposed) EditorNotifications.getInstance(project).updateAllNotifications()
+ if (!relevant || project.isDisposed) return
+ EditorNotifications.getInstance(project).updateAllNotifications()
+ // a package.ludic that just appeared makes the Ludic tool window available
+ if (events.any { it.path.endsWith("/" + LudicManifest.FILE_NAME) }) {
+ ApplicationManager.getApplication().invokeLater({
+ ToolWindowManager.getInstance(project).getToolWindow(LudicToolWindowFactory.ID)?.let { if (!it.isAvailable) it.isAvailable = true }
+ }, project.disposed)
+ }
}
}
diff --git a/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/project/LudicToolWindow.kt b/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/project/LudicToolWindow.kt
new file mode 100644
index 00000000..eaa03e41
--- /dev/null
+++ b/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/project/LudicToolWindow.kt
@@ -0,0 +1,468 @@
+package io.ludic.ide.project
+
+import com.intellij.icons.AllIcons
+import com.intellij.openapi.Disposable
+import com.intellij.openapi.actionSystem.ActionManager
+import com.intellij.openapi.actionSystem.ActionUpdateThread
+import com.intellij.openapi.actionSystem.AnAction
+import com.intellij.openapi.actionSystem.AnActionEvent
+import com.intellij.openapi.actionSystem.DefaultActionGroup
+import com.intellij.openapi.actionSystem.Separator
+import com.intellij.openapi.application.ApplicationManager
+import com.intellij.openapi.application.ReadAction
+import com.intellij.openapi.command.WriteCommandAction
+import com.intellij.openapi.editor.EditorFactory
+import com.intellij.openapi.editor.event.DocumentEvent
+import com.intellij.openapi.editor.event.DocumentListener
+import com.intellij.openapi.fileEditor.FileDocumentManager
+import com.intellij.openapi.fileEditor.OpenFileDescriptor
+import com.intellij.openapi.project.DumbAware
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.ui.Messages
+import com.intellij.openapi.ui.SimpleToolWindowPanel
+import com.intellij.openapi.util.Disposer
+import com.intellij.openapi.vfs.LocalFileSystem
+import com.intellij.openapi.vfs.VirtualFileManager
+import com.intellij.openapi.vfs.newvfs.BulkFileListener
+import com.intellij.openapi.vfs.newvfs.events.VFileEvent
+import com.intellij.openapi.wm.ToolWindow
+import com.intellij.openapi.wm.ToolWindowFactory
+import com.intellij.openapi.wm.ToolWindowManager
+import com.intellij.ui.ColoredTreeCellRenderer
+import com.intellij.ui.DoubleClickListener
+import com.intellij.ui.JBSplitter
+import com.intellij.ui.PopupHandler
+import com.intellij.ui.ScrollPaneFactory
+import com.intellij.ui.SimpleTextAttributes
+import com.intellij.ui.TreeSpeedSearch
+import com.intellij.ui.content.ContentFactory
+import com.intellij.ui.treeStructure.Tree
+import com.intellij.util.Alarm
+import com.intellij.util.ui.HTMLEditorKitBuilder
+import com.intellij.util.ui.JBUI
+import com.intellij.util.ui.tree.TreeUtil
+import io.ludic.ide.LudicToolchain
+import io.ludic.ide.run.LudicCommand
+import java.awt.event.KeyAdapter
+import java.awt.event.KeyEvent
+import java.awt.event.MouseEvent
+import java.io.File
+import javax.swing.Icon
+import javax.swing.JEditorPane
+import javax.swing.JTree
+import javax.swing.tree.DefaultMutableTreeNode
+import javax.swing.tree.DefaultTreeModel
+import javax.swing.tree.TreePath
+
+/**
+ * View | Tool Windows | Ludic: the package the way the Gradle window shows a
+ * build — what it is, the commands and scripts it runs, the hooks around them,
+ * its dependencies against the lock, and its app — with a details pane below.
+ */
+class LudicToolWindowFactory : ToolWindowFactory, DumbAware {
+ override fun shouldBeAvailable(project: Project): Boolean {
+ val base = project.basePath ?: return false
+ return LudicPackageInfo.discover(File(base)).isNotEmpty()
+ }
+
+ override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
+ val panel = LudicToolWindowPanel(project)
+ val content = ContentFactory.getInstance().createContent(panel, "", false)
+ Disposer.register(content, panel)
+ toolWindow.contentManager.addContent(content)
+ }
+
+ companion object {
+ const val ID = "Ludic"
+
+ fun show(project: Project) {
+ ToolWindowManager.getInstance(project).getToolWindow(ID)?.let { tw ->
+ tw.isAvailable = true
+ tw.activate(null)
+ }
+ }
+ }
+}
+
+/** A row of the tree. [key] survives a refresh so expansion and selection do too. */
+internal class LudicTreeItem(
+ val key: String,
+ val title: String,
+ val hint: String?,
+ val icon: Icon?,
+ val pkg: LudicPackageInfo?,
+ val payload: Any? = null,
+ val line: Int? = null,
+ val warn: Boolean = false,
+) {
+ override fun toString() = title
+}
+
+internal enum class Group { OVERVIEW, APP, COMMANDS, SCRIPTS, HOOKS, DEPENDENCIES, LOCK, ASSETS }
+
+class LudicToolWindowPanel(private val project: Project) : SimpleToolWindowPanel(true, true), Disposable {
+ private val root = DefaultMutableTreeNode()
+ private val model = DefaultTreeModel(root)
+ private val tree = Tree(model)
+ private val details = JEditorPane().apply {
+ editorKit = HTMLEditorKitBuilder().build()
+ isEditable = false
+ border = JBUI.Borders.empty(8)
+ }
+ private val alarm = Alarm(Alarm.ThreadToUse.SWING_THREAD, this)
+ private var packages: List = emptyList()
+ private var firstBuild = true
+
+ private fun isPackagePath(path: String): Boolean {
+ val n = path.substringAfterLast('/')
+ return n == LudicManifest.FILE_NAME || n == LudicManifest.LOCK_NAME || n == "ludic_modules" || n == "vendor"
+ }
+
+ private fun scheduleRefresh() {
+ alarm.cancelAllRequests()
+ alarm.addRequest({ refresh() }, 300)
+ }
+
+ fun refresh() {
+ val base = project.basePath ?: return
+ ApplicationManager.getApplication().executeOnPooledThread {
+ val found = ReadAction.compute, RuntimeException> { LudicPackageInfo.discover(File(base)) }
+ ApplicationManager.getApplication().invokeLater({ rebuild(found) }, project.disposed)
+ }
+ }
+
+ // ---- the tree ------------------------------------------------------------
+
+ private fun rebuild(found: List) {
+ val expanded = mutableSetOf()
+ TreeUtil.collectExpandedPaths(tree).forEach { p -> item(p)?.let { expanded += it.key } }
+ val selected = tree.selectionPath?.let(::item)?.key
+ packages = found
+ root.removeAllChildren()
+ for (p in found) root.add(packageNode(p))
+ model.reload()
+ if (firstBuild) {
+ // packages and their groups open; the long lists stay folded
+ TreeUtil.treeNodeTraverser(root).forEach { n ->
+ val it = (n as? DefaultMutableTreeNode)?.userObject as? LudicTreeItem ?: return@forEach
+ if (it.payload is LudicPackageInfo || it.payload == Group.COMMANDS || it.payload == Group.SCRIPTS) expanded += it.key
+ }
+ firstBuild = false
+ }
+ TreeUtil.treeNodeTraverser(root).forEach { n ->
+ val node = n as? DefaultMutableTreeNode ?: return@forEach
+ val it = node.userObject as? LudicTreeItem ?: return@forEach
+ val path = TreePath(node.path)
+ if (it.key in expanded) tree.expandPath(path)
+ if (it.key == selected) tree.selectionPath = path
+ }
+ if (tree.selectionPath == null && root.childCount > 0) {
+ tree.selectionPath = TreePath((root.getChildAt(0) as DefaultMutableTreeNode).path)
+ }
+ showDetails()
+ }
+
+ private fun packageNode(p: LudicPackageInfo): DefaultMutableTreeNode {
+ val m = p.manifest
+ val k = p.dir.path
+ val node = leaf(LudicTreeItem(k, p.name, listOfNotNull(m.version, m.kind ?: "source").joinToString(" · "),
+ LudicPackageIcons.MANIFEST, p, p, 0, warn = p.staleReason != null))
+ node.add(leaf(LudicTreeItem("$k/overview", "Overview", m.module, AllIcons.General.Information, p, Group.OVERVIEW, 0)))
+ if (m.app.isNotEmpty()) {
+ node.add(leaf(LudicTreeItem("$k/app", "App", LudicAppPreview.appName(m, p.dir), AllIcons.Nodes.Artifact, p, Group.APP,
+ m.app.keys.firstOrNull()?.let { key -> lineOf(p, "app", key) })))
+ }
+ val cmds = group("$k/commands", "Commands", null, AllIcons.Nodes.ConfigFolder, p, Group.COMMANDS)
+ LudicPackageInfo.COMMANDS.forEach { c ->
+ val hooks = p.hooksOf(c.verb).joinToString(", ") { "${it.phase}: ${it.command}" }
+ cmds.add(leaf(LudicTreeItem("$k/cmd/${c.verb}", c.title, "ludic ${c.verb}" + if (hooks.isNotEmpty()) " ⟲ $hooks" else "",
+ commandIcon(c.verb), p, c)))
+ }
+ node.add(cmds)
+ if (m.scripts.isNotEmpty()) {
+ val g = group("$k/scripts", "Scripts", m.scripts.size.toString(), AllIcons.Nodes.ConfigFolder, p, Group.SCRIPTS)
+ m.scripts.forEach { s -> g.add(leaf(LudicTreeItem("$k/script/${s.name}", s.name, s.command, AllIcons.Actions.Execute, p, s, s.line))) }
+ node.add(g)
+ }
+ if (m.hooks.isNotEmpty()) {
+ val g = group("$k/hooks", "Hooks", m.hooks.size.toString(), AllIcons.Nodes.ConfigFolder, p, Group.HOOKS)
+ m.hooks.forEach { h ->
+ g.add(leaf(LudicTreeItem("$k/hook/${h.line}", "${h.phase} ${h.event}", h.command, AllIcons.Nodes.Tag, p, h, h.line)))
+ }
+ node.add(g)
+ }
+ val deps = group("$k/deps", "Dependencies", depHint(p), AllIcons.Nodes.PpLibFolder, p, Group.DEPENDENCIES, warn = p.staleReason != null)
+ m.requires.forEach { r ->
+ val st = p.status(r)
+ val locked = p.lockedOf(r.module)?.version
+ val hint = ">= ${r.version}" + (locked?.let { " · locked $it" } ?: "") + if (st != LudicPackageInfo.DepStatus.OK) " · ${st.label}" else ""
+ deps.add(leaf(LudicTreeItem("$k/dep/${r.module}", r.module, hint, AllIcons.Nodes.PpLib, p, r, r.line, warn = st != LudicPackageInfo.DepStatus.OK)))
+ }
+ p.transitive.forEach { l ->
+ deps.add(leaf(LudicTreeItem("$k/dep/${l.module}", l.module, "${l.version} · indirect", AllIcons.Nodes.PpLibFolder, p, l)))
+ }
+ val lockHint = when {
+ p.locked == null -> if (m.requires.isEmpty()) "not needed" else "missing"
+ p.staleReason != null -> "out of date"
+ else -> "${p.locked.size} pinned"
+ }
+ deps.add(leaf(LudicTreeItem("$k/lock", LudicManifest.LOCK_NAME, lockHint, LudicPackageIcons.MANIFEST, p, Group.LOCK, warn = p.staleReason != null)))
+ node.add(deps)
+ if (m.packs.isNotEmpty()) {
+ val g = group("$k/assets", "Asset Roots", m.packs.size.toString(), AllIcons.Nodes.Folder, p, Group.ASSETS)
+ m.packs.forEach { v ->
+ val ok = File(v).let { if (it.isAbsolute) it else File(p.dir, v) }.exists()
+ g.add(leaf(LudicTreeItem("$k/pack/$v", v, if (ok) null else "missing", AllIcons.Nodes.Folder, p, v, lineOf(p, "pack", v), warn = !ok)))
+ }
+ node.add(g)
+ }
+ return node
+ }
+
+ private fun depHint(p: LudicPackageInfo): String {
+ val n = p.manifest.requires.size
+ return p.staleReason?.let { "fetch needed" } ?: if (n == 0) "none" else n.toString()
+ }
+
+ private fun commandIcon(verb: String): Icon = when (verb) {
+ "run" -> AllIcons.Actions.Execute
+ "build" -> AllIcons.Actions.Compile
+ "test" -> AllIcons.RunConfigurations.TestState.Run_run
+ "bundle", "pack" -> AllIcons.Nodes.Artifact
+ "clean" -> AllIcons.Actions.GC
+ else -> AllIcons.Nodes.Console
+ }
+
+ /** The line of the first ` ` in the package's manifest. */
+ private fun lineOf(p: LudicPackageInfo, directive: String, word: String): Int? {
+ val text = runCatching { p.manifestFile.readText() }.getOrNull() ?: return null
+ return text.lines().indexOfFirst { l -> LudicManifest.words(l).let { it.getOrNull(0) == directive && it.getOrNull(1) == word } }
+ .takeIf { it >= 0 }
+ }
+
+ private fun leaf(item: LudicTreeItem) = DefaultMutableTreeNode(item)
+
+ private fun group(key: String, title: String, hint: String?, icon: Icon, p: LudicPackageInfo, g: Group, warn: Boolean = false) =
+ DefaultMutableTreeNode(LudicTreeItem(key, title, hint, icon, p, g, warn = warn))
+
+ private fun item(path: TreePath?): LudicTreeItem? =
+ (path?.lastPathComponent as? DefaultMutableTreeNode)?.userObject as? LudicTreeItem
+
+ private fun selected(): LudicTreeItem? = item(tree.selectionPath)
+
+ private fun currentPackage(): LudicPackageInfo? = selected()?.pkg ?: packages.firstOrNull()
+
+ // ---- the details pane ------------------------------------------------------
+
+ private fun showDetails() {
+ val it = selected()
+ val p = it?.pkg
+ val html = when (val x = it?.payload) {
+ null -> if (packages.isEmpty()) "No package.ludic in this project. Create one with ludic new.
" else ""
+ is LudicPackageInfo -> LudicPackageHtml.overview(x)
+ Group.OVERVIEW -> LudicPackageHtml.overview(p!!)
+ Group.APP -> LudicAppPreview.html(p!!.manifest, p.dir)
+ Group.LOCK, Group.DEPENDENCIES -> LudicPackageHtml.lock(p!!)
+ Group.ASSETS -> LudicPackageHtml.assets(p!!)
+ Group.COMMANDS -> "Double-click a command to run it; its output goes to the Run tool window.
"
+ Group.SCRIPTS -> "The package's own commands, from its script lines. Double-click one to run it.
"
+ Group.HOOKS -> "Commands package.ludic runs before or after another command.
"
+ is LudicPackageInfo.Command -> LudicPackageHtml.command(p!!, x)
+ is LudicManifest.Script -> LudicPackageHtml.script(p!!, x)
+ is LudicManifest.Hook -> LudicPackageHtml.hook(x)
+ is LudicManifest.Require -> LudicPackageHtml.dependency(p!!, x)
+ is LudicManifest.Locked -> LudicPackageHtml.locked(p!!, x)
+ is String -> LudicPackageHtml.assets(p!!)
+ else -> ""
+ }
+ details.text = "$html"
+ details.caretPosition = 0
+ }
+
+ // ---- acting on a row ---------------------------------------------------------
+
+ /** What Enter or a double-click does: run a command or script, or open the line. */
+ private fun activate(): Boolean {
+ val it = selected() ?: return false
+ return when (it.payload) {
+ is LudicPackageInfo.Command, is LudicManifest.Script -> { runSelected(); true }
+ Group.LOCK -> { open(it.pkg!!.lockFile, 0); true }
+ is LudicManifest.Locked -> { open(it.pkg!!.lockFile, 0); true }
+ else -> if (it.line != null) { open(it.pkg!!.manifestFile, it.line); true } else false
+ }
+ }
+
+ private fun runnable(it: LudicTreeItem?): Pair? = when (val x = it?.payload) {
+ is LudicPackageInfo.Command -> when (x.verb) {
+ "run" -> LudicCommand.RUN to ""
+ "build" -> LudicCommand.BUILD to ""
+ "test" -> LudicCommand.TEST to ""
+ else -> LudicCommand.CUSTOM to x.verb
+ }
+ is LudicManifest.Script -> LudicCommand.CUSTOM to "script ${x.name}"
+ is LudicManifest.Hook -> LudicCommand.CUSTOM to (if (x.event in LudicManifest.HOOK_EVENTS) x.event else "script ${x.event}")
+ is LudicPackageInfo, Group.OVERVIEW -> LudicCommand.RUN to ""
+ Group.APP -> LudicCommand.CUSTOM to "bundle"
+ else -> null
+ }
+
+ private fun runSelected() {
+ val p = currentPackage() ?: return
+ val (cmd, args) = runnable(selected()) ?: return
+ run(p, cmd, args)
+ }
+
+ private fun run(p: LudicPackageInfo, cmd: LudicCommand, args: String) = runLudic(project, cmd, args, p.dir.path)
+
+ private fun open(f: File, line: Int) {
+ val vf = LocalFileSystem.getInstance().refreshAndFindFileByIoFile(f) ?: return
+ OpenFileDescriptor(project, vf, line, 0).navigate(true)
+ }
+
+ private fun addDependency() {
+ val p = currentPackage() ?: return
+ val spec = Messages.showInputDialog(project,
+ "Module path, optionally with a version:\ngit.example.com/me/util or git.example.com/me/util@1.2.0",
+ "Add Ludic Dependency", AllIcons.General.Add)?.trim().orEmpty()
+ if (spec.isEmpty() || spec.any { it.isWhitespace() || it == '"' || it == '\'' }) return
+ run(p, LudicCommand.CUSTOM, "add $spec")
+ }
+
+ private fun removeDependency(p: LudicPackageInfo, r: LudicManifest.Require) {
+ val ok = Messages.showYesNoDialog(project, "Remove ${r.module} from package.ludic and fetch again?", "Remove Dependency", null)
+ if (ok != Messages.YES) return
+ val vf = LocalFileSystem.getInstance().refreshAndFindFileByIoFile(p.manifestFile) ?: return
+ val doc = FileDocumentManager.getInstance().getDocument(vf) ?: return
+ if (r.line >= doc.lineCount || LudicManifest.words(doc.getText(com.intellij.openapi.util.TextRange(doc.getLineStartOffset(r.line), doc.getLineEndOffset(r.line)))).getOrNull(1) != r.module) return
+ WriteCommandAction.runWriteCommandAction(project, "Remove Dependency", null, {
+ val end = if (r.line + 1 < doc.lineCount) doc.getLineStartOffset(r.line + 1) else doc.getLineEndOffset(r.line)
+ doc.deleteString(doc.getLineStartOffset(r.line), end)
+ })
+ run(p, LudicCommand.CUSTOM, "get")
+ }
+
+ // ---- actions -----------------------------------------------------------------
+
+ private inner class Act(text: String, icon: Icon?, private val enabled: () -> Boolean = { currentPackage() != null }, private val body: () -> Unit) :
+ AnAction(text, null, icon), DumbAware {
+ override fun getActionUpdateThread() = ActionUpdateThread.EDT
+ override fun update(e: AnActionEvent) {
+ val cli = LudicToolchain.cli(project) != null
+ e.presentation.isEnabled = enabled() && (cli || templatePresentation.text == "Refresh" || templatePresentation.text.startsWith("Open"))
+ }
+ override fun actionPerformed(e: AnActionEvent) = body()
+ }
+
+ private fun pkgCmd(text: String, icon: Icon, verb: String) = Act(text, icon) { currentPackage()?.let { run(it, LudicCommand.CUSTOM, verb) } }
+
+ private val runAct by lazy {
+ Act("Run", AllIcons.Actions.Execute, { runnable(selected()) != null }) { runSelected() }
+ }
+ private val refreshAct by lazy {
+ Act("Refresh", AllIcons.Actions.Refresh, { true }) { refresh() }
+ }
+ private val fetchAct by lazy {
+ pkgCmd("Fetch Dependencies (ludic get)", AllIcons.Actions.Download, "get")
+ }
+ private val updateAllAct by lazy {
+ pkgCmd("Update All Dependencies (ludic update)", AllIcons.Actions.CheckOut, "update")
+ }
+ private val verifyAct by lazy {
+ pkgCmd("Verify Locked Packages (ludic verify)", AllIcons.Actions.Checked, "verify")
+ }
+ private val vendorAct by lazy {
+ pkgCmd("Vendor Dependencies (ludic vendor)", AllIcons.Nodes.PpLibFolder, "vendor")
+ }
+ private val addAct by lazy {
+ Act("Add Dependency…", AllIcons.General.Add) { addDependency() }
+ }
+ private val openManifestAct by lazy {
+ Act("Open package.ludic", AllIcons.Actions.EditSource) {
+ val it = selected()
+ currentPackage()?.let { p -> open(p.manifestFile, it?.line ?: 0) }
+ }
+ }
+ private val updateOneAct by lazy {
+ Act("Update This Dependency", AllIcons.Actions.CheckOut, { selected()?.payload is LudicManifest.Require }) {
+ val it = selected()
+ (it?.payload as? LudicManifest.Require)?.let { r -> run(it.pkg!!, LudicCommand.CUSTOM, "update ${r.module}") }
+ }
+ }
+ private val removeAct by lazy {
+ Act("Remove Dependency…", AllIcons.General.Remove, { selected()?.payload is LudicManifest.Require }) {
+ val it = selected()
+ (it?.payload as? LudicManifest.Require)?.let { r -> removeDependency(it.pkg!!, r) }
+ }
+ }
+ private val openLockAct by lazy {
+ Act("Open package.lock.ludic", AllIcons.Actions.EditSource, { currentPackage()?.lockFile?.isFile == true }) {
+ currentPackage()?.let { open(it.lockFile, 0) }
+ }
+ }
+ private fun toolbarGroup() = DefaultActionGroup().apply {
+ add(refreshAct); add(runAct); add(Separator.getInstance())
+ add(fetchAct); add(updateAllAct); add(verifyAct); add(addAct); add(Separator.getInstance())
+ add(openManifestAct)
+ }
+
+ private fun popupGroup() = DefaultActionGroup().apply {
+ add(runAct); add(openManifestAct); add(Separator.getInstance())
+ add(updateOneAct); add(removeAct); add(addAct); add(Separator.getInstance())
+ add(fetchAct); add(updateAllAct); add(verifyAct); add(vendorAct); add(openLockAct)
+ }
+
+ // last, so every action above exists before the toolbar and menu are built
+ init {
+ tree.isRootVisible = false
+ tree.showsRootHandles = true
+ tree.cellRenderer = Renderer()
+ TreeSpeedSearch.installOn(tree)
+ tree.addTreeSelectionListener { showDetails() }
+ object : DoubleClickListener() {
+ override fun onDoubleClick(event: MouseEvent): Boolean = activate()
+ }.installOn(tree)
+ tree.addKeyListener(object : KeyAdapter() {
+ override fun keyPressed(e: KeyEvent) { if (e.keyCode == KeyEvent.VK_ENTER && activate()) e.consume() }
+ })
+ PopupHandler.installPopupMenu(tree, popupGroup(), "LudicToolWindowPopup")
+
+ val splitter = JBSplitter(true, "ludic.toolwindow.split", 0.6f)
+ splitter.firstComponent = ScrollPaneFactory.createScrollPane(tree, true)
+ splitter.secondComponent = ScrollPaneFactory.createScrollPane(details, true)
+ setContent(splitter)
+
+ val bar = ActionManager.getInstance().createActionToolbar("LudicToolWindow", toolbarGroup(), true)
+ bar.targetComponent = this
+ toolbar = bar.component
+
+ project.messageBus.connect(this).subscribe(VirtualFileManager.VFS_CHANGES, object : BulkFileListener {
+ override fun after(events: List) {
+ if (events.any { isPackagePath(it.path) }) scheduleRefresh()
+ }
+ })
+ EditorFactory.getInstance().eventMulticaster.addDocumentListener(object : DocumentListener {
+ override fun documentChanged(event: DocumentEvent) {
+ val f = FileDocumentManager.getInstance().getFile(event.document) ?: return
+ if (LudicManifest.isManifest(f) || LudicManifest.isLock(f)) scheduleRefresh()
+ }
+ }, this)
+ refresh()
+ }
+
+ override fun dispose() {}
+
+ private class Renderer : ColoredTreeCellRenderer() {
+ override fun customizeCellRenderer(tree: JTree, value: Any?, selected: Boolean, expanded: Boolean, leaf: Boolean, row: Int, hasFocus: Boolean) {
+ val it = (value as? DefaultMutableTreeNode)?.userObject as? LudicTreeItem ?: return
+ icon = if (it.warn && it.payload !is LudicPackageInfo) AllIcons.General.Warning else it.icon
+ val attrs = when {
+ it.payload is LudicPackageInfo -> SimpleTextAttributes.REGULAR_BOLD_ATTRIBUTES
+ it.warn -> SimpleTextAttributes.ERROR_ATTRIBUTES
+ else -> SimpleTextAttributes.REGULAR_ATTRIBUTES
+ }
+ append(it.title, attrs)
+ it.hint?.let { h -> append(" $h", SimpleTextAttributes.GRAYED_ATTRIBUTES) }
+ toolTipText = it.hint
+ }
+ }
+}
diff --git a/tools/editors/jetbrains/src/main/resources/META-INF/plugin.xml b/tools/editors/jetbrains/src/main/resources/META-INF/plugin.xml
index b0f9b0e6..30ec6496 100644
--- a/tools/editors/jetbrains/src/main/resources/META-INF/plugin.xml
+++ b/tools/editors/jetbrains/src/main/resources/META-INF/plugin.xml
@@ -100,6 +100,8 @@
+
diff --git a/tools/editors/jetbrains/src/main/resources/icons/ludicToolWindow.svg b/tools/editors/jetbrains/src/main/resources/icons/ludicToolWindow.svg
new file mode 100644
index 00000000..906bbda1
--- /dev/null
+++ b/tools/editors/jetbrains/src/main/resources/icons/ludicToolWindow.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/tools/editors/jetbrains/src/main/resources/icons/ludicToolWindow_dark.svg b/tools/editors/jetbrains/src/main/resources/icons/ludicToolWindow_dark.svg
new file mode 100644
index 00000000..dce9872f
--- /dev/null
+++ b/tools/editors/jetbrains/src/main/resources/icons/ludicToolWindow_dark.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/tools/editors/jetbrains/src/test/kotlin/io/ludic/ide/LudicManifestTest.kt b/tools/editors/jetbrains/src/test/kotlin/io/ludic/ide/LudicManifestTest.kt
index 3c2a3c5a..c2d8a59c 100644
--- a/tools/editors/jetbrains/src/test/kotlin/io/ludic/ide/LudicManifestTest.kt
+++ b/tools/editors/jetbrains/src/test/kotlin/io/ludic/ide/LudicManifestTest.kt
@@ -100,12 +100,64 @@ class LudicManifestTest : BasePlatformTestCase() {
assertNotNull(LudicManifestDocumentation().generateDoc(comment, comment))
}
- fun testBannerAppearsOnlyOnTheManifest() {
+ fun testBannerOnlyPromptsWhenSomethingNeedsDoing() {
val provider = LudicPackageNotificationProvider()
- val man = myFixture.configureByText("package.ludic", manifest).virtualFile
- assertNotNull(provider.collectNotificationData(project, man))
val src = myFixture.configureByText("main.ludic", "program X {}").virtualFile
assertNull(provider.collectNotificationData(project, src))
+ // the everyday commands moved to the tool window: no bar for a healthy package
+ assertNull(LudicPackageNotificationProvider.bannerReason(true, null))
+ assertTrue(LudicPackageNotificationProvider.bannerReason(true, "There is no lock.")!!.contains("Fetch"))
+ assertTrue(LudicPackageNotificationProvider.bannerReason(false, null)!!.contains("'ludic'"))
+ }
+
+ fun testToolWindowPanelBuilds() {
+ myFixture.configureByText("package.ludic", manifest)
+ val panel = io.ludic.ide.project.LudicToolWindowPanel(project)
+ try {
+ assertNotNull(panel.toolbar)
+ assertNotNull(panel.content)
+ } finally {
+ com.intellij.openapi.util.Disposer.dispose(panel)
+ }
+ }
+
+ fun testToolWindowModelReadsPackageLockAndLinks() {
+ val dir = java.nio.file.Files.createTempDirectory("ludicpkg").toFile()
+ try {
+ java.io.File(dir, "package.ludic").writeText(
+ "package \"git.x/me/game\"\nversion \"0.2.0\"\nentry \"src/g.ludic\"\n" +
+ "require \"git.x/me/util\" \"1.1.0\"\nrequire \"git.x/me/net\" \"2.0.0\"\n" +
+ "script \"check\" \"tests/check.sh\"\nhook before bundle \"tests/pre.sh\"\napp name \"Game\"\npack \"assets\"\n"
+ )
+ java.io.File(dir, "package.lock.ludic").writeText(
+ "lock 1\nmodule \"git.x/me/util\" version \"1.0.0\" hash \"h1\" kind \"source\"\n" +
+ "module \"git.x/me/deep\" version \"0.3.0\" hash \"h2\" kind \"source\"\n"
+ )
+ java.io.File(dir, "ludic_modules/git.x/me/util").mkdirs()
+ java.io.File(dir, "sub/lib").mkdirs()
+ java.io.File(dir, "sub/lib/package.ludic").writeText("package \"git.x/me/lib\"\n")
+ java.io.File(dir, "ludic_modules/git.x/me/util/package.ludic").writeText("package \"git.x/me/util\"\n")
+
+ val found = io.ludic.ide.project.LudicPackageInfo.discover(dir)
+ assertEquals("the root first, fetched modules skipped", listOf(dir, java.io.File(dir, "sub/lib")), found.map { it.dir })
+ val p = found.first()
+ assertEquals("game", p.name)
+ val (util, net) = p.manifest.requires
+ assertEquals(io.ludic.ide.project.LudicPackageInfo.DepStatus.BELOW_MINIMUM, p.status(util))
+ assertEquals(io.ludic.ide.project.LudicPackageInfo.DepStatus.NOT_LOCKED, p.status(net))
+ assertEquals(listOf("git.x/me/deep"), p.transitive.map { it.module })
+ assertEquals(listOf("tests/pre.sh"), p.hooksOf("bundle").map { it.command })
+
+ val html = io.ludic.ide.project.LudicPackageHtml.overview(p)
+ assertTrue(html, html.contains("Game") && html.contains("src/g.ludic") && html.contains("check"))
+ assertTrue(io.ludic.ide.project.LudicPackageHtml.dependency(p, util).contains("locked below the minimum"))
+ val cmd = io.ludic.ide.project.LudicPackageInfo.COMMANDS.first { it.verb == "bundle" }
+ assertTrue(io.ludic.ide.project.LudicPackageHtml.command(p, cmd).contains("tests/pre.sh"))
+ assertTrue(io.ludic.ide.project.LudicAppPreview.html(p.manifest, p.dir).contains("Game.app"))
+ assertTrue(io.ludic.ide.project.LudicPackageInfo.versionLess("1.2.9", "1.10.0"))
+ } finally {
+ dir.deleteRecursively()
+ }
}
fun testPathValuesNavigateAndMissingOnesAreErrors() {
diff --git a/tools/ludic-cli/tools.ludic b/tools/ludic-cli/tools.ludic
index 7b3bec4e..0d77c24f 100644
--- a/tools/ludic-cli/tools.ludic
+++ b/tools/ludic-cli/tools.ludic
@@ -166,7 +166,7 @@ function cmd_test_tools() -> int {
# IntelliJ SDK). One env var away: LUDIC_TEST_JETBRAINS=1 ludic-dev test-tools
if (getenv_or("LUDIC_TEST_JETBRAINS", "0") == "1") {
if shq("test -x tools/editors/jetbrains/gradlew") {
- if shq("cd tools/editors/jetbrains && ./gradlew buildPlugin verifyPluginStructure --console=plain -q >/dev/null 2>&1 && test -f build/distributions/Ludic-1.5.0.zip") { ok("jetbrains plugin builds and verifies") } else { bad("jetbrains plugin build") }
+ if shq("cd tools/editors/jetbrains && ./gradlew buildPlugin verifyPluginStructure --console=plain -q >/dev/null 2>&1 && test -f build/distributions/Ludic-1.6.0.zip") { ok("jetbrains plugin builds and verifies") } else { bad("jetbrains plugin build") }
if shq("cd tools/editors/jetbrains && ./gradlew test --console=plain -q >/dev/null 2>&1") { ok("jetbrains platform tests (lexer, file type, PSI, folding)") } else { bad("jetbrains platform tests") }
} else { print(" skip jetbrains plugin (no gradle wrapper)") }
} else {