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
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
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(" 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>") }
|
||||
}
|
||||
val dir = file.virtualFile?.parent?.path?.let(::File)
|
||||
return LudicAppPreview.html(LudicManifest.parse(file.text), dir, highlight)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
<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"/>
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
||||
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() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue