diff --git a/changes/jetbrains-package-window.md b/changes/jetbrains-package-window.md new file mode 100644 index 00000000..c7024b96 --- /dev/null +++ b/changes/jetbrains-package-window.md @@ -0,0 +1,10 @@ +bump: minor +type: feat +The JetBrains plugin (1.6.0) has a Ludic tool window for the package, like the Gradle one: +- an overview of the package (what it is, entry, version, dependencies, scripts, hooks, app); +- its commands, scripts and hooks, run by double-click, with each command's hooks shown; +- its dependencies against package.lock.ludic: the locked version, whether it is fetched + and linked, the indirect ones, and Fetch, Update, Verify, Vendor, Add and Remove; +- the app preview the `app` lines describe, and the asset roots with missing ones marked. +The bar over package.ludic now appears only when something needs doing (fetch, or no +toolchain), instead of carrying every command. diff --git a/tools/editors/jetbrains/README.md b/tools/editors/jetbrains/README.md index 84768eae..cb407f99 100644 --- a/tools/editors/jetbrains/README.md +++ b/tools/editors/jetbrains/README.md @@ -17,7 +17,7 @@ packages cleanly can still fail to load: scheduled-for-removal API usages. - `./gradlew runIde` boots a real IDEA 2025.3 with the plugin installed. The sandbox log line to look for is - `Loaded custom plugins: LSP4IJ (0.20.1), Ludic (1.5.0)` in + `Loaded custom plugins: LSP4IJ (0.20.1), Ludic (1.6.0)` in `.intellijPlatform/sandbox/Ludic/IC-2025.3/log/idea.log`. **If the plugin does not appear in Settings -> Plugins after installing**, the @@ -35,7 +35,7 @@ as the error message, which is not obvious the first time you see it: ```bash export JAVA_HOME=$(/usr/libexec/java_home -v 21) # macOS cd tools/editors/jetbrains -./gradlew buildPlugin # -> build/distributions/Ludic-1.5.0.zip +./gradlew buildPlugin # -> build/distributions/Ludic-1.6.0.zip ./gradlew runIde # try it in a sandbox IDE ``` @@ -70,6 +70,12 @@ LSP4IJ is a required dependency; the IDE offers to install it for you. - **Project**: File | New | Project | Ludic (runs `ludic new`), File | New | Ludic File (program, module, test), Tools | Ludic (run, build, test, fmt, get, pack, bundle, clean, doctor). +- **Ludic tool window** (right side, like Gradle's): the package's overview; its + commands, scripts and hooks, run by double-click; its dependencies against + `package.lock.ludic` (locked version, fetched or not, indirect ones) with Fetch, + Update, Verify, Vendor, Add and Remove; the app preview; the asset roots. The bar + over `package.ludic` only appears when something needs doing (fetch, or no + toolchain found). There is no debugger: `ludicc` does not emit debug info yet. diff --git a/tools/editors/jetbrains/gradle.properties b/tools/editors/jetbrains/gradle.properties index 2951def1..80501a1a 100644 --- a/tools/editors/jetbrains/gradle.properties +++ b/tools/editors/jetbrains/gradle.properties @@ -1,6 +1,6 @@ pluginGroup = io.ludic.ide pluginName = Ludic -pluginVersion = 1.5.0 +pluginVersion = 1.6.0 # 2024.2 (242) is the oldest platform LSP4IJ 0.20+ installs on, so it is the # floor here too: claiming older would let an IDE accept this plugin and then diff --git a/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/project/LudicAppPreview.kt b/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/project/LudicAppPreview.kt new file mode 100644 index 00000000..f3d141dc --- /dev/null +++ b/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/project/LudicAppPreview.kt @@ -0,0 +1,175 @@ +package io.ludic.ide.project + +import com.intellij.openapi.util.text.StringUtil +import java.io.File + +private fun esc(s: String) = StringUtil.escapeXmlEntities(s) + +private fun StringBuilder.row(k: String, v: String) { + append("

").append(k).append("

").append(v).append("") +} + +private fun resolve(dir: File?, value: String): File? { + if (value.isBlank()) return null + val f = File(value) + return if (f.isAbsolute) f else dir?.let { File(it, value) } +} + +/** The app a manifest's `app` lines describe, as `ludic bundle` would build it. */ +object LudicAppPreview { + fun html(m: LudicManifest, dir: File?, highlight: String? = null): String { + val app = m.app + val name = appName(m, dir) + fun img(key: String, size: Int): String? { + val f = app[key]?.let { resolve(dir, it) }?.takeIf { it.isFile } ?: return null + return "" + } + fun mark(k: String, v: String) = if (k == highlight) "$v" else v + return buildString { + append("
").append(esc(name)).append(".app
") + append("
") + append(img("icon", 64) ?: "") + append("

").append(esc(name)).append("

") + append(esc(app["id"] ?: "(no bundle id)")).append("
version ") + append(esc(app["version"] ?: m.version ?: "0.1.0")) + app["category"]?.let { append("
").append(esc(it.removePrefix("public.app-category."))) } + app["min_macos"]?.let { append("
macOS ").append(esc(it)).append("+") } + append("

") + app["splash"]?.let { s -> + val f = resolve(dir, s) + val bg = app["splash_bg"] ?: "#000000" + append("

Splash

") + if (f?.isFile == true) append("") else append(" missing: ").append(esc(s)) + append("
") + } + append("
") + for ((k, help) in LudicManifest.APP_KEYS) { + val v = app[k] ?: continue + val shown = if (k == "splash_bg") "     ${esc(v)}" else esc(v) + row(mark(k, k), mark(k, shown) + "
${esc(help)}") + } + append("
") + m.entry?.let { append("
entry ").append(esc(it)).append("
") } + } + } + + fun appName(m: LudicManifest, dir: File?) = m.app["name"] ?: m.module?.substringAfterLast('/') ?: dir?.name ?: "app" +} + +/** The Ludic tool window's details pane: one page per kind of node. */ +object LudicPackageHtml { + + fun overview(p: LudicPackageInfo): String = buildString { + val m = p.manifest + append("
").append(esc(m.module ?: p.dir.name)).append("
") + val kind = m.kind ?: "source" + val what = when { + m.app.isNotEmpty() -> "A game or app, shipped as ${esc(LudicAppPreview.appName(m, p.dir))} by ludic bundle." + m.entry != null -> "A program: ludic run starts ${esc(m.entry)}." + else -> "A library other packages import." + } + append("

").append(what).append("

") + LudicManifest.KINDS[kind]?.let { append("

").append(esc(kind)).append(": ").append(esc(it)).append("

") } + append("
") + row("Version", esc(m.version ?: "(none)") + (m.version?.let { " published by tag v${esc(it)}" } ?: "")) + m.entry?.let { row("Entry", pathCell(p, it)) } + row("Location", "${esc(p.dir.path)}") + row("Dependencies", depSummary(p)) + if (m.scripts.isNotEmpty()) row("Scripts", m.scripts.joinToString(" ") { "${esc(it.name)}" }) + if (m.hooks.isNotEmpty()) row("Hooks", m.hooks.joinToString("
") { "${esc(it.phase)} ${esc(it.event)}: ${esc(it.command)}" }) + if (m.packs.isNotEmpty()) row("Asset roots", "${m.packs.size}, " + m.packs.count { resolve(p.dir, it)?.exists() != true }.let { if (it == 0) "all present" else "$it missing" }) + m.app["name"]?.let { row("App", "${esc(it)} ${esc(m.app["id"] ?: "")}") } + append("
") + } + + private fun depSummary(p: LudicPackageInfo): String { + val reqs = p.manifest.requires + if (reqs.isEmpty()) return "none" + (if (p.transitive.isNotEmpty()) "" else " — everything comes with the toolchain") + val bad = reqs.count { p.status(it) != LudicPackageInfo.DepStatus.OK } + val base = "${reqs.size} direct, ${p.transitive.size} through them" + return p.staleReason?.let { "$base
${esc(it)}" } ?: if (bad == 0) "$base, all fetched" else "$base, $bad need fetching" + } + + private fun pathCell(p: LudicPackageInfo, v: String): String { + val ok = resolve(p.dir, v)?.exists() == true + return "${esc(v)}" + if (ok) "" else " missing" + } + + fun dependency(p: LudicPackageInfo, req: LudicManifest.Require): String = buildString { + append("
require \"").append(esc(req.module)).append("\" \"").append(esc(req.version)).append("\"
") + append("

Needs at least ").append(esc(req.version)).append("; Minimum Version Selection picks the lowest version that satisfies every requirement.

") + append("") + row("Status", esc(p.status(req).label)) + p.lockedOf(req.module)?.let { lockedRows(it) } + row("On disk", p.linkedDir(req.module)?.let { "${esc(it.path)}" } ?: "not linked — Fetch") + append("
") + } + + fun locked(p: LudicPackageInfo, l: LudicManifest.Locked): String = buildString { + append("
").append(esc(l.module)).append("
") + append("

Pulled in by a dependency, not required here directly.

") + lockedRows(l) + row("On disk", p.linkedDir(l.module)?.let { "${esc(it.path)}" } ?: "not linked — Fetch") + append("
") + } + + private fun StringBuilder.lockedRows(l: LudicManifest.Locked) { + row("Locked", esc(l.version)) + row("Kind", esc(l.kind)) + if (l.provides.isNotEmpty()) row("Provides", l.provides.joinToString { "${esc(it)}.*" }) + row("Hash", "${esc(l.hash)}") + } + + fun lock(p: LudicPackageInfo): String = buildString { + append("
").append(LudicManifest.LOCK_NAME).append("
") + append("

Written by ludic get: the exact version and content hash of every package the build uses, so every checkout builds the same thing. Commit it; do not edit it.

") + val state = when { + p.manifest.requires.isEmpty() && p.locked.isNullOrEmpty() -> "Nothing to lock: this package has no dependencies." + p.staleReason != null -> "${esc(p.staleReason!!)} Fetch to bring it up to date." + else -> "Up to date with package.ludic." + } + append("

").append(state).append("

") + p.locked?.forEach { row(esc(it.module), "${esc(it.version)} ${esc(it.hash.take(12))}…") } + append("
") + } + + fun command(p: LudicPackageInfo, c: LudicPackageInfo.Command): String = buildString { + append("
ludic ").append(c.verb).append("

").append(esc(c.help)).append("

") + if (c.verb in setOf("run", "build", "bundle")) p.manifest.entry?.let { append("

Entry: ").append(esc(it)).append("

") } + hooks(p, c.verb) + append("
") + } + + fun script(p: LudicPackageInfo, s: LudicManifest.Script): String = buildString { + val how = if (s.name in LudicManifest.BUILTIN_COMMANDS) "ludic script ${s.name}" else "ludic ${s.name}" + append("
").append(esc(how)).append("
") + append("

Runs ").append(esc(s.command)).append(" from the package root; extra arguments are passed on.

") + hooks(p, s.name) + append("
") + } + + fun hook(h: LudicManifest.Hook): String = buildString { + append("
hook ").append(esc(h.phase)).append(' ').append(esc(h.event)).append("
") + append("

").append(esc(h.command)).append("

") + LudicManifest.HOOK_PHASES[h.phase]?.let { append("

").append(esc(it)).append("

") } + append("

Runs through the shell in the package root with LUDIC_EVENT, LUDIC_PHASE and LUDIC_PACKAGE_ROOT set.

") + } + + fun assets(p: LudicPackageInfo): String = buildString { + append("
pack

") + .append(esc(LudicManifest.DIRECTIVES["pack"] ?: "")).append("

") + p.manifest.packs.forEach { v -> + val f = resolve(p.dir, v) + row("${esc(v)}", if (f?.exists() != true) "missing" else if (f.isDirectory) "directory" else "file") + } + append("
") + } + + private fun StringBuilder.hooks(p: LudicPackageInfo, event: String) { + val hs = p.hooksOf(event) + if (hs.isEmpty()) return + append("

Hooks:

") + } +} diff --git a/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/project/LudicManifestEditing.kt b/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/project/LudicManifestEditing.kt index de131289..10f0e994 100644 --- a/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/project/LudicManifestEditing.kt +++ b/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/project/LudicManifestEditing.kt @@ -352,39 +352,7 @@ class LudicManifestDocumentation : AbstractDocumentationProvider() { /** The app the `app` lines describe, as `ludic bundle` would build it. */ private fun appPreview(file: PsiFile, highlight: String?): String { - val m = LudicManifest.parse(file.text) - val app = m.app - val name = app["name"] ?: m.module?.substringAfterLast('/') ?: file.virtualFile?.parent?.name ?: "app" - fun img(key: String, size: Int): String? { - val f = app[key]?.let { resolveManifestPath(file, it) }?.takeIf { it.isFile } ?: return null - return "" - } - fun mark(k: String, v: String) = if (k == highlight) "$v" else v - return buildString { - append("
").append(esc(name)).append(".app
") - append("
") - append(img("icon", 64) ?: "") - append("

").append(esc(name)).append("

") - append(esc(app["id"] ?: "(no bundle id)")).append("
version ") - append(esc(app["version"] ?: m.version ?: "0.1.0")) - app["category"]?.let { append("
").append(esc(it.removePrefix("public.app-category."))) } - app["min_macos"]?.let { append("
macOS ").append(esc(it)).append("+") } - append("

") - app["splash"]?.let { s -> - val f = resolveManifestPath(file, s) - val bg = app["splash_bg"] ?: "#000000" - append("

Splash

") - if (f?.isFile == true) append("") else append(" missing: ").append(esc(s)) - append("
") - } - append("
") - for ((k, help) in LudicManifest.APP_KEYS) { - val v = app[k] ?: continue - val shown = if (k == "splash_bg") "     ${esc(v)}" else esc(v) - row(mark(k, k), mark(k, shown) + "
${esc(help)}") - } - append("
") - m.entry?.let { append("
entry ").append(esc(it)).append("
") } - } + val dir = file.virtualFile?.parent?.path?.let(::File) + return LudicAppPreview.html(LudicManifest.parse(file.text), dir, highlight) } } diff --git a/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/project/LudicPackageModel.kt b/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/project/LudicPackageModel.kt new file mode 100644 index 00000000..0ac55ea5 --- /dev/null +++ b/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/project/LudicPackageModel.kt @@ -0,0 +1,100 @@ +package io.ludic.ide.project + +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.vfs.LocalFileSystem +import java.io.File + +/** + * One package as the Ludic tool window shows it: the manifest (read from the + * open editor when there is one, so unsaved edits show), its lock, and where + * each dependency is linked. + */ +class LudicPackageInfo(val dir: File, val manifest: LudicManifest, val locked: List?) { + + val name: String get() = manifest.module?.substringAfterLast('/') ?: dir.name + val manifestFile: File get() = File(dir, LudicManifest.FILE_NAME) + val lockFile: File get() = File(dir, LudicManifest.LOCK_NAME) + + /** Why the dependencies need fetching, or null when they look current. */ + val staleReason: String? get() = LudicManifest.staleReason(dir, manifest) + + /** Hooks that wrap [event] (a command or a script name). */ + fun hooksOf(event: String) = manifest.hooks.filter { it.event == event } + + fun lockedOf(module: String) = locked?.firstOrNull { it.module == module } + + /** Locked modules nobody in this manifest asked for: pulled in by the dependencies. */ + val transitive: List + get() = locked.orEmpty().filter { l -> manifest.requires.none { it.module == l.module } } + + /** The directory a module is linked at (vendor/ wins, as in the compiler), or null. */ + fun linkedDir(module: String): File? { + val modules = File(dir, System.getenv("LUDIC_MODULES") ?: "ludic_modules") + return listOf(File(dir, "vendor/$module"), File(modules, module)).firstOrNull { it.isDirectory } + } + + fun status(req: LudicManifest.Require): DepStatus { + val l = lockedOf(req.module) ?: return DepStatus.NOT_LOCKED + if (linkedDir(req.module) == null) return DepStatus.NOT_LINKED + return if (LudicManifest.isSemver(l.version) && LudicManifest.isSemver(req.version) && versionLess(l.version, req.version)) + DepStatus.BELOW_MINIMUM else DepStatus.OK + } + + enum class DepStatus(val label: String) { + OK("locked"), NOT_LOCKED("not in the lock"), NOT_LINKED("not linked"), BELOW_MINIMUM("locked below the minimum"), + } + + companion object { + private val SKIP = setOf("ludic_modules", "vendor", "build", "node_modules", "out", "dist") + private const val MAX = 24 + + /** Every package.ludic under [base], the root one first, at most two levels down. */ + fun discover(base: File): List { + val found = mutableListOf() + fun walk(d: File, depth: Int) { + if (found.size >= MAX) return + if (File(d, LudicManifest.FILE_NAME).isFile) found += d + if (depth == 0) return + d.listFiles()?.filter { it.isDirectory && !it.name.startsWith('.') && it.name !in SKIP } + ?.sortedBy { it.name }?.forEach { walk(it, depth - 1) } + } + walk(base, 2) + return found.mapNotNull(::load) + } + + fun load(dir: File): LudicPackageInfo? { + val text = read(File(dir, LudicManifest.FILE_NAME)) ?: return null + val lock = read(File(dir, LudicManifest.LOCK_NAME))?.let { LudicManifest.parseLock(it) } + return LudicPackageInfo(dir, LudicManifest.parse(text), lock) + } + + /** The file's text, from its open document when it has one. */ + private fun read(f: File): String? { + if (!f.isFile) return null + val vf = LocalFileSystem.getInstance().findFileByIoFile(f) + val doc = vf?.let { FileDocumentManager.getInstance().getCachedDocument(it) } + return doc?.text ?: runCatching { f.readText() }.getOrNull() + } + + fun versionLess(a: String, b: String): Boolean { + val x = a.substringBefore('-').substringBefore('+').split('.').map { it.toIntOrNull() ?: 0 } + val y = b.substringBefore('-').substringBefore('+').split('.').map { it.toIntOrNull() ?: 0 } + for (i in 0 until 3) if (x.getOrElse(i) { 0 } != y.getOrElse(i) { 0 }) return x.getOrElse(i) { 0 } < y.getOrElse(i) { 0 } + return false + } + + /** The built-in commands the tool window offers, with what each does. */ + val COMMANDS = listOf( + Command("Run", "run", "Build the entry program and start it."), + Command("Build", "build", "Compile the entry program into build/."), + Command("Test", "test", "Compile and run every test block under the package."), + Command("Bundle", "bundle", "Build the shippable app from the app lines and the packed assets."), + Command("Pack", "pack", "Walk the pack roots into a .lpak asset archive."), + Command("Format", "fmt", "Format every .ludic file in the package."), + Command("Clean", "clean", "Remove build/."), + Command("Doctor", "doctor", "Check the toolchain: clang, the install root, the store."), + ) + } + + data class Command(val title: String, val verb: String, val help: String) +} diff --git a/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/project/LudicPackageSupport.kt b/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/project/LudicPackageSupport.kt index 740d802f..014e3303 100644 --- a/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/project/LudicPackageSupport.kt +++ b/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/project/LudicPackageSupport.kt @@ -5,6 +5,7 @@ import com.intellij.icons.AllIcons import com.intellij.ide.IconProvider import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.editor.Document import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.fileEditor.FileEditor @@ -18,6 +19,7 @@ import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.openapi.vfs.VirtualFile import com.intellij.openapi.vfs.newvfs.BulkFileListener import com.intellij.openapi.vfs.newvfs.events.VFileEvent +import com.intellij.openapi.wm.ToolWindowManager import com.intellij.psi.PsiDocumentManager import com.intellij.psi.PsiElement import com.intellij.psi.PsiFile @@ -72,12 +74,11 @@ internal data class ManifestLine(val index: Int, val range: TextRange, val text: } /** - * The bar across the top of package.ludic and package.lock.ludic. - * - * On the manifest: the package's identity and the commands that act on it, - * turning into a prompt to fetch when the lock is missing or older than the - * manifest, the way a build-file sync does. On the lock: a reminder that it is - * generated, with the commands that regenerate and check it. + * The bar across the top of package.ludic and package.lock.ludic, shown only + * when something needs doing: the toolchain is missing, or the dependencies + * need fetching (the way a build-file sync prompts). Everything else — the + * package overview, commands, scripts, dependencies, app — lives in the Ludic + * tool window, which the lock's bar also points to. */ class LudicPackageNotificationProvider : EditorNotificationProvider, DumbAware { override fun collectNotificationData(project: Project, file: VirtualFile): Function? { @@ -89,40 +90,20 @@ class LudicPackageNotificationProvider : EditorNotificationProvider, DumbAware { val text = FileDocumentManager.getInstance().getCachedDocument(file)?.charsSequence ?: runCatching { String(file.contentsToByteArray(), Charsets.UTF_8) }.getOrNull() ?: return null - val manifest = LudicManifest.parse(text) - val stale = LudicManifest.staleReason(File(dir), manifest) + val stale = LudicManifest.staleReason(File(dir), LudicManifest.parse(text)) + val reason = bannerReason(cli != null, stale) ?: return null return Function { editor -> - val panel = if (stale != null) EditorNotificationPanel(editor, EditorNotificationPanel.Status.Warning) - else EditorNotificationPanel(editor, EditorNotificationPanel.Status.Info) + val panel = EditorNotificationPanel(editor, EditorNotificationPanel.Status.Warning) panel.icon(LudicPackageIcons.MANIFEST) - val name = manifest.module ?: File(dir).name - val ver = manifest.version?.let { " $it" } ?: "" - val deps = manifest.requires.size.let { if (it == 1) "1 dependency" else "$it dependencies" } - val entry = manifest.entry?.let { " · entry $it" } ?: "" - panel.text = when { - cli == null -> "Ludic package $name$ver — the 'ludic' command was not found." - stale != null -> "$stale Fetch the dependencies so builds and code insight see them." - else -> "Ludic package $name$ver · $deps$entry" - } + panel.text = reason if (cli != null) { - val get = if (stale != null) "Fetch Dependencies" else "Fetch" - panel.createActionLabel(get) { runLudic(project, LudicCommand.CUSTOM, "get", dir) } - if (manifest.requires.isNotEmpty()) { - panel.createActionLabel("Update All") { runLudic(project, LudicCommand.CUSTOM, "update", dir) } - panel.createActionLabel("Verify") { runLudic(project, LudicCommand.CUSTOM, "verify", dir) } - } - panel.createActionLabel("Run") { runLudic(project, LudicCommand.RUN, "", dir) } - panel.createActionLabel("Test") { runLudic(project, LudicCommand.TEST, "", dir) } - panel.createActionLabel("Build") { runLudic(project, LudicCommand.BUILD, "", dir) } - panel.createActionLabel("Bundle") { runLudic(project, LudicCommand.CUSTOM, "bundle", dir) } - manifest.scripts.take(4).forEach { s -> - panel.createActionLabel("▶ ${s.name}") { runLudic(project, LudicCommand.CUSTOM, "script ${s.name}", dir) } - } + panel.createActionLabel("Fetch Dependencies") { runLudic(project, LudicCommand.CUSTOM, "get", dir) } } else { panel.createActionLabel("Set Paths…") { ShowSettingsUtil.getInstance().showSettingsDialog(project, LudicConfigurable::class.java) } } + panel.createActionLabel("Ludic Tool Window") { LudicToolWindowFactory.show(project) } panel } } @@ -136,13 +117,19 @@ class LudicPackageNotificationProvider : EditorNotificationProvider, DumbAware { OpenFileDescriptor(project, it).navigate(true) } } - if (hasCli) { - panel.createActionLabel("Regenerate") { runLudic(project, LudicCommand.CUSTOM, "get", dir) } - panel.createActionLabel("Verify Hashes") { runLudic(project, LudicCommand.CUSTOM, "verify", dir) } - panel.createActionLabel("Vendor") { runLudic(project, LudicCommand.CUSTOM, "vendor", dir) } - } + if (hasCli) panel.createActionLabel("Regenerate") { runLudic(project, LudicCommand.CUSTOM, "get", dir) } + panel.createActionLabel("Ludic Tool Window") { LudicToolWindowFactory.show(project) } panel } + + companion object { + /** The manifest bar's message, or null when there is nothing to prompt for. */ + fun bannerReason(hasCli: Boolean, stale: String?): String? = when { + !hasCli -> "The 'ludic' command was not found, so this package cannot be run or fetched." + stale != null -> "$stale Fetch the dependencies so builds and code insight see them." + else -> null + } + } } /** Keeps the bars and the library list current as manifests, locks and linked packages change. */ @@ -152,7 +139,14 @@ class LudicManifestWatcher(private val project: Project) : BulkFileListener { val n = e.path.substringAfterLast('/') n == LudicManifest.FILE_NAME || n == LudicManifest.LOCK_NAME || n == "ludic_modules" || n == "vendor" } - if (relevant && !project.isDisposed) EditorNotifications.getInstance(project).updateAllNotifications() + if (!relevant || project.isDisposed) return + EditorNotifications.getInstance(project).updateAllNotifications() + // a package.ludic that just appeared makes the Ludic tool window available + if (events.any { it.path.endsWith("/" + LudicManifest.FILE_NAME) }) { + ApplicationManager.getApplication().invokeLater({ + ToolWindowManager.getInstance(project).getToolWindow(LudicToolWindowFactory.ID)?.let { if (!it.isAvailable) it.isAvailable = true } + }, project.disposed) + } } } diff --git a/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/project/LudicToolWindow.kt b/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/project/LudicToolWindow.kt new file mode 100644 index 00000000..eaa03e41 --- /dev/null +++ b/tools/editors/jetbrains/src/main/kotlin/io/ludic/ide/project/LudicToolWindow.kt @@ -0,0 +1,468 @@ +package io.ludic.ide.project + +import com.intellij.icons.AllIcons +import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.actionSystem.Separator +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ReadAction +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.editor.event.DocumentEvent +import com.intellij.openapi.editor.event.DocumentListener +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.fileEditor.OpenFileDescriptor +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.Messages +import com.intellij.openapi.ui.SimpleToolWindowPanel +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.vfs.VirtualFileManager +import com.intellij.openapi.vfs.newvfs.BulkFileListener +import com.intellij.openapi.vfs.newvfs.events.VFileEvent +import com.intellij.openapi.wm.ToolWindow +import com.intellij.openapi.wm.ToolWindowFactory +import com.intellij.openapi.wm.ToolWindowManager +import com.intellij.ui.ColoredTreeCellRenderer +import com.intellij.ui.DoubleClickListener +import com.intellij.ui.JBSplitter +import com.intellij.ui.PopupHandler +import com.intellij.ui.ScrollPaneFactory +import com.intellij.ui.SimpleTextAttributes +import com.intellij.ui.TreeSpeedSearch +import com.intellij.ui.content.ContentFactory +import com.intellij.ui.treeStructure.Tree +import com.intellij.util.Alarm +import com.intellij.util.ui.HTMLEditorKitBuilder +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.tree.TreeUtil +import io.ludic.ide.LudicToolchain +import io.ludic.ide.run.LudicCommand +import java.awt.event.KeyAdapter +import java.awt.event.KeyEvent +import java.awt.event.MouseEvent +import java.io.File +import javax.swing.Icon +import javax.swing.JEditorPane +import javax.swing.JTree +import javax.swing.tree.DefaultMutableTreeNode +import javax.swing.tree.DefaultTreeModel +import javax.swing.tree.TreePath + +/** + * View | Tool Windows | Ludic: the package the way the Gradle window shows a + * build — what it is, the commands and scripts it runs, the hooks around them, + * its dependencies against the lock, and its app — with a details pane below. + */ +class LudicToolWindowFactory : ToolWindowFactory, DumbAware { + override fun shouldBeAvailable(project: Project): Boolean { + val base = project.basePath ?: return false + return LudicPackageInfo.discover(File(base)).isNotEmpty() + } + + override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { + val panel = LudicToolWindowPanel(project) + val content = ContentFactory.getInstance().createContent(panel, "", false) + Disposer.register(content, panel) + toolWindow.contentManager.addContent(content) + } + + companion object { + const val ID = "Ludic" + + fun show(project: Project) { + ToolWindowManager.getInstance(project).getToolWindow(ID)?.let { tw -> + tw.isAvailable = true + tw.activate(null) + } + } + } +} + +/** A row of the tree. [key] survives a refresh so expansion and selection do too. */ +internal class LudicTreeItem( + val key: String, + val title: String, + val hint: String?, + val icon: Icon?, + val pkg: LudicPackageInfo?, + val payload: Any? = null, + val line: Int? = null, + val warn: Boolean = false, +) { + override fun toString() = title +} + +internal enum class Group { OVERVIEW, APP, COMMANDS, SCRIPTS, HOOKS, DEPENDENCIES, LOCK, ASSETS } + +class LudicToolWindowPanel(private val project: Project) : SimpleToolWindowPanel(true, true), Disposable { + private val root = DefaultMutableTreeNode() + private val model = DefaultTreeModel(root) + private val tree = Tree(model) + private val details = JEditorPane().apply { + editorKit = HTMLEditorKitBuilder().build() + isEditable = false + border = JBUI.Borders.empty(8) + } + private val alarm = Alarm(Alarm.ThreadToUse.SWING_THREAD, this) + private var packages: List = emptyList() + private var firstBuild = true + + private fun isPackagePath(path: String): Boolean { + val n = path.substringAfterLast('/') + return n == LudicManifest.FILE_NAME || n == LudicManifest.LOCK_NAME || n == "ludic_modules" || n == "vendor" + } + + private fun scheduleRefresh() { + alarm.cancelAllRequests() + alarm.addRequest({ refresh() }, 300) + } + + fun refresh() { + val base = project.basePath ?: return + ApplicationManager.getApplication().executeOnPooledThread { + val found = ReadAction.compute, RuntimeException> { LudicPackageInfo.discover(File(base)) } + ApplicationManager.getApplication().invokeLater({ rebuild(found) }, project.disposed) + } + } + + // ---- the tree ------------------------------------------------------------ + + private fun rebuild(found: List) { + val expanded = mutableSetOf() + TreeUtil.collectExpandedPaths(tree).forEach { p -> item(p)?.let { expanded += it.key } } + val selected = tree.selectionPath?.let(::item)?.key + packages = found + root.removeAllChildren() + for (p in found) root.add(packageNode(p)) + model.reload() + if (firstBuild) { + // packages and their groups open; the long lists stay folded + TreeUtil.treeNodeTraverser(root).forEach { n -> + val it = (n as? DefaultMutableTreeNode)?.userObject as? LudicTreeItem ?: return@forEach + if (it.payload is LudicPackageInfo || it.payload == Group.COMMANDS || it.payload == Group.SCRIPTS) expanded += it.key + } + firstBuild = false + } + TreeUtil.treeNodeTraverser(root).forEach { n -> + val node = n as? DefaultMutableTreeNode ?: return@forEach + val it = node.userObject as? LudicTreeItem ?: return@forEach + val path = TreePath(node.path) + if (it.key in expanded) tree.expandPath(path) + if (it.key == selected) tree.selectionPath = path + } + if (tree.selectionPath == null && root.childCount > 0) { + tree.selectionPath = TreePath((root.getChildAt(0) as DefaultMutableTreeNode).path) + } + showDetails() + } + + private fun packageNode(p: LudicPackageInfo): DefaultMutableTreeNode { + val m = p.manifest + val k = p.dir.path + val node = leaf(LudicTreeItem(k, p.name, listOfNotNull(m.version, m.kind ?: "source").joinToString(" · "), + LudicPackageIcons.MANIFEST, p, p, 0, warn = p.staleReason != null)) + node.add(leaf(LudicTreeItem("$k/overview", "Overview", m.module, AllIcons.General.Information, p, Group.OVERVIEW, 0))) + if (m.app.isNotEmpty()) { + node.add(leaf(LudicTreeItem("$k/app", "App", LudicAppPreview.appName(m, p.dir), AllIcons.Nodes.Artifact, p, Group.APP, + m.app.keys.firstOrNull()?.let { key -> lineOf(p, "app", key) }))) + } + val cmds = group("$k/commands", "Commands", null, AllIcons.Nodes.ConfigFolder, p, Group.COMMANDS) + LudicPackageInfo.COMMANDS.forEach { c -> + val hooks = p.hooksOf(c.verb).joinToString(", ") { "${it.phase}: ${it.command}" } + cmds.add(leaf(LudicTreeItem("$k/cmd/${c.verb}", c.title, "ludic ${c.verb}" + if (hooks.isNotEmpty()) " ⟲ $hooks" else "", + commandIcon(c.verb), p, c))) + } + node.add(cmds) + if (m.scripts.isNotEmpty()) { + val g = group("$k/scripts", "Scripts", m.scripts.size.toString(), AllIcons.Nodes.ConfigFolder, p, Group.SCRIPTS) + m.scripts.forEach { s -> g.add(leaf(LudicTreeItem("$k/script/${s.name}", s.name, s.command, AllIcons.Actions.Execute, p, s, s.line))) } + node.add(g) + } + if (m.hooks.isNotEmpty()) { + val g = group("$k/hooks", "Hooks", m.hooks.size.toString(), AllIcons.Nodes.ConfigFolder, p, Group.HOOKS) + m.hooks.forEach { h -> + g.add(leaf(LudicTreeItem("$k/hook/${h.line}", "${h.phase} ${h.event}", h.command, AllIcons.Nodes.Tag, p, h, h.line))) + } + node.add(g) + } + val deps = group("$k/deps", "Dependencies", depHint(p), AllIcons.Nodes.PpLibFolder, p, Group.DEPENDENCIES, warn = p.staleReason != null) + m.requires.forEach { r -> + val st = p.status(r) + val locked = p.lockedOf(r.module)?.version + val hint = ">= ${r.version}" + (locked?.let { " · locked $it" } ?: "") + if (st != LudicPackageInfo.DepStatus.OK) " · ${st.label}" else "" + deps.add(leaf(LudicTreeItem("$k/dep/${r.module}", r.module, hint, AllIcons.Nodes.PpLib, p, r, r.line, warn = st != LudicPackageInfo.DepStatus.OK))) + } + p.transitive.forEach { l -> + deps.add(leaf(LudicTreeItem("$k/dep/${l.module}", l.module, "${l.version} · indirect", AllIcons.Nodes.PpLibFolder, p, l))) + } + val lockHint = when { + p.locked == null -> if (m.requires.isEmpty()) "not needed" else "missing" + p.staleReason != null -> "out of date" + else -> "${p.locked.size} pinned" + } + deps.add(leaf(LudicTreeItem("$k/lock", LudicManifest.LOCK_NAME, lockHint, LudicPackageIcons.MANIFEST, p, Group.LOCK, warn = p.staleReason != null))) + node.add(deps) + if (m.packs.isNotEmpty()) { + val g = group("$k/assets", "Asset Roots", m.packs.size.toString(), AllIcons.Nodes.Folder, p, Group.ASSETS) + m.packs.forEach { v -> + val ok = File(v).let { if (it.isAbsolute) it else File(p.dir, v) }.exists() + g.add(leaf(LudicTreeItem("$k/pack/$v", v, if (ok) null else "missing", AllIcons.Nodes.Folder, p, v, lineOf(p, "pack", v), warn = !ok))) + } + node.add(g) + } + return node + } + + private fun depHint(p: LudicPackageInfo): String { + val n = p.manifest.requires.size + return p.staleReason?.let { "fetch needed" } ?: if (n == 0) "none" else n.toString() + } + + private fun commandIcon(verb: String): Icon = when (verb) { + "run" -> AllIcons.Actions.Execute + "build" -> AllIcons.Actions.Compile + "test" -> AllIcons.RunConfigurations.TestState.Run_run + "bundle", "pack" -> AllIcons.Nodes.Artifact + "clean" -> AllIcons.Actions.GC + else -> AllIcons.Nodes.Console + } + + /** The line of the first ` ` in the package's manifest. */ + private fun lineOf(p: LudicPackageInfo, directive: String, word: String): Int? { + val text = runCatching { p.manifestFile.readText() }.getOrNull() ?: return null + return text.lines().indexOfFirst { l -> LudicManifest.words(l).let { it.getOrNull(0) == directive && it.getOrNull(1) == word } } + .takeIf { it >= 0 } + } + + private fun leaf(item: LudicTreeItem) = DefaultMutableTreeNode(item) + + private fun group(key: String, title: String, hint: String?, icon: Icon, p: LudicPackageInfo, g: Group, warn: Boolean = false) = + DefaultMutableTreeNode(LudicTreeItem(key, title, hint, icon, p, g, warn = warn)) + + private fun item(path: TreePath?): LudicTreeItem? = + (path?.lastPathComponent as? DefaultMutableTreeNode)?.userObject as? LudicTreeItem + + private fun selected(): LudicTreeItem? = item(tree.selectionPath) + + private fun currentPackage(): LudicPackageInfo? = selected()?.pkg ?: packages.firstOrNull() + + // ---- the details pane ------------------------------------------------------ + + private fun showDetails() { + val it = selected() + val p = it?.pkg + val html = when (val x = it?.payload) { + null -> if (packages.isEmpty()) "

No package.ludic in this project. Create one with ludic new.

" else "" + is LudicPackageInfo -> LudicPackageHtml.overview(x) + Group.OVERVIEW -> LudicPackageHtml.overview(p!!) + Group.APP -> LudicAppPreview.html(p!!.manifest, p.dir) + Group.LOCK, Group.DEPENDENCIES -> LudicPackageHtml.lock(p!!) + Group.ASSETS -> LudicPackageHtml.assets(p!!) + Group.COMMANDS -> "

Double-click a command to run it; its output goes to the Run tool window.

" + Group.SCRIPTS -> "

The package's own commands, from its script lines. Double-click one to run it.

" + Group.HOOKS -> "

Commands package.ludic runs before or after another command.

" + is LudicPackageInfo.Command -> LudicPackageHtml.command(p!!, x) + is LudicManifest.Script -> LudicPackageHtml.script(p!!, x) + is LudicManifest.Hook -> LudicPackageHtml.hook(x) + is LudicManifest.Require -> LudicPackageHtml.dependency(p!!, x) + is LudicManifest.Locked -> LudicPackageHtml.locked(p!!, x) + is String -> LudicPackageHtml.assets(p!!) + else -> "" + } + details.text = "$html" + details.caretPosition = 0 + } + + // ---- acting on a row --------------------------------------------------------- + + /** What Enter or a double-click does: run a command or script, or open the line. */ + private fun activate(): Boolean { + val it = selected() ?: return false + return when (it.payload) { + is LudicPackageInfo.Command, is LudicManifest.Script -> { runSelected(); true } + Group.LOCK -> { open(it.pkg!!.lockFile, 0); true } + is LudicManifest.Locked -> { open(it.pkg!!.lockFile, 0); true } + else -> if (it.line != null) { open(it.pkg!!.manifestFile, it.line); true } else false + } + } + + private fun runnable(it: LudicTreeItem?): Pair? = when (val x = it?.payload) { + is LudicPackageInfo.Command -> when (x.verb) { + "run" -> LudicCommand.RUN to "" + "build" -> LudicCommand.BUILD to "" + "test" -> LudicCommand.TEST to "" + else -> LudicCommand.CUSTOM to x.verb + } + is LudicManifest.Script -> LudicCommand.CUSTOM to "script ${x.name}" + is LudicManifest.Hook -> LudicCommand.CUSTOM to (if (x.event in LudicManifest.HOOK_EVENTS) x.event else "script ${x.event}") + is LudicPackageInfo, Group.OVERVIEW -> LudicCommand.RUN to "" + Group.APP -> LudicCommand.CUSTOM to "bundle" + else -> null + } + + private fun runSelected() { + val p = currentPackage() ?: return + val (cmd, args) = runnable(selected()) ?: return + run(p, cmd, args) + } + + private fun run(p: LudicPackageInfo, cmd: LudicCommand, args: String) = runLudic(project, cmd, args, p.dir.path) + + private fun open(f: File, line: Int) { + val vf = LocalFileSystem.getInstance().refreshAndFindFileByIoFile(f) ?: return + OpenFileDescriptor(project, vf, line, 0).navigate(true) + } + + private fun addDependency() { + val p = currentPackage() ?: return + val spec = Messages.showInputDialog(project, + "Module path, optionally with a version:\ngit.example.com/me/util or git.example.com/me/util@1.2.0", + "Add Ludic Dependency", AllIcons.General.Add)?.trim().orEmpty() + if (spec.isEmpty() || spec.any { it.isWhitespace() || it == '"' || it == '\'' }) return + run(p, LudicCommand.CUSTOM, "add $spec") + } + + private fun removeDependency(p: LudicPackageInfo, r: LudicManifest.Require) { + val ok = Messages.showYesNoDialog(project, "Remove ${r.module} from package.ludic and fetch again?", "Remove Dependency", null) + if (ok != Messages.YES) return + val vf = LocalFileSystem.getInstance().refreshAndFindFileByIoFile(p.manifestFile) ?: return + val doc = FileDocumentManager.getInstance().getDocument(vf) ?: return + if (r.line >= doc.lineCount || LudicManifest.words(doc.getText(com.intellij.openapi.util.TextRange(doc.getLineStartOffset(r.line), doc.getLineEndOffset(r.line)))).getOrNull(1) != r.module) return + WriteCommandAction.runWriteCommandAction(project, "Remove Dependency", null, { + val end = if (r.line + 1 < doc.lineCount) doc.getLineStartOffset(r.line + 1) else doc.getLineEndOffset(r.line) + doc.deleteString(doc.getLineStartOffset(r.line), end) + }) + run(p, LudicCommand.CUSTOM, "get") + } + + // ---- actions ----------------------------------------------------------------- + + private inner class Act(text: String, icon: Icon?, private val enabled: () -> Boolean = { currentPackage() != null }, private val body: () -> Unit) : + AnAction(text, null, icon), DumbAware { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + override fun update(e: AnActionEvent) { + val cli = LudicToolchain.cli(project) != null + e.presentation.isEnabled = enabled() && (cli || templatePresentation.text == "Refresh" || templatePresentation.text.startsWith("Open")) + } + override fun actionPerformed(e: AnActionEvent) = body() + } + + private fun pkgCmd(text: String, icon: Icon, verb: String) = Act(text, icon) { currentPackage()?.let { run(it, LudicCommand.CUSTOM, verb) } } + + private val runAct by lazy { + Act("Run", AllIcons.Actions.Execute, { runnable(selected()) != null }) { runSelected() } + } + private val refreshAct by lazy { + Act("Refresh", AllIcons.Actions.Refresh, { true }) { refresh() } + } + private val fetchAct by lazy { + pkgCmd("Fetch Dependencies (ludic get)", AllIcons.Actions.Download, "get") + } + private val updateAllAct by lazy { + pkgCmd("Update All Dependencies (ludic update)", AllIcons.Actions.CheckOut, "update") + } + private val verifyAct by lazy { + pkgCmd("Verify Locked Packages (ludic verify)", AllIcons.Actions.Checked, "verify") + } + private val vendorAct by lazy { + pkgCmd("Vendor Dependencies (ludic vendor)", AllIcons.Nodes.PpLibFolder, "vendor") + } + private val addAct by lazy { + Act("Add Dependency…", AllIcons.General.Add) { addDependency() } + } + private val openManifestAct by lazy { + Act("Open package.ludic", AllIcons.Actions.EditSource) { + val it = selected() + currentPackage()?.let { p -> open(p.manifestFile, it?.line ?: 0) } + } + } + private val updateOneAct by lazy { + Act("Update This Dependency", AllIcons.Actions.CheckOut, { selected()?.payload is LudicManifest.Require }) { + val it = selected() + (it?.payload as? LudicManifest.Require)?.let { r -> run(it.pkg!!, LudicCommand.CUSTOM, "update ${r.module}") } + } + } + private val removeAct by lazy { + Act("Remove Dependency…", AllIcons.General.Remove, { selected()?.payload is LudicManifest.Require }) { + val it = selected() + (it?.payload as? LudicManifest.Require)?.let { r -> removeDependency(it.pkg!!, r) } + } + } + private val openLockAct by lazy { + Act("Open package.lock.ludic", AllIcons.Actions.EditSource, { currentPackage()?.lockFile?.isFile == true }) { + currentPackage()?.let { open(it.lockFile, 0) } + } + } + private fun toolbarGroup() = DefaultActionGroup().apply { + add(refreshAct); add(runAct); add(Separator.getInstance()) + add(fetchAct); add(updateAllAct); add(verifyAct); add(addAct); add(Separator.getInstance()) + add(openManifestAct) + } + + private fun popupGroup() = DefaultActionGroup().apply { + add(runAct); add(openManifestAct); add(Separator.getInstance()) + add(updateOneAct); add(removeAct); add(addAct); add(Separator.getInstance()) + add(fetchAct); add(updateAllAct); add(verifyAct); add(vendorAct); add(openLockAct) + } + + // last, so every action above exists before the toolbar and menu are built + init { + tree.isRootVisible = false + tree.showsRootHandles = true + tree.cellRenderer = Renderer() + TreeSpeedSearch.installOn(tree) + tree.addTreeSelectionListener { showDetails() } + object : DoubleClickListener() { + override fun onDoubleClick(event: MouseEvent): Boolean = activate() + }.installOn(tree) + tree.addKeyListener(object : KeyAdapter() { + override fun keyPressed(e: KeyEvent) { if (e.keyCode == KeyEvent.VK_ENTER && activate()) e.consume() } + }) + PopupHandler.installPopupMenu(tree, popupGroup(), "LudicToolWindowPopup") + + val splitter = JBSplitter(true, "ludic.toolwindow.split", 0.6f) + splitter.firstComponent = ScrollPaneFactory.createScrollPane(tree, true) + splitter.secondComponent = ScrollPaneFactory.createScrollPane(details, true) + setContent(splitter) + + val bar = ActionManager.getInstance().createActionToolbar("LudicToolWindow", toolbarGroup(), true) + bar.targetComponent = this + toolbar = bar.component + + project.messageBus.connect(this).subscribe(VirtualFileManager.VFS_CHANGES, object : BulkFileListener { + override fun after(events: List) { + if (events.any { isPackagePath(it.path) }) scheduleRefresh() + } + }) + EditorFactory.getInstance().eventMulticaster.addDocumentListener(object : DocumentListener { + override fun documentChanged(event: DocumentEvent) { + val f = FileDocumentManager.getInstance().getFile(event.document) ?: return + if (LudicManifest.isManifest(f) || LudicManifest.isLock(f)) scheduleRefresh() + } + }, this) + refresh() + } + + override fun dispose() {} + + private class Renderer : ColoredTreeCellRenderer() { + override fun customizeCellRenderer(tree: JTree, value: Any?, selected: Boolean, expanded: Boolean, leaf: Boolean, row: Int, hasFocus: Boolean) { + val it = (value as? DefaultMutableTreeNode)?.userObject as? LudicTreeItem ?: return + icon = if (it.warn && it.payload !is LudicPackageInfo) AllIcons.General.Warning else it.icon + val attrs = when { + it.payload is LudicPackageInfo -> SimpleTextAttributes.REGULAR_BOLD_ATTRIBUTES + it.warn -> SimpleTextAttributes.ERROR_ATTRIBUTES + else -> SimpleTextAttributes.REGULAR_ATTRIBUTES + } + append(it.title, attrs) + it.hint?.let { h -> append(" $h", SimpleTextAttributes.GRAYED_ATTRIBUTES) } + toolTipText = it.hint + } + } +} diff --git a/tools/editors/jetbrains/src/main/resources/META-INF/plugin.xml b/tools/editors/jetbrains/src/main/resources/META-INF/plugin.xml index b0f9b0e6..30ec6496 100644 --- a/tools/editors/jetbrains/src/main/resources/META-INF/plugin.xml +++ b/tools/editors/jetbrains/src/main/resources/META-INF/plugin.xml @@ -100,6 +100,8 @@ + diff --git a/tools/editors/jetbrains/src/main/resources/icons/ludicToolWindow.svg b/tools/editors/jetbrains/src/main/resources/icons/ludicToolWindow.svg new file mode 100644 index 00000000..906bbda1 --- /dev/null +++ b/tools/editors/jetbrains/src/main/resources/icons/ludicToolWindow.svg @@ -0,0 +1,4 @@ + + + + diff --git a/tools/editors/jetbrains/src/main/resources/icons/ludicToolWindow_dark.svg b/tools/editors/jetbrains/src/main/resources/icons/ludicToolWindow_dark.svg new file mode 100644 index 00000000..dce9872f --- /dev/null +++ b/tools/editors/jetbrains/src/main/resources/icons/ludicToolWindow_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/tools/editors/jetbrains/src/test/kotlin/io/ludic/ide/LudicManifestTest.kt b/tools/editors/jetbrains/src/test/kotlin/io/ludic/ide/LudicManifestTest.kt index 3c2a3c5a..c2d8a59c 100644 --- a/tools/editors/jetbrains/src/test/kotlin/io/ludic/ide/LudicManifestTest.kt +++ b/tools/editors/jetbrains/src/test/kotlin/io/ludic/ide/LudicManifestTest.kt @@ -100,12 +100,64 @@ class LudicManifestTest : BasePlatformTestCase() { assertNotNull(LudicManifestDocumentation().generateDoc(comment, comment)) } - fun testBannerAppearsOnlyOnTheManifest() { + fun testBannerOnlyPromptsWhenSomethingNeedsDoing() { val provider = LudicPackageNotificationProvider() - val man = myFixture.configureByText("package.ludic", manifest).virtualFile - assertNotNull(provider.collectNotificationData(project, man)) val src = myFixture.configureByText("main.ludic", "program X {}").virtualFile assertNull(provider.collectNotificationData(project, src)) + // the everyday commands moved to the tool window: no bar for a healthy package + assertNull(LudicPackageNotificationProvider.bannerReason(true, null)) + assertTrue(LudicPackageNotificationProvider.bannerReason(true, "There is no lock.")!!.contains("Fetch")) + assertTrue(LudicPackageNotificationProvider.bannerReason(false, null)!!.contains("'ludic'")) + } + + fun testToolWindowPanelBuilds() { + myFixture.configureByText("package.ludic", manifest) + val panel = io.ludic.ide.project.LudicToolWindowPanel(project) + try { + assertNotNull(panel.toolbar) + assertNotNull(panel.content) + } finally { + com.intellij.openapi.util.Disposer.dispose(panel) + } + } + + fun testToolWindowModelReadsPackageLockAndLinks() { + val dir = java.nio.file.Files.createTempDirectory("ludicpkg").toFile() + try { + java.io.File(dir, "package.ludic").writeText( + "package \"git.x/me/game\"\nversion \"0.2.0\"\nentry \"src/g.ludic\"\n" + + "require \"git.x/me/util\" \"1.1.0\"\nrequire \"git.x/me/net\" \"2.0.0\"\n" + + "script \"check\" \"tests/check.sh\"\nhook before bundle \"tests/pre.sh\"\napp name \"Game\"\npack \"assets\"\n" + ) + java.io.File(dir, "package.lock.ludic").writeText( + "lock 1\nmodule \"git.x/me/util\" version \"1.0.0\" hash \"h1\" kind \"source\"\n" + + "module \"git.x/me/deep\" version \"0.3.0\" hash \"h2\" kind \"source\"\n" + ) + java.io.File(dir, "ludic_modules/git.x/me/util").mkdirs() + java.io.File(dir, "sub/lib").mkdirs() + java.io.File(dir, "sub/lib/package.ludic").writeText("package \"git.x/me/lib\"\n") + java.io.File(dir, "ludic_modules/git.x/me/util/package.ludic").writeText("package \"git.x/me/util\"\n") + + val found = io.ludic.ide.project.LudicPackageInfo.discover(dir) + assertEquals("the root first, fetched modules skipped", listOf(dir, java.io.File(dir, "sub/lib")), found.map { it.dir }) + val p = found.first() + assertEquals("game", p.name) + val (util, net) = p.manifest.requires + assertEquals(io.ludic.ide.project.LudicPackageInfo.DepStatus.BELOW_MINIMUM, p.status(util)) + assertEquals(io.ludic.ide.project.LudicPackageInfo.DepStatus.NOT_LOCKED, p.status(net)) + assertEquals(listOf("git.x/me/deep"), p.transitive.map { it.module }) + assertEquals(listOf("tests/pre.sh"), p.hooksOf("bundle").map { it.command }) + + val html = io.ludic.ide.project.LudicPackageHtml.overview(p) + assertTrue(html, html.contains("Game") && html.contains("src/g.ludic") && html.contains("check")) + assertTrue(io.ludic.ide.project.LudicPackageHtml.dependency(p, util).contains("locked below the minimum")) + val cmd = io.ludic.ide.project.LudicPackageInfo.COMMANDS.first { it.verb == "bundle" } + assertTrue(io.ludic.ide.project.LudicPackageHtml.command(p, cmd).contains("tests/pre.sh")) + assertTrue(io.ludic.ide.project.LudicAppPreview.html(p.manifest, p.dir).contains("Game.app")) + assertTrue(io.ludic.ide.project.LudicPackageInfo.versionLess("1.2.9", "1.10.0")) + } finally { + dir.deleteRecursively() + } } fun testPathValuesNavigateAndMissingOnesAreErrors() { diff --git a/tools/ludic-cli/tools.ludic b/tools/ludic-cli/tools.ludic index 7b3bec4e..0d77c24f 100644 --- a/tools/ludic-cli/tools.ludic +++ b/tools/ludic-cli/tools.ludic @@ -166,7 +166,7 @@ function cmd_test_tools() -> int { # IntelliJ SDK). One env var away: LUDIC_TEST_JETBRAINS=1 ludic-dev test-tools if (getenv_or("LUDIC_TEST_JETBRAINS", "0") == "1") { if shq("test -x tools/editors/jetbrains/gradlew") { - if shq("cd tools/editors/jetbrains && ./gradlew buildPlugin verifyPluginStructure --console=plain -q >/dev/null 2>&1 && test -f build/distributions/Ludic-1.5.0.zip") { ok("jetbrains plugin builds and verifies") } else { bad("jetbrains plugin build") } + if shq("cd tools/editors/jetbrains && ./gradlew buildPlugin verifyPluginStructure --console=plain -q >/dev/null 2>&1 && test -f build/distributions/Ludic-1.6.0.zip") { ok("jetbrains plugin builds and verifies") } else { bad("jetbrains plugin build") } if shq("cd tools/editors/jetbrains && ./gradlew test --console=plain -q >/dev/null 2>&1") { ok("jetbrains platform tests (lexer, file type, PSI, folding)") } else { bad("jetbrains platform tests") } } else { print(" skip jetbrains plugin (no gradle wrapper)") } } else {