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:
parent
48c701cf1b
commit
45fe909128
13 changed files with 862 additions and 79 deletions
10
changes/jetbrains-package-window.md
Normal file
10
changes/jetbrains-package-window.md
Normal 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.
|
||||||
|
|
@ -17,7 +17,7 @@ packages cleanly can still fail to load:
|
||||||
scheduled-for-removal API usages.
|
scheduled-for-removal API usages.
|
||||||
- `./gradlew runIde` boots a real IDEA 2025.3 with the plugin installed. The
|
- `./gradlew runIde` boots a real IDEA 2025.3 with the plugin installed. The
|
||||||
sandbox log line to look for is
|
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`.
|
`.intellijPlatform/sandbox/Ludic/IC-2025.3/log/idea.log`.
|
||||||
|
|
||||||
**If the plugin does not appear in Settings -> Plugins after installing**, the
|
**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
|
```bash
|
||||||
export JAVA_HOME=$(/usr/libexec/java_home -v 21) # macOS
|
export JAVA_HOME=$(/usr/libexec/java_home -v 21) # macOS
|
||||||
cd tools/editors/jetbrains
|
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
|
./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 |
|
- **Project**: File | New | Project | Ludic (runs `ludic new`), File | New |
|
||||||
Ludic File (program, module, test), Tools | Ludic (run, build, test, fmt,
|
Ludic File (program, module, test), Tools | Ludic (run, build, test, fmt,
|
||||||
get, pack, bundle, clean, doctor).
|
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.
|
There is no debugger: `ludicc` does not emit debug info yet.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
pluginGroup = io.ludic.ide
|
pluginGroup = io.ludic.ide
|
||||||
pluginName = Ludic
|
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
|
# 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
|
# floor here too: claiming older would let an IDE accept this plugin and then
|
||||||
|
|
|
||||||
|
|
@ -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(" 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)}'> </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>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -352,39 +352,7 @@ class LudicManifestDocumentation : AbstractDocumentationProvider() {
|
||||||
|
|
||||||
/** The app the `app` lines describe, as `ludic bundle` would build it. */
|
/** The app the `app` lines describe, as `ludic bundle` would build it. */
|
||||||
private fun appPreview(file: PsiFile, highlight: String?): String {
|
private fun appPreview(file: PsiFile, highlight: String?): String {
|
||||||
val m = LudicManifest.parse(file.text)
|
val dir = file.virtualFile?.parent?.path?.let(::File)
|
||||||
val app = m.app
|
return LudicAppPreview.html(LudicManifest.parse(file.text), dir, highlight)
|
||||||
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(" 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)}'> </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>") }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import com.intellij.icons.AllIcons
|
||||||
import com.intellij.ide.IconProvider
|
import com.intellij.ide.IconProvider
|
||||||
import com.intellij.openapi.actionSystem.AnAction
|
import com.intellij.openapi.actionSystem.AnAction
|
||||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||||
|
import com.intellij.openapi.application.ApplicationManager
|
||||||
import com.intellij.openapi.editor.Document
|
import com.intellij.openapi.editor.Document
|
||||||
import com.intellij.openapi.fileEditor.FileDocumentManager
|
import com.intellij.openapi.fileEditor.FileDocumentManager
|
||||||
import com.intellij.openapi.fileEditor.FileEditor
|
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.VirtualFile
|
||||||
import com.intellij.openapi.vfs.newvfs.BulkFileListener
|
import com.intellij.openapi.vfs.newvfs.BulkFileListener
|
||||||
import com.intellij.openapi.vfs.newvfs.events.VFileEvent
|
import com.intellij.openapi.vfs.newvfs.events.VFileEvent
|
||||||
|
import com.intellij.openapi.wm.ToolWindowManager
|
||||||
import com.intellij.psi.PsiDocumentManager
|
import com.intellij.psi.PsiDocumentManager
|
||||||
import com.intellij.psi.PsiElement
|
import com.intellij.psi.PsiElement
|
||||||
import com.intellij.psi.PsiFile
|
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.
|
* 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
|
||||||
* On the manifest: the package's identity and the commands that act on it,
|
* need fetching (the way a build-file sync prompts). Everything else — the
|
||||||
* turning into a prompt to fetch when the lock is missing or older than the
|
* package overview, commands, scripts, dependencies, app — lives in the Ludic
|
||||||
* manifest, the way a build-file sync does. On the lock: a reminder that it is
|
* tool window, which the lock's bar also points to.
|
||||||
* generated, with the commands that regenerate and check it.
|
|
||||||
*/
|
*/
|
||||||
class LudicPackageNotificationProvider : EditorNotificationProvider, DumbAware {
|
class LudicPackageNotificationProvider : EditorNotificationProvider, DumbAware {
|
||||||
override fun collectNotificationData(project: Project, file: VirtualFile): Function<in FileEditor, out JComponent?>? {
|
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
|
val text = FileDocumentManager.getInstance().getCachedDocument(file)?.charsSequence
|
||||||
?: runCatching { String(file.contentsToByteArray(), Charsets.UTF_8) }.getOrNull() ?: return null
|
?: runCatching { String(file.contentsToByteArray(), Charsets.UTF_8) }.getOrNull() ?: return null
|
||||||
val manifest = LudicManifest.parse(text)
|
val stale = LudicManifest.staleReason(File(dir), LudicManifest.parse(text))
|
||||||
val stale = LudicManifest.staleReason(File(dir), manifest)
|
val reason = bannerReason(cli != null, stale) ?: return null
|
||||||
return Function { editor ->
|
return Function { editor ->
|
||||||
val panel = if (stale != null) EditorNotificationPanel(editor, EditorNotificationPanel.Status.Warning)
|
val panel = EditorNotificationPanel(editor, EditorNotificationPanel.Status.Warning)
|
||||||
else EditorNotificationPanel(editor, EditorNotificationPanel.Status.Info)
|
|
||||||
panel.icon(LudicPackageIcons.MANIFEST)
|
panel.icon(LudicPackageIcons.MANIFEST)
|
||||||
val name = manifest.module ?: File(dir).name
|
panel.text = reason
|
||||||
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"
|
|
||||||
}
|
|
||||||
if (cli != null) {
|
if (cli != null) {
|
||||||
val get = if (stale != null) "Fetch Dependencies" else "Fetch"
|
panel.createActionLabel("Fetch Dependencies") { runLudic(project, LudicCommand.CUSTOM, "get", dir) }
|
||||||
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) }
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
panel.createActionLabel("Set Paths…") {
|
panel.createActionLabel("Set Paths…") {
|
||||||
ShowSettingsUtil.getInstance().showSettingsDialog(project, LudicConfigurable::class.java)
|
ShowSettingsUtil.getInstance().showSettingsDialog(project, LudicConfigurable::class.java)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
panel.createActionLabel("Ludic Tool Window") { LudicToolWindowFactory.show(project) }
|
||||||
panel
|
panel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -136,13 +117,19 @@ class LudicPackageNotificationProvider : EditorNotificationProvider, DumbAware {
|
||||||
OpenFileDescriptor(project, it).navigate(true)
|
OpenFileDescriptor(project, it).navigate(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (hasCli) {
|
if (hasCli) panel.createActionLabel("Regenerate") { runLudic(project, LudicCommand.CUSTOM, "get", dir) }
|
||||||
panel.createActionLabel("Regenerate") { runLudic(project, LudicCommand.CUSTOM, "get", dir) }
|
panel.createActionLabel("Ludic Tool Window") { LudicToolWindowFactory.show(project) }
|
||||||
panel.createActionLabel("Verify Hashes") { runLudic(project, LudicCommand.CUSTOM, "verify", dir) }
|
|
||||||
panel.createActionLabel("Vendor") { runLudic(project, LudicCommand.CUSTOM, "vendor", dir) }
|
|
||||||
}
|
|
||||||
panel
|
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. */
|
/** 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('/')
|
val n = e.path.substringAfterLast('/')
|
||||||
n == LudicManifest.FILE_NAME || n == LudicManifest.LOCK_NAME || n == "ludic_modules" || n == "vendor"
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -100,6 +100,8 @@
|
||||||
|
|
||||||
<!-- package.ludic: the package manifest -->
|
<!-- package.ludic: the package manifest -->
|
||||||
<editorNotificationProvider implementation="io.ludic.ide.project.LudicPackageNotificationProvider"/>
|
<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"/>
|
<runLineMarkerContributor language="Ludic" implementationClass="io.ludic.ide.project.LudicManifestLineMarker"/>
|
||||||
<completion.contributor language="Ludic" implementationClass="io.ludic.ide.project.LudicManifestCompletion" order="first"/>
|
<completion.contributor language="Ludic" implementationClass="io.ludic.ide.project.LudicManifestCompletion" order="first"/>
|
||||||
<lang.documentationProvider language="Ludic" implementationClass="io.ludic.ide.project.LudicManifestDocumentation" order="first"/>
|
<lang.documentationProvider language="Ludic" implementationClass="io.ludic.ide.project.LudicManifestDocumentation" order="first"/>
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -100,12 +100,64 @@ class LudicManifestTest : BasePlatformTestCase() {
|
||||||
assertNotNull(LudicManifestDocumentation().generateDoc(comment, comment))
|
assertNotNull(LudicManifestDocumentation().generateDoc(comment, comment))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun testBannerAppearsOnlyOnTheManifest() {
|
fun testBannerOnlyPromptsWhenSomethingNeedsDoing() {
|
||||||
val provider = LudicPackageNotificationProvider()
|
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
|
val src = myFixture.configureByText("main.ludic", "program X {}").virtualFile
|
||||||
assertNull(provider.collectNotificationData(project, src))
|
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() {
|
fun testPathValuesNavigateAndMissingOnesAreErrors() {
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,7 @@ function cmd_test_tools() -> int {
|
||||||
# IntelliJ SDK). One env var away: LUDIC_TEST_JETBRAINS=1 ludic-dev test-tools
|
# IntelliJ SDK). One env var away: LUDIC_TEST_JETBRAINS=1 ludic-dev test-tools
|
||||||
if (getenv_or("LUDIC_TEST_JETBRAINS", "0") == "1") {
|
if (getenv_or("LUDIC_TEST_JETBRAINS", "0") == "1") {
|
||||||
if shq("test -x tools/editors/jetbrains/gradlew") {
|
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") }
|
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 { print(" skip jetbrains plugin (no gradle wrapper)") }
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue