feat(jetbrains): a Ludic tool window for the package (plugin 1.6.0)

The package overview, its commands, scripts and hooks, dependencies against
the lock with fetch/update/verify/vendor/add/remove, the app preview and the
asset roots. The bar over package.ludic now only prompts when something
needs doing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-17 12:16:22 +03:00
parent 48c701cf1b
commit 45fe909128
13 changed files with 862 additions and 79 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -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("<tr><td valign='top' class='section'><p>").append(k).append("</p></td><td valign='top'>").append(v).append("</td></tr>")
}
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 "<img src='${f.toURI()}' width='$size' height='$size'>"
}
fun mark(k: String, v: String) = if (k == highlight) "<b>$v</b>" else v
return buildString {
append("<div class='definition'><pre>").append(esc(name)).append(".app</pre></div>")
append("<div class='content'><table><tr><td valign='top'>")
append(img("icon", 64) ?: "")
append("</td><td valign='top'><p><b>").append(esc(name)).append("</b></p><p>")
append(esc(app["id"] ?: "(no bundle id)")).append("<br>version ")
append(esc(app["version"] ?: m.version ?: "0.1.0"))
app["category"]?.let { append("<br>").append(esc(it.removePrefix("public.app-category."))) }
app["min_macos"]?.let { append("<br>macOS ").append(esc(it)).append("+") }
append("</p></td></tr></table>")
app["splash"]?.let { s ->
val f = resolve(dir, s)
val bg = app["splash_bg"] ?: "#000000"
append("<p>Splash</p><table><tr><td bgcolor='").append(esc(bg)).append("'>")
if (f?.isFile == true) append("<img src='").append(f.toURI()).append("' width='240'>") else append("&nbsp;missing: ").append(esc(s))
append("</td></tr></table>")
}
append("</div><table class='sections'>")
for ((k, help) in LudicManifest.APP_KEYS) {
val v = app[k] ?: continue
val shown = if (k == "splash_bg") "<span style='background-color:${esc(v)}'>&nbsp;&nbsp;&nbsp;&nbsp;</span> ${esc(v)}" else esc(v)
row(mark(k, k), mark(k, shown) + "<br><span class='grayed'>${esc(help)}</span>")
}
append("</table>")
m.entry?.let { append("<div class='bottom'>entry ").append(esc(it)).append("</div>") }
}
}
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("<div class='definition'><pre>").append(esc(m.module ?: p.dir.name)).append("</pre></div><div class='content'>")
val kind = m.kind ?: "source"
val what = when {
m.app.isNotEmpty() -> "A game or app, shipped as <b>${esc(LudicAppPreview.appName(m, p.dir))}</b> by <code>ludic bundle</code>."
m.entry != null -> "A program: <code>ludic run</code> starts <code>${esc(m.entry)}</code>."
else -> "A library other packages import."
}
append("<p>").append(what).append("</p>")
LudicManifest.KINDS[kind]?.let { append("<p><b>").append(esc(kind)).append("</b>: ").append(esc(it)).append("</p>") }
append("</div><table class='sections'>")
row("Version", esc(m.version ?: "(none)") + (m.version?.let { " <span class='grayed'>published by tag v${esc(it)}</span>" } ?: ""))
m.entry?.let { row("Entry", pathCell(p, it)) }
row("Location", "<code>${esc(p.dir.path)}</code>")
row("Dependencies", depSummary(p))
if (m.scripts.isNotEmpty()) row("Scripts", m.scripts.joinToString(" ") { "<code>${esc(it.name)}</code>" })
if (m.hooks.isNotEmpty()) row("Hooks", m.hooks.joinToString("<br>") { "${esc(it.phase)} ${esc(it.event)}: <code>${esc(it.command)}</code>" })
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 "<b>$it missing</b>" })
m.app["name"]?.let { row("App", "${esc(it)} <span class='grayed'>${esc(m.app["id"] ?: "")}</span>") }
append("</table>")
}
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<br><b>${esc(it)}</b>" } ?: if (bad == 0) "$base, all fetched" else "$base, <b>$bad need fetching</b>"
}
private fun pathCell(p: LudicPackageInfo, v: String): String {
val ok = resolve(p.dir, v)?.exists() == true
return "<code>${esc(v)}</code>" + if (ok) "" else " <b>missing</b>"
}
fun dependency(p: LudicPackageInfo, req: LudicManifest.Require): String = buildString {
append("<div class='definition'><pre>require \"").append(esc(req.module)).append("\" \"").append(esc(req.version)).append("\"</pre></div>")
append("<div class='content'><p>Needs at least <b>").append(esc(req.version)).append("</b>; Minimum Version Selection picks the lowest version that satisfies every requirement.</p></div>")
append("<table class='sections'>")
row("Status", esc(p.status(req).label))
p.lockedOf(req.module)?.let { lockedRows(it) }
row("On disk", p.linkedDir(req.module)?.let { "<code>${esc(it.path)}</code>" } ?: "not linked — Fetch")
append("</table>")
}
fun locked(p: LudicPackageInfo, l: LudicManifest.Locked): String = buildString {
append("<div class='definition'><pre>").append(esc(l.module)).append("</pre></div>")
append("<div class='content'><p>Pulled in by a dependency, not required here directly.</p></div><table class='sections'>")
lockedRows(l)
row("On disk", p.linkedDir(l.module)?.let { "<code>${esc(it.path)}</code>" } ?: "not linked — Fetch")
append("</table>")
}
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 { "<code>${esc(it)}.*</code>" })
row("Hash", "<code>${esc(l.hash)}</code>")
}
fun lock(p: LudicPackageInfo): String = buildString {
append("<div class='definition'><pre>").append(LudicManifest.LOCK_NAME).append("</pre></div><div class='content'>")
append("<p>Written by <code>ludic get</code>: 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.</p>")
val state = when {
p.manifest.requires.isEmpty() && p.locked.isNullOrEmpty() -> "Nothing to lock: this package has no dependencies."
p.staleReason != null -> "<b>${esc(p.staleReason!!)}</b> Fetch to bring it up to date."
else -> "Up to date with package.ludic."
}
append("<p>").append(state).append("</p></div><table class='sections'>")
p.locked?.forEach { row(esc(it.module), "${esc(it.version)} <span class='grayed'>${esc(it.hash.take(12))}…</span>") }
append("</table>")
}
fun command(p: LudicPackageInfo, c: LudicPackageInfo.Command): String = buildString {
append("<div class='definition'><pre>ludic ").append(c.verb).append("</pre></div><div class='content'><p>").append(esc(c.help)).append("</p>")
if (c.verb in setOf("run", "build", "bundle")) p.manifest.entry?.let { append("<p>Entry: <code>").append(esc(it)).append("</code></p>") }
hooks(p, c.verb)
append("</div>")
}
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("<div class='definition'><pre>").append(esc(how)).append("</pre></div><div class='content'>")
append("<p>Runs <code>").append(esc(s.command)).append("</code> from the package root; extra arguments are passed on.</p>")
hooks(p, s.name)
append("</div>")
}
fun hook(h: LudicManifest.Hook): String = buildString {
append("<div class='definition'><pre>hook ").append(esc(h.phase)).append(' ').append(esc(h.event)).append("</pre></div><div class='content'>")
append("<p><code>").append(esc(h.command)).append("</code></p>")
LudicManifest.HOOK_PHASES[h.phase]?.let { append("<p>").append(esc(it)).append("</p>") }
append("<p>Runs through the shell in the package root with <code>LUDIC_EVENT</code>, <code>LUDIC_PHASE</code> and <code>LUDIC_PACKAGE_ROOT</code> set.</p></div>")
}
fun assets(p: LudicPackageInfo): String = buildString {
append("<div class='definition'><pre>pack</pre></div><div class='content'><p>")
.append(esc(LudicManifest.DIRECTIVES["pack"] ?: "")).append("</p></div><table class='sections'>")
p.manifest.packs.forEach { v ->
val f = resolve(p.dir, v)
row("<code>${esc(v)}</code>", if (f?.exists() != true) "<b>missing</b>" else if (f.isDirectory) "directory" else "file")
}
append("</table>")
}
private fun StringBuilder.hooks(p: LudicPackageInfo, event: String) {
val hs = p.hooksOf(event)
if (hs.isEmpty()) return
append("<p>Hooks:</p><ul>")
hs.forEach { append("<li>").append(esc(it.phase)).append(": <code>").append(esc(it.command)).append("</code></li>") }
append("</ul>")
}
}

View file

@ -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 "<img src='${f.toURI()}' width='$size' height='$size'>"
}
fun mark(k: String, v: String) = if (k == highlight) "<b>$v</b>" else v
return buildString {
append("<div class='definition'><pre>").append(esc(name)).append(".app</pre></div>")
append("<div class='content'><table><tr><td valign='top'>")
append(img("icon", 64) ?: "")
append("</td><td valign='top'><p><b>").append(esc(name)).append("</b></p><p>")
append(esc(app["id"] ?: "(no bundle id)")).append("<br>version ")
append(esc(app["version"] ?: m.version ?: "0.1.0"))
app["category"]?.let { append("<br>").append(esc(it.removePrefix("public.app-category."))) }
app["min_macos"]?.let { append("<br>macOS ").append(esc(it)).append("+") }
append("</p></td></tr></table>")
app["splash"]?.let { s ->
val f = resolveManifestPath(file, s)
val bg = app["splash_bg"] ?: "#000000"
append("<p>Splash</p><table><tr><td bgcolor='").append(esc(bg)).append("'>")
if (f?.isFile == true) append("<img src='").append(f.toURI()).append("' width='240'>") else append("&nbsp;missing: ").append(esc(s))
append("</td></tr></table>")
}
append("</div><table class='sections'>")
for ((k, help) in LudicManifest.APP_KEYS) {
val v = app[k] ?: continue
val shown = if (k == "splash_bg") "<span style='background-color:${esc(v)}'>&nbsp;&nbsp;&nbsp;&nbsp;</span> ${esc(v)}" else esc(v)
row(mark(k, k), mark(k, shown) + "<br><span class='grayed'>${esc(help)}</span>")
}
append("</table>")
m.entry?.let { append("<div class='bottom'>entry ").append(esc(it)).append("</div>") }
}
val dir = file.virtualFile?.parent?.path?.let(::File)
return LudicAppPreview.html(LudicManifest.parse(file.text), dir, highlight)
}
}

View file

@ -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<LudicManifest.Locked>?) {
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<LudicManifest.Locked>
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<LudicPackageInfo> {
val found = mutableListOf<File>()
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)
}

View file

@ -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<in FileEditor, out JComponent?>? {
@ -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)
}
}
}

View file

@ -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<LudicPackageInfo> = 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<List<LudicPackageInfo>, RuntimeException> { LudicPackageInfo.discover(File(base)) }
ApplicationManager.getApplication().invokeLater({ rebuild(found) }, project.disposed)
}
}
// ---- the tree ------------------------------------------------------------
private fun rebuild(found: List<LudicPackageInfo>) {
val expanded = mutableSetOf<String>()
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 `<directive> <word>` 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()) "<p>No package.ludic in this project. Create one with <code>ludic new</code>.</p>" 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 -> "<p>Double-click a command to run it; its output goes to the Run tool window.</p>"
Group.SCRIPTS -> "<p>The package's own commands, from its <code>script</code> lines. Double-click one to run it.</p>"
Group.HOOKS -> "<p>Commands package.ludic runs before or after another command.</p>"
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><body>$html</body></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<LudicCommand, String>? = 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<VFileEvent>) {
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
}
}
}

View file

@ -100,6 +100,8 @@
<!-- package.ludic: the package manifest -->
<editorNotificationProvider implementation="io.ludic.ide.project.LudicPackageNotificationProvider"/>
<toolWindow id="Ludic" anchor="right" secondary="false" icon="/icons/ludicToolWindow.svg"
factoryClass="io.ludic.ide.project.LudicToolWindowFactory"/>
<runLineMarkerContributor language="Ludic" implementationClass="io.ludic.ide.project.LudicManifestLineMarker"/>
<completion.contributor language="Ludic" implementationClass="io.ludic.ide.project.LudicManifestCompletion" order="first"/>
<lang.documentationProvider language="Ludic" implementationClass="io.ludic.ide.project.LudicManifestDocumentation" order="first"/>

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path d="M8 1.5 L14 4.5 L14 11.5 L8 14.5 L2 11.5 L2 4.5 Z" fill="none" stroke="#6C707E" stroke-width="1.2" stroke-linejoin="round"/>
<path d="M2 4.5 L8 7.5 L14 4.5 M8 7.5 L8 14.5" fill="none" stroke="#6C707E" stroke-width="1.2" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 349 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path d="M8 1.5 L14 4.5 L14 11.5 L8 14.5 L2 11.5 L2 4.5 Z" fill="none" stroke="#CED0D6" stroke-width="1.2" stroke-linejoin="round"/>
<path d="M2 4.5 L8 7.5 L14 4.5 M8 7.5 L8 14.5" fill="none" stroke="#CED0D6" stroke-width="1.2" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 349 B

View file

@ -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() {

View file

@ -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 {