From 8b7df1d2f39176871145907ee04ba4c74af56cf8 Mon Sep 17 00:00:00 2001 From: joe Date: Tue, 10 Nov 2026 23:11:50 +0000 Subject: [PATCH 1/5] Start on annotater editor --- annotater-review.md | 28 + build.gradle.kts | 1 + .../src/main/kotlin/mcdev-core.gradle.kts | 1 + src/main/kotlin/asset/GeneralAssets.kt | 3 + .../fabric/annotater/AnnotaterCheckout.kt | 38 ++ .../annotater/AnnotaterCheckoutService.kt | 215 +++++++ .../fabric/annotater/AnnotaterConfig.kt | 83 +++ .../fabric/annotater/AnnotaterConfigurable.kt | 228 ++++++++ .../fabric/annotater/AnnotaterListener.kt | 41 ++ .../annotater/AnnotaterNotifications.kt | 80 +++ .../fabric/annotater/AnnotaterSettings.kt | 102 ++++ .../platform/fabric/annotater/AnnotaterVcs.kt | 119 ++++ .../fabric/annotater/git/AnnotaterGitVcs.kt | 426 ++++++++++++++ .../github/AnnotaterGitHubPullRequests.kt | 312 +++++++++++ .../github/AnnotaterPullRequestDialog.kt | 88 +++ ...notaterJavadocCodeStyleSettingsModifier.kt | 46 ++ .../AnnotaterJavadocCommentInspection.kt | 88 +++ .../javadoc/AnnotaterJavadocContributions.kt | 222 ++++++++ .../AnnotaterJavadocDocumentationProvider.kt | 41 ++ .../javadoc/AnnotaterJavadocEditing.kt | 197 +++++++ ...otaterJavadocEditorNotificationProvider.kt | 93 +++ .../AnnotaterJavadocFormattingRestriction.kt | 32 ++ .../AnnotaterJavadocInlayPassFactory.kt | 211 +++++++ .../javadoc/AnnotaterJavadocInlayRenderer.kt | 136 +++++ .../javadoc/AnnotaterJavadocInlineEditor.kt | 529 ++++++++++++++++++ .../AnnotaterJavadocIntentionActionFilter.kt | 50 ++ .../javadoc/AnnotaterJavadocRefresher.kt | 76 +++ .../javadoc/AnnotaterJavadocRendering.kt | 97 ++++ .../javadoc/AnnotaterJavadocSourceCopy.kt | 233 ++++++++ .../javadoc/AnnotaterJavadocStore.kt | 250 +++++++++ .../javadoc/AnnotaterJavadocTarget.kt | 106 ++++ ...erJavadocUnqualifiedReferenceInspection.kt | 93 +++ .../AnnotaterJavadocVetoRenameCondition.kt | 31 + .../javadoc/AnnotaterJavadocVirtualFile.kt | 57 ++ .../javadoc/EditAnnotaterJavadocAction.kt | 52 ++ .../javadoc/EditAnnotaterJavadocIntention.kt | 53 ++ .../META-INF/mcdev-annotater-git.xml | 28 + .../META-INF/mcdev-annotater-github.xml | 26 + src/main/resources/META-INF/plugin.xml | 53 ++ .../icons/general/Annotater.LICENSE.txt | 3 + .../assets/icons/general/Annotater.png | Bin 0 -> 655 bytes .../assets/icons/general/Annotater@2x.png | Bin 0 -> 1505 bytes .../assets/icons/general/AnnotaterLarge.png | Bin 0 -> 1505 bytes .../icons/general/AnnotaterLarge@2x.png | Bin 0 -> 3192 bytes .../description.html | 25 + .../messages/MinecraftDevelopment.properties | 87 +++ .../annotater/AnnotaterJavadocEditorTest.kt | 403 +++++++++++++ .../annotater/AnnotaterJavadocStoreTest.kt | 141 +++++ 48 files changed, 5224 insertions(+) create mode 100644 annotater-review.md create mode 100644 src/main/kotlin/platform/fabric/annotater/AnnotaterCheckout.kt create mode 100644 src/main/kotlin/platform/fabric/annotater/AnnotaterCheckoutService.kt create mode 100644 src/main/kotlin/platform/fabric/annotater/AnnotaterConfig.kt create mode 100644 src/main/kotlin/platform/fabric/annotater/AnnotaterConfigurable.kt create mode 100644 src/main/kotlin/platform/fabric/annotater/AnnotaterListener.kt create mode 100644 src/main/kotlin/platform/fabric/annotater/AnnotaterNotifications.kt create mode 100644 src/main/kotlin/platform/fabric/annotater/AnnotaterSettings.kt create mode 100644 src/main/kotlin/platform/fabric/annotater/AnnotaterVcs.kt create mode 100644 src/main/kotlin/platform/fabric/annotater/git/AnnotaterGitVcs.kt create mode 100644 src/main/kotlin/platform/fabric/annotater/github/AnnotaterGitHubPullRequests.kt create mode 100644 src/main/kotlin/platform/fabric/annotater/github/AnnotaterPullRequestDialog.kt create mode 100644 src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocCodeStyleSettingsModifier.kt create mode 100644 src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocCommentInspection.kt create mode 100644 src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocContributions.kt create mode 100644 src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocDocumentationProvider.kt create mode 100644 src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocEditing.kt create mode 100644 src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocEditorNotificationProvider.kt create mode 100644 src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocFormattingRestriction.kt create mode 100644 src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlayPassFactory.kt create mode 100644 src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlayRenderer.kt create mode 100644 src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlineEditor.kt create mode 100644 src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocIntentionActionFilter.kt create mode 100644 src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocRefresher.kt create mode 100644 src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocRendering.kt create mode 100644 src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocSourceCopy.kt create mode 100644 src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocStore.kt create mode 100644 src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocTarget.kt create mode 100644 src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocUnqualifiedReferenceInspection.kt create mode 100644 src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocVetoRenameCondition.kt create mode 100644 src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocVirtualFile.kt create mode 100644 src/main/kotlin/platform/fabric/annotater/javadoc/EditAnnotaterJavadocAction.kt create mode 100644 src/main/kotlin/platform/fabric/annotater/javadoc/EditAnnotaterJavadocIntention.kt create mode 100644 src/main/resources/META-INF/mcdev-annotater-git.xml create mode 100644 src/main/resources/META-INF/mcdev-annotater-github.xml create mode 100644 src/main/resources/assets/icons/general/Annotater.LICENSE.txt create mode 100644 src/main/resources/assets/icons/general/Annotater.png create mode 100644 src/main/resources/assets/icons/general/Annotater@2x.png create mode 100644 src/main/resources/assets/icons/general/AnnotaterLarge.png create mode 100644 src/main/resources/assets/icons/general/AnnotaterLarge@2x.png create mode 100644 src/main/resources/intentionDescriptions/EditAnnotaterJavadocIntention/description.html create mode 100644 src/test/kotlin/platform/fabric/annotater/AnnotaterJavadocEditorTest.kt create mode 100644 src/test/kotlin/platform/fabric/annotater/AnnotaterJavadocStoreTest.kt diff --git a/annotater-review.md b/annotater-review.md new file mode 100644 index 000000000..cd14dc54d --- /dev/null +++ b/annotater-review.md @@ -0,0 +1,28 @@ +- Add a way to delete a contribution (with an "are you sure" dialog). Only delete it locally, don't touch the remotes or any pull request. +- Add a checkbox in the pull request dialog (on by default) to check the build before creating the pull request. This is done by running `./gradlew check` in the annotater directory. This can take a minute or so to run, so a progress dialog is required. If the check fails, the user should be presented with a dialog containing the failure log, with options to cancel (default) or submit anyway. +- If a pull request has already been created for a contribution, the "create pull request" button should be replaced with an "update pull request" button, which should only appear if there are modifications to be pushed (just as "create pull request" should). +- When you open a pull request, why do you browse to it and then add a button to browse to it? I would just leave it at the button. +- `AnnotaterJavadocCommentInspection.findMarkdownMigrationFixes` should instead use reflection to construct the fix. Put the reference to the constructor in `McdevReflection`. +- The method names in `AnnotaterJavadocContributions`: `switchTo`, `checkUncommittedChanges`, and `switch` aren't clear enough, especially `checkUncommittedChanges` doesn't really describe that it does the switch afterwards. +- The `DocumentationProvider` docs recommend migrating to `DocumentationTarget`. Evaluate whether we can do that for `AnnotaterJavadocDocumentationProvider`, and if we can, do it. +- `AnnotaterJavadocEditing.edit` can be renamed to an overload of `open` (can therefore move the methods into a more logical order). +- `AnnotaterJavadocEditing.openEditor` uses `ReadAction.compute`, which is deprecated. +- Rename the icon to `tiny_potato`. +- What is `AnnotaterJavadocInlayPassFactory.Pass.active` for? If it's a workaround for something, a comment would be nice. +- Companion objects inside extension point impl classes sometimes generate an intellij warning. Where these appear, change them to named objects, as we do in the rest of the project. +- Storing the inlay for `offset` in `AnnotaterJavadocInlayRenderer` looks like a bit of a hack. Is there precedent for this in IntelliJ's codebase? Why can't `initialOffset` always be used? +- `AnnotaterJavadocInlayRenderer` attempts to get proper Javadoc highlighting colors with the token thing, and while it does properly turn the text green, it doesn't highlight other things, like turning the color of referenced parameters. Fix this if it's an easy fix, otherwise tell me how you would fix it and wait for my approval before fixing it. +- `AnnotaterJavadocInlineEditor` uses `Alarm` which is obsolete. Follow the advice in the `Alarm` docs to replace it, if possible. If that's kinda complicated, then not saving until editing is finished is acceptable. +- Since it now should be difficult to accidentally edit readonly sections, the `ReadonlyFragmentModificationHandler` can be removed. +- The document modification guards should be not greedy at the end close to the edited javadoc, and greedy on the other end. +- In `AnnotaterJavadocInlineEditor.dispose`, it disposes the inlay directly. Isn't the normal solution to this to register the inlay with `Disposer.register` instead? +- Why does `AnnotaterJavadocInlineEditor.ProjectDisposable` exist, why not register the inline editor disposable directly with the project (which is itself disposable)? +- In `AnnotaterJavadocRefresher`, use `BulkFileListenerBackgroundable` instead. +- Why is `AnnotaterJavadocRendering.getJavadoc` in that class? I feel like that would fit better in another class. +- Make it so `AnnotaterJavadocSourceCopy.createVirtualFile` uses the language level of the file it's copied from, or Java 23 (the version Markdown Javadocs were introduced), whichever is newer. Same in `AnnotaterJavadocVirtualFile`. +- Why do we have both `AnnotaterJavadocSourceCopy.createVirtualFile` and `AnnotaterJavadocVirtualFile`? +- Local classes should be possible to document. Members within local and anonymous classes should also be possible to document. +- There's a lot of methods throughout this feature that are public but could be made private. +- The `EnigmaFileWriter` `VisitOrder` doesn't match what is used by annotater. Also you should update `mapping-io` in mcdev to the latest version. +- Fix the lint error on `EditAnnotaterJavadocIntention`. +- Find a built-in or library equivalent to `AnnotaterConfig.validatePath`, rather than writing it yourself. \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 760d26e87..1df16e2a9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -127,6 +127,7 @@ dependencies { bundledPlugin("org.jetbrains.kotlin") bundledPlugin("org.toml.lang") bundledPlugin("org.jetbrains.plugins.yaml") + bundledPlugin("org.jetbrains.plugins.github") testFramework(TestFrameworkType.JUnit5) testFramework(TestFrameworkType.Platform) diff --git a/buildSrc/src/main/kotlin/mcdev-core.gradle.kts b/buildSrc/src/main/kotlin/mcdev-core.gradle.kts index b23c1c945..46b4228e6 100644 --- a/buildSrc/src/main/kotlin/mcdev-core.gradle.kts +++ b/buildSrc/src/main/kotlin/mcdev-core.gradle.kts @@ -193,6 +193,7 @@ idea { tasks.runIde { maxHeapSize = "2G" jvmArgs("--add-exports=java.base/jdk.internal.vm=ALL-UNNAMED") + systemProperty("mcdev.annotater.upstream", "Earthcomputer/annotater") } tasks.test { diff --git a/src/main/kotlin/asset/GeneralAssets.kt b/src/main/kotlin/asset/GeneralAssets.kt index 8fdf6cec5..b9046bdb9 100644 --- a/src/main/kotlin/asset/GeneralAssets.kt +++ b/src/main/kotlin/asset/GeneralAssets.kt @@ -25,4 +25,7 @@ object GeneralAssets : Assets() { val PLUGIN = loadIcon("/assets/icons/general/plugin.png") val MC_TEMPLATE = loadIcon("/assets/icons/general/MinecraftTemplate.png") + + val ANNOTATER = loadIcon("/assets/icons/general/Annotater.png") + val ANNOTATER_LARGE = loadIcon("/assets/icons/general/AnnotaterLarge.png") } diff --git a/src/main/kotlin/platform/fabric/annotater/AnnotaterCheckout.kt b/src/main/kotlin/platform/fabric/annotater/AnnotaterCheckout.kt new file mode 100644 index 000000000..bfaf4a324 --- /dev/null +++ b/src/main/kotlin/platform/fabric/annotater/AnnotaterCheckout.kt @@ -0,0 +1,38 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.platform.fabric.annotater + +import com.demonwav.mcdev.platform.fabric.annotater.javadoc.AnnotaterJavadocStore +import java.nio.file.Path + +/** + * An annotater checkout on disk for one Minecraft version. + * + * @property managed whether the plugin created this checkout and may run git operations in it + */ +class AnnotaterCheckout(val root: Path, val config: AnnotaterConfig, val managed: Boolean) { + val version: String + get() = config.version + + val mappingsDir: Path = root.resolve(config.mappings) + + val javadocStore = AnnotaterJavadocStore(mappingsDir) +} diff --git a/src/main/kotlin/platform/fabric/annotater/AnnotaterCheckoutService.kt b/src/main/kotlin/platform/fabric/annotater/AnnotaterCheckoutService.kt new file mode 100644 index 000000000..3a81265f0 --- /dev/null +++ b/src/main/kotlin/platform/fabric/annotater/AnnotaterCheckoutService.kt @@ -0,0 +1,215 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.platform.fabric.annotater + +import com.demonwav.mcdev.facet.MinecraftFacet +import com.demonwav.mcdev.platform.fabric.FabricModuleType +import com.demonwav.mcdev.platform.minecraft.MinecraftModuleSettings +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.module.ModuleManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.roots.ProjectFileIndex +import com.intellij.openapi.util.Key +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiElement +import com.intellij.psi.util.PsiUtilCore +import java.io.IOException +import java.nio.file.Path +import java.util.concurrent.ConcurrentHashMap +import kotlin.io.path.Path +import kotlin.io.path.exists + +/** + * Finds the annotater checkout to use for Minecraft classes. + */ +@Service(Service.Level.APP) +class AnnotaterCheckoutService { + private val checkouts = ConcurrentHashMap() + + init { + ApplicationManager.getApplication().messageBus.connect().subscribe( + AnnotaterListener.TOPIC, + object : AnnotaterListener { + override fun checkoutsChanged() { + checkouts.clear() + } + }, + ) + } + + sealed class Status { + data class Available(val checkout: AnnotaterCheckout) : Status() + data object Disabled : Status() + data object UnknownMinecraftVersion : Status() + data class NotCloned(val version: String) : Status() + data class VersionMismatch(val expected: String, val actual: String) : Status() + data class Invalid(val root: Path, val message: String) : Status() + } + + /** + * Finds the checkout for [version] according to the user's settings, ignoring whether the feature is enabled. + */ + fun statusForVersion(version: String): Status { + val settings = AnnotaterSettings.instance + return when (settings.checkoutMode) { + AnnotaterSettings.CheckoutMode.MANAGED -> { + val root = managedWorktree(version) + if (!root.resolve(AnnotaterConfig.FILE_NAME).exists()) { + return Status.NotCloned(version) + } + load(root, managed = true) + } + + AnnotaterSettings.CheckoutMode.CUSTOM -> { + if (settings.customDirectory.isBlank()) { + return Status.Invalid(Path(""), "No annotater directory selected") + } + when (val status = load(Path(settings.customDirectory), managed = false)) { + is Status.Available -> if (status.checkout.version != version) { + Status.VersionMismatch(version, status.checkout.version) + } else { + status + } + + else -> status + } + } + } + } + + fun checkoutForVersion(version: String): AnnotaterCheckout? { + return (statusForVersion(version) as? Status.Available)?.checkout + } + + fun status(element: PsiElement): Status { + if (!AnnotaterSettings.instance.enabled) { + return Status.Disabled + } + val file = PsiUtilCore.getVirtualFile(element) ?: return Status.UnknownMinecraftVersion + val version = minecraftVersion(element.project, file) ?: return Status.UnknownMinecraftVersion + return statusForVersion(version) + } + + /** + * The checkout to use for [element], or null if annotater is disabled, [element] isn't part of Minecraft, or no + * checkout exists for its Minecraft version. + */ + fun checkoutFor(element: PsiElement): AnnotaterCheckout? { + if (!AnnotaterSettings.instance.enabled) { + return null + } + val file = PsiUtilCore.getVirtualFile(element) ?: return null + return checkoutFor(element.project, file) + } + + /** + * The checkout to use for [file] in [project], or null if annotater is disabled, [file] isn't part of Minecraft, + * or no checkout exists for its Minecraft version. + */ + fun checkoutFor(project: Project, file: VirtualFile): AnnotaterCheckout? { + if (!AnnotaterSettings.instance.enabled || !isMinecraftFile(project, file)) { + return null + } + val version = minecraftVersion(project, file) ?: return null + return checkoutForVersion(version) + } + + /** + * Updates cached data after files changed on disk, returning the root and outer class of each changed Javadoc + * file, and whether a `config.json` changed. + */ + fun filesChanged(paths: Collection): Pair>, Boolean> { + val changedJavadocs = mutableListOf>() + var configChanged = false + for (checkout in checkouts.values) { + for (path in paths) { + if (path == checkout.root.resolve(AnnotaterConfig.FILE_NAME)) { + configChanged = true + } else if (path.startsWith(checkout.mappingsDir) && path.fileName.toString().endsWith(".mapping")) { + val outerClass = checkout.mappingsDir.relativize(path).joinToString("/").removeSuffix(".mapping") + checkout.javadocStore.invalidate(outerClass) + changedJavadocs += checkout.root to outerClass + } + } + } + return changedJavadocs to configChanged + } + + fun managedWorktree(version: String): Path = AnnotaterSettings.instance.effectiveManagedRoot.resolve(version) + + private fun load(root: Path, managed: Boolean): Status { + checkouts[root]?.let { return Status.Available(it) } + return try { + val checkout = AnnotaterCheckout(root, AnnotaterConfig.read(root), managed) + Status.Available(checkouts.putIfAbsent(root, checkout) ?: checkout) + } catch (e: IOException) { + thisLogger().debug("Failed to read annotater checkout at $root", e) + Status.Invalid(root, e.message ?: e.toString()) + } + } + + companion object { + private val IS_MINECRAFT_ROOT = Key.create("mcdev.annotater.isMinecraftRoot") + + val instance: AnnotaterCheckoutService + get() = ApplicationManager.getApplication().getService(AnnotaterCheckoutService::class.java) + + /** + * Whether any module of [project] is a Fabric mod. Annotater is a Fabric project, so it's offered more + * prominently there. + */ + fun isFabricProject(project: Project): Boolean { + return ModuleManager.getInstance(project).modules.any { MinecraftFacet.getInstance(it, FabricModuleType) != null } + } + + /** + * Whether [file] is a class or source file from a Minecraft jar, as opposed to project sources or another + * library. + */ + fun isMinecraftFile(project: Project, file: VirtualFile): Boolean { + val fileIndex = ProjectFileIndex.getInstance(project) + if (!fileIndex.isInLibrary(file)) { + return false + } + val root = fileIndex.getClassRootForFile(file) ?: fileIndex.getSourceRootForFile(file) ?: return false + root.getUserData(IS_MINECRAFT_ROOT)?.let { return it } + val isMinecraft = root.findFileByRelativePath("net/minecraft") != null + root.putUserData(IS_MINECRAFT_ROOT, isMinecraft) + return isMinecraft + } + + /** + * The Minecraft version of the modules that depend on the library containing [file]. + */ + fun minecraftVersion(project: Project, file: VirtualFile): String? { + val fileIndex = ProjectFileIndex.getInstance(project) + val modules = fileIndex.getOrderEntriesForFile(file).asSequence().map { it.ownerModule } + + ModuleManager.getInstance(project).modules.asSequence() + return modules + .mapNotNull { module -> + if (module.isDisposed) null else MinecraftModuleSettings.getInstance(module).state.minecraftVersion + } + .firstOrNull { it.isNotBlank() } + } + } +} diff --git a/src/main/kotlin/platform/fabric/annotater/AnnotaterConfig.kt b/src/main/kotlin/platform/fabric/annotater/AnnotaterConfig.kt new file mode 100644 index 000000000..37d3c642a --- /dev/null +++ b/src/main/kotlin/platform/fabric/annotater/AnnotaterConfig.kt @@ -0,0 +1,83 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.platform.fabric.annotater + +import com.google.gson.Gson +import com.google.gson.JsonParseException +import java.io.IOException +import java.nio.file.Path +import kotlin.io.path.exists +import kotlin.io.path.readText + +/** + * The contents of an annotater checkout's `config.json`. + */ +data class AnnotaterConfig( + val version: String, + val mappings: String, + val unpickDefinitions: String?, +) { + companion object { + const val FILE_NAME = "config.json" + + private class Raw( + val version: String? = null, + val mappings: String? = null, + val unpickDefinitions: String? = null, + ) + + @Throws(IOException::class) + fun read(root: Path): AnnotaterConfig { + val file = root.resolve(FILE_NAME) + if (!file.exists()) { + throw IOException("$root does not contain a $FILE_NAME file") + } + val raw = try { + Gson().fromJson(file.readText(), Raw::class.java) + } catch (e: JsonParseException) { + throw IOException("Invalid $FILE_NAME: ${e.message}", e) + } ?: throw IOException("$FILE_NAME is empty") + + val version = raw.version?.takeIf { it.isNotBlank() } + ?: throw IOException("$FILE_NAME is missing \"version\"") + val mappings = validatePath(raw.mappings ?: throw IOException("$FILE_NAME is missing \"mappings\"")) + val unpick = raw.unpickDefinitions?.let(::validatePath) + return AnnotaterConfig(version, mappings, unpick) + } + + /** + * Paths must be relative subdirectories using forward slashes, like mcsrc requires. + */ + @Throws(IOException::class) + private fun validatePath(path: String): String { + val segments = path.split('/') + val valid = path.isNotEmpty() && + !path.startsWith('/') && + '\\' !in path && + ':' !in path && + segments.none { it.isEmpty() || it == "." || it == ".." } + if (!valid) { + throw IOException("Invalid path in $FILE_NAME: \"$path\"") + } + return path + } + } +} diff --git a/src/main/kotlin/platform/fabric/annotater/AnnotaterConfigurable.kt b/src/main/kotlin/platform/fabric/annotater/AnnotaterConfigurable.kt new file mode 100644 index 000000000..28b1948cd --- /dev/null +++ b/src/main/kotlin/platform/fabric/annotater/AnnotaterConfigurable.kt @@ -0,0 +1,228 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.platform.fabric.annotater + +import com.demonwav.mcdev.asset.GeneralAssets +import com.demonwav.mcdev.asset.MCDevBundle +import com.demonwav.mcdev.platform.minecraft.MinecraftModuleSettings +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory +import com.intellij.openapi.module.ModuleManager +import com.intellij.openapi.options.Configurable +import com.intellij.openapi.options.ShowSettingsUtil +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.ProjectManager +import com.intellij.openapi.ui.DialogPanel +import com.intellij.openapi.ui.Messages +import com.intellij.ui.components.JBCheckBox +import com.intellij.ui.components.JBRadioButton +import com.intellij.ui.components.JBTextField +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.BottomGap +import com.intellij.ui.dsl.builder.Cell +import com.intellij.ui.dsl.builder.bind +import com.intellij.ui.dsl.builder.bindSelected +import com.intellij.ui.dsl.builder.bindText +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.dsl.builder.selected +import java.awt.Font +import java.io.IOException +import javax.swing.JComponent +import javax.swing.JLabel +import javax.swing.SwingConstants +import kotlin.io.path.Path +import org.jetbrains.annotations.Nls + +class AnnotaterConfigurable : Configurable { + private lateinit var panel: DialogPanel + + @Nls + override fun getDisplayName() = MCDevBundle("fabric.annotater.settings.display_name") + + override fun createComponent(): JComponent = panel { + row( + JLabel(MCDevBundle("fabric.annotater.settings.title"), GeneralAssets.ANNOTATER_LARGE, SwingConstants.LEADING) + .apply { font = font.deriveFont(Font.BOLD, font.size * 1.5f) }, + ) {}.bottomGap(BottomGap.MEDIUM) + + val settings = AnnotaterSettings.instance + lateinit var enabled: Cell + row { + enabled = checkBox(MCDevBundle("fabric.annotater.settings.enabled")) + .bindSelected(settings::enabled) + .comment(MCDevBundle("fabric.annotater.settings.enabled.comment")) + } + indent { + row { + checkBox(MCDevBundle("fabric.annotater.settings.show_javadocs")) + .bindSelected(settings::showAnnotaterJavadocs) + .comment(MCDevBundle("fabric.annotater.settings.show_javadocs.comment")) + } + lateinit var managed: Cell + lateinit var custom: Cell + buttonsGroup { + row { + managed = radioButton( + MCDevBundle("fabric.annotater.settings.managed"), + AnnotaterSettings.CheckoutMode.MANAGED, + ) + } + indent { + row(MCDevBundle("fabric.annotater.settings.managed_root")) { + textFieldWithBrowseButton(FileChooserDescriptorFactory.singleDir()) + .bindText(settings::managedRoot) + .applyToComponent { + (textField as? JBTextField)?.emptyText?.text = + AnnotaterSettings.defaultManagedRoot.toString() + } + .align(AlignX.FILL) + .enabledIf(managed.selected) + } + row { + val vcs = AnnotaterVcs.instance + button(MCDevBundle("fabric.annotater.settings.clone_now")) { + cloneOrUpdate(vcs ?: return@button) + }.enabled(vcs != null).enabledIf(managed.selected) + if (vcs == null) { + comment(MCDevBundle("fabric.annotater.settings.no_git")) + } + } + } + row { + custom = radioButton( + MCDevBundle("fabric.annotater.settings.custom"), + AnnotaterSettings.CheckoutMode.CUSTOM, + ) + } + indent { + row { + textFieldWithBrowseButton(FileChooserDescriptorFactory.singleDir()) + .bindText(settings::customDirectory) + .align(AlignX.FILL) + .enabledIf(custom.selected) + .validationOnInput { field -> + val error = validateCustomDirectory(field.text) + when { + error == null -> null + error.second -> error(error.first) + else -> warning(error.first) + } + } + .comment(MCDevBundle("fabric.annotater.settings.custom.comment")) + } + } + }.bind(settings::checkoutMode) + }.enabledIf(enabled.selected) + + onApply { + ApplicationManager.getApplication().messageBus.syncPublisher(AnnotaterListener.TOPIC).checkoutsChanged() + } + }.also { panel = it } + + /** + * Checks that [directory] is an annotater checkout for a Minecraft version used by an open project, returning + * the problem and whether it's an error rather than a warning. + */ + private fun validateCustomDirectory(directory: String): Pair? { + if (directory.isBlank()) { + return null + } + val config = try { + AnnotaterConfig.read(Path(directory)) + } catch (e: IOException) { + return MCDevBundle("fabric.annotater.status.invalid", e.message ?: e.toString()) to true + } + val versions = knownMinecraftVersions() + if (versions.isNotEmpty() && config.version !in versions) { + val message = MCDevBundle( + "fabric.annotater.settings.custom.version_mismatch", + config.version, + versions.joinToString(", "), + ) + return message to false + } + return null + } + + private fun cloneOrUpdate(vcs: AnnotaterVcs) { + val versions = knownMinecraftVersions() + val version = when (versions.size) { + 0 -> { + Messages.showWarningDialog( + panel, + MCDevBundle("fabric.annotater.settings.clone_now.no_versions"), + MCDevBundle("fabric.annotater.settings.clone_now"), + ) + return + } + 1 -> versions.single() + else -> Messages.showEditableChooseDialog( + MCDevBundle("fabric.annotater.settings.clone_now.version"), + MCDevBundle("fabric.annotater.settings.clone_now"), + null, + versions.toTypedArray(), + versions.first(), + null, + )?.takeIf { it.isNotBlank() } ?: return + } + + // Clone into the directory currently in the form, even if it hasn't been applied yet + panel.apply() + vcs.cloneOrUpdate(null, version, modal = true) { result -> + result.onSuccess { checkout -> + Messages.showInfoMessage( + panel, + MCDevBundle("fabric.annotater.cloned.content", checkout.root), + MCDevBundle("fabric.annotater.cloned", version), + ) + }.onFailure { error -> + Messages.showErrorDialog( + panel, + error.message ?: error.toString(), + MCDevBundle("fabric.annotater.clone_failed"), + ) + } + } + } + + override fun isModified(): Boolean = panel.isModified() + + override fun apply() = panel.apply() + + override fun reset() = panel.reset() + + companion object { + /** + * The Minecraft versions of all modules in open projects, as configured in their Minecraft module settings. + */ + fun knownMinecraftVersions(): List { + return ProjectManager.getInstance().openProjects.flatMap { project -> + ModuleManager.getInstance(project).modules.mapNotNull { + MinecraftModuleSettings.getInstance(it).state.minecraftVersion + } + }.filter { it.isNotBlank() }.distinct() + } + + fun show(project: Project?) { + ShowSettingsUtil.getInstance().showSettingsDialog(project, AnnotaterConfigurable::class.java) + } + } +} diff --git a/src/main/kotlin/platform/fabric/annotater/AnnotaterListener.kt b/src/main/kotlin/platform/fabric/annotater/AnnotaterListener.kt new file mode 100644 index 000000000..d6a6834a5 --- /dev/null +++ b/src/main/kotlin/platform/fabric/annotater/AnnotaterListener.kt @@ -0,0 +1,41 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.platform.fabric.annotater + +import com.intellij.util.messages.Topic +import java.nio.file.Path + +interface AnnotaterListener { + /** + * Called when annotater settings change, or a checkout is created, updated or switches branch. + */ + fun checkoutsChanged() {} + + /** + * Called when the Javadocs of [outerClass] (an internal name) in the checkout at [checkoutRoot] change. + */ + fun javadocsChanged(checkoutRoot: Path, outerClass: String) {} + + companion object { + @Topic.AppLevel + val TOPIC = Topic(AnnotaterListener::class.java, Topic.BroadcastDirection.NONE) + } +} diff --git a/src/main/kotlin/platform/fabric/annotater/AnnotaterNotifications.kt b/src/main/kotlin/platform/fabric/annotater/AnnotaterNotifications.kt new file mode 100644 index 000000000..ab4c2a8a4 --- /dev/null +++ b/src/main/kotlin/platform/fabric/annotater/AnnotaterNotifications.kt @@ -0,0 +1,80 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.platform.fabric.annotater + +import com.demonwav.mcdev.asset.MCDevBundle +import com.intellij.notification.Notification +import com.intellij.notification.NotificationAction +import com.intellij.notification.NotificationType +import com.intellij.openapi.project.Project + +object AnnotaterNotifications { + const val GROUP_ID = "Minecraft annotater" + + fun describe(status: AnnotaterCheckoutService.Status): String = when (status) { + is AnnotaterCheckoutService.Status.Available -> status.checkout.root.toString() + AnnotaterCheckoutService.Status.Disabled -> MCDevBundle("fabric.annotater.status.disabled") + AnnotaterCheckoutService.Status.UnknownMinecraftVersion -> + MCDevBundle("fabric.annotater.status.unknown_version") + + is AnnotaterCheckoutService.Status.NotCloned -> if (AnnotaterVcs.instance != null) { + MCDevBundle("fabric.annotater.status.not_cloned", status.version) + } else { + MCDevBundle("fabric.annotater.status.not_cloned_no_git", status.version) + } + + is AnnotaterCheckoutService.Status.VersionMismatch -> + MCDevBundle("fabric.annotater.status.version_mismatch", status.actual, status.expected) + + is AnnotaterCheckoutService.Status.Invalid -> MCDevBundle("fabric.annotater.status.invalid", status.message) + } + + fun notifyUnavailable(project: Project, status: AnnotaterCheckoutService.Status) { + val notification = Notification( + GROUP_ID, + MCDevBundle("fabric.annotater.unavailable"), + describe(status), + NotificationType.WARNING, + ) + val vcs = AnnotaterVcs.instance + if (status is AnnotaterCheckoutService.Status.NotCloned && vcs != null) { + notification.addAction( + NotificationAction.createSimpleExpiring(MCDevBundle("fabric.annotater.clone", status.version)) { + vcs.cloneOrUpdate(project, status.version) + }, + ) + } + notification.addAction( + NotificationAction.createSimpleExpiring(MCDevBundle("fabric.annotater.configure")) { + AnnotaterConfigurable.show(project) + }, + ) + notification.notify(project) + } + + fun notifyError(project: Project?, title: String, error: Throwable) { + Notification(GROUP_ID, title, error.message ?: error.toString(), NotificationType.ERROR).notify(project) + } + + fun notifyInfo(project: Project?, title: String, content: String) { + Notification(GROUP_ID, title, content, NotificationType.INFORMATION).notify(project) + } +} diff --git a/src/main/kotlin/platform/fabric/annotater/AnnotaterSettings.kt b/src/main/kotlin/platform/fabric/annotater/AnnotaterSettings.kt new file mode 100644 index 000000000..220dd71d7 --- /dev/null +++ b/src/main/kotlin/platform/fabric/annotater/AnnotaterSettings.kt @@ -0,0 +1,102 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.platform.fabric.annotater + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.PathManager +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import java.nio.file.Path +import kotlin.io.path.Path + +@State(name = "MinecraftAnnotaterSettings", storages = [Storage("minecraft_dev.xml")]) +class AnnotaterSettings : PersistentStateComponent { + + data class State( + var enabled: Boolean = false, + var showAnnotaterJavadocs: Boolean = true, + var checkoutMode: CheckoutMode = CheckoutMode.MANAGED, + var managedRoot: String = "", + var customDirectory: String = "", + ) + + enum class CheckoutMode { + /** + * The plugin clones annotater itself and keeps one git worktree per Minecraft version. + */ + MANAGED, + + /** + * The user points the plugin at an existing annotater checkout, which the plugin never runs git in. + */ + CUSTOM, + } + + private var state = State() + + override fun getState() = state + + override fun loadState(state: State) { + this.state = state + } + + var enabled: Boolean + get() = state.enabled + set(enabled) { + state.enabled = enabled + } + + var showAnnotaterJavadocs: Boolean + get() = state.showAnnotaterJavadocs + set(showAnnotaterJavadocs) { + state.showAnnotaterJavadocs = showAnnotaterJavadocs + } + + var checkoutMode: CheckoutMode + get() = state.checkoutMode + set(checkoutMode) { + state.checkoutMode = checkoutMode + } + + var managedRoot: String + get() = state.managedRoot + set(managedRoot) { + state.managedRoot = managedRoot + } + + var customDirectory: String + get() = state.customDirectory + set(customDirectory) { + state.customDirectory = customDirectory + } + + val effectiveManagedRoot: Path + get() = state.managedRoot.takeIf { it.isNotBlank() }?.let(::Path) ?: defaultManagedRoot + + companion object { + val defaultManagedRoot: Path + get() = PathManager.getSystemDir().resolve("minecraft-dev").resolve("annotater") + + val instance: AnnotaterSettings + get() = ApplicationManager.getApplication().getService(AnnotaterSettings::class.java) + } +} diff --git a/src/main/kotlin/platform/fabric/annotater/AnnotaterVcs.kt b/src/main/kotlin/platform/fabric/annotater/AnnotaterVcs.kt new file mode 100644 index 000000000..3ccbd2d91 --- /dev/null +++ b/src/main/kotlin/platform/fabric/annotater/AnnotaterVcs.kt @@ -0,0 +1,119 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.platform.fabric.annotater + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.project.Project + +/** + * Git operations on managed annotater checkouts. Only available when the Git plugin is installed. + */ +interface AnnotaterVcs { + /** + * Clones annotater if needed, fetches it, and creates or updates the worktree for [version], in the background + * with a notification when done, or with a modal progress dialog if [modal]. + */ + fun cloneOrUpdate( + project: Project?, + version: String, + modal: Boolean = false, + onFinished: (Result) -> Unit = {}, + ) + + /** + * Makes sure the managed [checkout] is on a contribution branch rather than the upstream version branch, so that + * edits can later be pushed and turned into a pull request. Must not be called on the EDT. + */ + fun prepareForEditing(project: Project?, checkout: AnnotaterCheckout) + + /** + * The contribution [checkout] has checked out, or null if HEAD is detached or this isn't a git repository. This is + * cheap enough to call from any thread. + */ + fun currentContribution(checkout: AnnotaterCheckout): AnnotaterContribution? + + /** + * The contribution branches in the managed [checkout], newest first. Must not be called on the EDT. + */ + fun contributions(project: Project?, checkout: AnnotaterCheckout): List + + /** + * The mapping files in [checkout] that have changed since the last commit. Must not be called on the EDT. + */ + fun uncommittedMappingFiles(project: Project?, checkout: AnnotaterCheckout): List + + /** + * Commits any changes in the managed [checkout] with [commitMessage], then checks out the contribution [branch], + * or the latest upstream version branch to start a new contribution if it's null. Must not be called on the EDT. + */ + fun switchContribution( + project: Project?, + checkout: AnnotaterCheckout, + branch: String?, + commitMessage: String?, + indicator: ProgressIndicator, + ) + + companion object { + val instance: AnnotaterVcs? + get() = ApplicationManager.getApplication().getService(AnnotaterVcs::class.java) + } +} + +/** + * A local branch in a managed checkout holding one contribution, which becomes one pull request. + * + * @property title the title of the contribution's pull request, or its latest commit message if it doesn't have one + * yet, or null if nothing has been committed to it + */ +data class AnnotaterContribution(val branch: String, val title: String?) { + val displayName: String + get() = title ?: branch + + companion object { + /** + * A pull request title or commit message describing changes to the mapping [files]. + */ + fun defaultTitle(files: List): String { + val classes = files.map { it.substringAfterLast('/').removeSuffix(".mapping") }.distinct() + return when { + classes.isEmpty() -> "" + classes.size <= 3 -> "Document ${classes.joinToString(", ")}" + else -> "Document ${classes.take(3).joinToString(", ")} and ${classes.size - 3} more" + } + } + } +} + +/** + * Creating pull requests to annotater. Only available when the GitHub plugin is installed. + */ +interface AnnotaterPullRequests { + fun canCreatePullRequest(checkout: AnnotaterCheckout): Boolean = checkout.managed + + fun createPullRequest(project: Project, checkout: AnnotaterCheckout) + + companion object { + val instance: AnnotaterPullRequests? + get() = ApplicationManager.getApplication().getService(AnnotaterPullRequests::class.java) + } +} diff --git a/src/main/kotlin/platform/fabric/annotater/git/AnnotaterGitVcs.kt b/src/main/kotlin/platform/fabric/annotater/git/AnnotaterGitVcs.kt new file mode 100644 index 000000000..c2e9ed054 --- /dev/null +++ b/src/main/kotlin/platform/fabric/annotater/git/AnnotaterGitVcs.kt @@ -0,0 +1,426 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.platform.fabric.annotater.git + +import com.demonwav.mcdev.asset.MCDevBundle +import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterCheckout +import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterCheckoutService +import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterContribution +import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterListener +import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterNotifications +import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterSettings +import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterVcs +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.Task +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.ProjectManager +import com.intellij.openapi.vcs.VcsException +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.ui.EditorNotifications +import git4idea.commands.Git +import git4idea.commands.GitCommand +import git4idea.commands.GitLineHandler +import java.io.IOException +import java.nio.file.Files +import java.nio.file.Path +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.concurrent.ConcurrentHashMap +import kotlin.io.path.exists +import kotlin.io.path.isDirectory +import kotlin.io.path.readText + +class AnnotaterGitVcs : AnnotaterVcs { + private val lock = Any() + + /** + * Pull request titles of contribution branches, by branch name. + */ + private val titles = ConcurrentHashMap() + + /** + * [AnnotaterContribution.title]s of contribution branches, by branch name, so that the editor banner can show them + * without running git. + */ + private val contributionTitles = ConcurrentHashMap() + + /** + * The branches whose [contributionTitles] have been looked up at least once. + */ + private val titledBranches = ConcurrentHashMap.newKeySet() + + override fun cloneOrUpdate( + project: Project?, + version: String, + modal: Boolean, + onFinished: (Result) -> Unit, + ) { + val title = MCDevBundle("fabric.annotater.cloning", version) + var checkout: AnnotaterCheckout? = null + + fun doRun(indicator: ProgressIndicator) { + checkout = cloneOrUpdateNow(project, version, indicator) + } + + fun doSuccess() { + val checkout = checkout ?: return + ApplicationManager.getApplication().messageBus.syncPublisher(AnnotaterListener.TOPIC).checkoutsChanged() + if (!modal) { + AnnotaterNotifications.notifyInfo( + project, + MCDevBundle("fabric.annotater.cloned", version), + MCDevBundle("fabric.annotater.cloned.content", checkout.root), + ) + } + onFinished(Result.success(checkout)) + } + + fun doFail(error: Throwable) { + if (!modal) { + AnnotaterNotifications.notifyError(project, MCDevBundle("fabric.annotater.clone_failed"), error) + } + onFinished(Result.failure(error)) + } + + val task = if (modal) { + object : Task.Modal(project, title, true) { + override fun run(indicator: ProgressIndicator) = doRun(indicator) + override fun onSuccess() = doSuccess() + override fun onThrowable(error: Throwable) = doFail(error) + } + } else { + object : Task.Backgroundable(project, title, true) { + override fun run(indicator: ProgressIndicator) = doRun(indicator) + override fun onSuccess() = doSuccess() + override fun onThrowable(error: Throwable) = doFail(error) + } + } + task.queue() + } + + @Throws(VcsException::class) + fun cloneOrUpdateNow(project: Project?, version: String, indicator: ProgressIndicator): AnnotaterCheckout { + val gitProject = project ?: ProjectManager.getInstance().defaultProject + synchronized(lock) { + val root = AnnotaterSettings.instance.effectiveManagedRoot + val repo = root.resolve(REPO_DIR) + if (!repo.resolve(".git").exists()) { + indicator.text = MCDevBundle("fabric.annotater.cloning.clone") + Files.createDirectories(root) + val result = Git.getInstance().clone(gitProject, root, upstreamUrl, REPO_DIR) + if (!result.success()) { + throw VcsException(result.errorOutputAsJoinedString) + } + } else { + indicator.text = MCDevBundle("fabric.annotater.cloning.fetch") + run(gitProject, repo, GitCommand.FETCH, "--prune", UPSTREAM_REMOTE, url = upstreamUrl) + } + + val remoteBranch = "$UPSTREAM_REMOTE/$version" + val hasBranch = runCatching { + run(gitProject, repo, GitCommand.REV_PARSE, "--verify", "--quiet", "refs/remotes/$remoteBranch") + }.isSuccess + if (!hasBranch) { + throw VcsException(MCDevBundle("fabric.annotater.no_branch", version)) + } + + val worktree = AnnotaterCheckoutService.instance.managedWorktree(version) + if (!worktree.exists()) { + indicator.text = MCDevBundle("fabric.annotater.cloning.worktree") + run(gitProject, repo, GitCommand.WORKTREE, "prune") + run(gitProject, repo, GitCommand.WORKTREE, "add", "--detach", worktree.toString(), remoteBranch) + } else if (currentBranch(gitProject, worktree) == null && isClean(gitProject, worktree)) { + // Not contributing yet, so follow upstream + run(gitProject, worktree, GitCommand.CHECKOUT, "--detach", remoteBranch) + } + + LocalFileSystem.getInstance().refreshNioFiles(listOf(worktree), true, true, null) + ApplicationManager.getApplication().messageBus.syncPublisher(AnnotaterListener.TOPIC).checkoutsChanged() + return AnnotaterCheckoutService.instance.checkoutForVersion(version) + ?: throw VcsException(MCDevBundle("fabric.annotater.invalid_after_clone", worktree)) + } + } + + override fun prepareForEditing(project: Project?, checkout: AnnotaterCheckout) { + if (!checkout.managed) { + return + } + val gitProject = project ?: ProjectManager.getInstance().defaultProject + synchronized(lock) { + if (currentBranch(gitProject, checkout.root) != null) { + refreshCurrentTitle(gitProject, checkout) + return + } + val timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")) + run(gitProject, checkout.root, GitCommand.CHECKOUT, "-b", "${branchPrefix(checkout)}$timestamp") + } + ApplicationManager.getApplication().messageBus.syncPublisher(AnnotaterListener.TOPIC).checkoutsChanged() + } + + override fun currentContribution(checkout: AnnotaterCheckout): AnnotaterContribution? { + val branch = readHeadBranch(checkout.root) ?: return null + if (checkout.managed && titledBranches.add(branch)) { + // Otherwise the banner shows the branch name until the contributions are listed or a Javadoc is edited + ApplicationManager.getApplication().executeOnPooledThread { + synchronized(lock) { + runCatching { refreshCurrentTitle(ProjectManager.getInstance().defaultProject, checkout) } + } + } + } + return AnnotaterContribution(branch, contributionTitles[branch]) + } + + /** + * Reads the checked out branch straight from the worktree's `HEAD` file, to avoid running git. + */ + private fun readHeadBranch(root: Path): String? { + return try { + val dotGit = root.resolve(".git") + val gitDir = if (dotGit.isDirectory()) { + dotGit + } else { + val gitDirPath = dotGit.readText().trim().removePrefix("gitdir:").trim() + root.resolve(gitDirPath) + } + // A detached HEAD contains a commit hash instead + val head = gitDir.resolve("HEAD").readText().trim() + head.takeIf { it.startsWith(HEAD_REF_PREFIX) }?.removePrefix(HEAD_REF_PREFIX) + } catch (e: IOException) { + null + } + } + + override fun contributions(project: Project?, checkout: AnnotaterCheckout): List { + val gitProject = project ?: ProjectManager.getInstance().defaultProject + loadTitles(gitProject, checkout) + val branches = run( + gitProject, + checkout.root, + GitCommand.FOR_EACH_REF, + "--sort=-refname", + "--format=%(refname:short)", + "refs/heads/${branchPrefix(checkout)}", + ).map { it.trim() }.filter { it.isNotEmpty() } + return branches.map { AnnotaterContribution(it, contributionTitle(gitProject, checkout, it)) } + } + + /** + * The pull request title of the contribution [branch], or its latest commit message if it doesn't have one yet. + */ + private fun contributionTitle(project: Project, checkout: AnnotaterCheckout, branch: String): String? { + val title = titles[branch] ?: run( + project, + checkout.root, + GitCommand.LOG, + "-1", + "--format=%s", + "$UPSTREAM_REMOTE/${checkout.version}..$branch", + "--", + ).firstOrNull()?.takeIf { it.isNotBlank() } + if (title != null) { + contributionTitles[branch] = title + } else { + contributionTitles.remove(branch) + } + return title + } + + /** + * Updates the title of the checked out contribution shown in editor banners. + */ + private fun refreshCurrentTitle(project: Project, checkout: AnnotaterCheckout) { + val branch = currentBranch(project, checkout.root) ?: return + loadTitles(project, checkout) + val old = contributionTitles[branch] + if (runCatching { contributionTitle(project, checkout, branch) }.getOrNull() != old) { + EditorNotifications.updateAll() + } + } + + /** + * The title of the pull request already opened for the contribution [branch], if any. + */ + fun pullRequestTitle(project: Project, checkout: AnnotaterCheckout, branch: String): String? { + loadTitles(project, checkout) + return titles[branch] + } + + /** + * Loads the pull request titles stored as branch descriptions by [setContributionTitle]. + */ + private fun loadTitles(project: Project, checkout: AnnotaterCheckout) { + val output = runCatching { + run(project, checkout.root, GitCommand.CONFIG, "--get-regexp", "^branch\\..*\\.description$") + }.getOrDefault(emptyList()) + for (line in output) { + val key = line.substringBefore(' ') + val value = line.substringAfter(' ', "").trim() + if (value.isNotEmpty()) { + titles[key.removePrefix("branch.").removeSuffix(".description")] = value + } + } + } + + /** + * Remembers the title of the pull request for the contribution [branch], as its git branch description. + */ + fun setContributionTitle(project: Project, checkout: AnnotaterCheckout, branch: String, title: String) { + run(project, checkout.root, GitCommand.CONFIG, "branch.$branch.description", title) + titles[branch] = title + contributionTitles[branch] = title + EditorNotifications.updateAll() + } + + override fun uncommittedMappingFiles(project: Project?, checkout: AnnotaterCheckout): List { + val output = run( + project ?: ProjectManager.getInstance().defaultProject, + checkout.root, + GitCommand.STATUS, + "--porcelain", + "--untracked-files=all", + "--", + checkout.config.mappings, + ) + return output.mapNotNull { line -> line.takeIf { it.length > 3 }?.substring(3) } + } + + /** + * The mapping files changed on the current branch since it diverged from the upstream version branch, whether + * committed or not. + */ + fun mappingFilesChangedSinceUpstream(project: Project, checkout: AnnotaterCheckout): List { + val base = run(project, checkout.root, GitCommand.MERGE_BASE, "$UPSTREAM_REMOTE/${checkout.version}", "HEAD") + .first().trim() + val committed = + run(project, checkout.root, GitCommand.DIFF, "--name-only", base, "--", checkout.config.mappings) + return (committed + uncommittedMappingFiles(project, checkout)).map { it.trim() }.filter { it.isNotEmpty() } + .distinct() + } + + fun commitMappings(project: Project, checkout: AnnotaterCheckout, message: String) { + synchronized(lock) { + run(project, checkout.root, GitCommand.ADD, "--all", "--", checkout.config.mappings) + run(project, checkout.root, GitCommand.COMMIT, "-m", message) + } + } + + /** + * The number of commits on the current branch that aren't in the upstream version branch. + */ + fun commitsAheadOfUpstream(project: Project, checkout: AnnotaterCheckout): Int { + val output = run( + project, + checkout.root, + GitCommand.REV_LIST, + "--count", + "$UPSTREAM_REMOTE/${checkout.version}..HEAD", + ) + return output.firstOrNull()?.trim()?.toIntOrNull() ?: 0 + } + + fun push(project: Project, checkout: AnnotaterCheckout, url: String, branch: String) { + run(project, checkout.root, GitCommand.PUSH, url, "HEAD:refs/heads/$branch", url = url) + } + + override fun switchContribution( + project: Project?, + checkout: AnnotaterCheckout, + branch: String?, + commitMessage: String?, + indicator: ProgressIndicator, + ) { + val gitProject = project ?: ProjectManager.getInstance().defaultProject + synchronized(lock) { + if (commitMessage != null && uncommittedMappingFiles(gitProject, checkout).isNotEmpty()) { + if (currentBranch(gitProject, checkout.root) == null) { + throw VcsException(MCDevBundle("fabric.annotater.uncommitted_changes")) + } + indicator.text = MCDevBundle("fabric.annotater.pr.committing") + commitMappings(gitProject, checkout, commitMessage) + } + if (!isClean(gitProject, checkout.root)) { + throw VcsException(MCDevBundle("fabric.annotater.uncommitted_changes")) + } + if (branch == null) { + indicator.text = MCDevBundle("fabric.annotater.cloning.fetch") + val repo = AnnotaterSettings.instance.effectiveManagedRoot.resolve(REPO_DIR) + run(gitProject, repo, GitCommand.FETCH, "--prune", UPSTREAM_REMOTE, url = upstreamUrl) + run(gitProject, checkout.root, GitCommand.CHECKOUT, "--detach", "$UPSTREAM_REMOTE/${checkout.version}") + } else { + run(gitProject, checkout.root, GitCommand.CHECKOUT, branch) + } + refreshCurrentTitle(gitProject, checkout) + } + LocalFileSystem.getInstance().refreshNioFiles(listOf(checkout.root), true, true, null) + ApplicationManager.getApplication().messageBus.syncPublisher(AnnotaterListener.TOPIC).checkoutsChanged() + } + + private fun branchPrefix(checkout: AnnotaterCheckout) = "mcdev/${checkout.version}/" + + private fun currentBranch(project: Project, dir: Path): String? { + val output = runCatching { run(project, dir, GitCommand.REV_PARSE, "--abbrev-ref", "HEAD") }.getOrNull() + return output?.firstOrNull()?.trim()?.takeIf { it.isNotEmpty() && it != "HEAD" } + } + + private fun isClean(project: Project, dir: Path): Boolean { + return run(project, dir, GitCommand.STATUS, "--porcelain").all { it.isBlank() } + } + + @Throws(VcsException::class) + private fun run( + project: Project, + dir: Path, + command: GitCommand, + vararg parameters: String, + url: String? = null, + ): List { + val handler = GitLineHandler(project, dir, command) + handler.addParameters(*parameters) + if (url != null) { + handler.setUrl(url) + } + handler.setSilent(true) + val result = Git.getInstance().runCommand(handler) + if (!result.success()) { + throw VcsException( + result.errorOutputAsJoinedString.ifBlank { "git ${command.name()} failed with ${result.exitCode}" }, + ) + } + return result.output + } + + companion object { + private const val REPO_DIR = ".repo" + private const val HEAD_REF_PREFIX = "ref: refs/heads/" + const val UPSTREAM_REMOTE = "origin" + + /** + * The GitHub repository annotater is cloned from and pull requests are opened against, as `owner/name`. Can + * be overridden to test against a fork without opening real pull requests. + */ + val upstreamRepository: String = System.getProperty("mcdev.annotater.upstream", "FabricMC/annotater") + val upstreamUrl = "https://github.com/$upstreamRepository.git" + + val instance: AnnotaterGitVcs? + get() = AnnotaterVcs.instance as? AnnotaterGitVcs + } +} diff --git a/src/main/kotlin/platform/fabric/annotater/github/AnnotaterGitHubPullRequests.kt b/src/main/kotlin/platform/fabric/annotater/github/AnnotaterGitHubPullRequests.kt new file mode 100644 index 000000000..338895b52 --- /dev/null +++ b/src/main/kotlin/platform/fabric/annotater/github/AnnotaterGitHubPullRequests.kt @@ -0,0 +1,312 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.platform.fabric.annotater.github + +import com.demonwav.mcdev.asset.MCDevBundle +import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterCheckout +import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterNotifications +import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterPullRequests +import com.demonwav.mcdev.platform.fabric.annotater.git.AnnotaterGitVcs +import com.demonwav.mcdev.platform.fabric.annotater.javadoc.AnnotaterJavadocContributions +import com.google.gson.Gson +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import com.intellij.ide.BrowserUtil +import com.intellij.notification.Notification +import com.intellij.notification.NotificationAction +import com.intellij.notification.NotificationType +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.Task +import com.intellij.openapi.project.Project +import com.intellij.openapi.vcs.VcsException +import com.intellij.util.io.HttpRequests +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URLEncoder +import org.jetbrains.plugins.github.api.GithubServerPath +import org.jetbrains.plugins.github.authentication.GHAccountsUtil +import org.jetbrains.plugins.github.authentication.accounts.GithubAccount +import org.jetbrains.plugins.github.util.GHCompatibilityUtil + +/** + * Commits annotater changes, forks annotater on GitHub, pushes the contribution branch to the fork and opens a pull + * request, using accounts from the GitHub plugin. + */ +class AnnotaterGitHubPullRequests : AnnotaterPullRequests { + override fun canCreatePullRequest(checkout: AnnotaterCheckout) = + checkout.managed && AnnotaterGitVcs.instance != null + + override fun createPullRequest(project: Project, checkout: AnnotaterCheckout) { + val git = AnnotaterGitVcs.instance ?: return + + object : Task.Backgroundable(project, MCDevBundle("fabric.annotater.pr.checking"), true) { + private var changes = emptyList() + private var uncommittedChanges = emptyList() + private var commitsAhead = 0 + private var branch: String? = null + private var existingTitle: String? = null + + override fun run(indicator: ProgressIndicator) { + changes = git.mappingFilesChangedSinceUpstream(project, checkout) + uncommittedChanges = git.uncommittedMappingFiles(project, checkout) + commitsAhead = git.commitsAheadOfUpstream(project, checkout) + branch = git.currentContribution(checkout)?.branch + existingTitle = branch?.let { git.pullRequestTitle(project, checkout, it) } + } + + override fun onSuccess() { + val branch = branch + if (changes.isEmpty() && uncommittedChanges.isEmpty() && commitsAhead == 0 || branch == null) { + AnnotaterNotifications.notifyInfo( + project, + MCDevBundle("fabric.annotater.pr.nothing"), + MCDevBundle("fabric.annotater.pr.nothing.content", checkout.version), + ) + return + } + showDialog(project, checkout, git, branch, changes, uncommittedChanges, existingTitle) + } + + override fun onThrowable(error: Throwable) { + AnnotaterNotifications.notifyError(project, MCDevBundle("fabric.annotater.pr.failed"), error) + } + }.queue() + } + + private fun showDialog( + project: Project, + checkout: AnnotaterCheckout, + git: AnnotaterGitVcs, + branch: String, + changes: List, + uncommittedChanges: List, + existingTitle: String?, + ) { + if (GHAccountsUtil.accounts.isEmpty()) { + GHAccountsUtil.requestNewAccount(GithubServerPath.DEFAULT_SERVER, null, project) ?: return + } + val accounts = GHAccountsUtil.accounts.filter { it.server.isGithubDotCom } + if (accounts.isEmpty()) { + AnnotaterNotifications.notifyInfo( + project, + MCDevBundle("fabric.annotater.pr.failed"), + MCDevBundle("fabric.annotater.pr.no_account"), + ) + return + } + val defaultAccount = GHAccountsUtil.getSingleOrDefaultAccount(project)?.takeIf { it in accounts } + + val dialog = + AnnotaterPullRequestDialog(project, accounts, defaultAccount, changes, uncommittedChanges, existingTitle) + if (!dialog.showAndGet()) { + return + } + val account = dialog.account ?: return + val token = GHCompatibilityUtil.getOrRequestToken(account, project) ?: return + + val request = PullRequest(dialog.prTitle, dialog.prDescription, dialog.commitMessage, branch) + object : Task.Backgroundable(project, MCDevBundle("fabric.annotater.pr.creating"), false) { + private var url: String? = null + + override fun run(indicator: ProgressIndicator) { + url = + submit(project, checkout, git, account, token, request, uncommittedChanges.isNotEmpty(), indicator) + // Label the contribution with its pull request, for switching back to it later + runCatching { git.setContributionTitle(project, checkout, request.branch, request.title) } + } + + override fun onSuccess() { + val url = url ?: return + Notification( + AnnotaterNotifications.GROUP_ID, + MCDevBundle("fabric.annotater.pr.created"), + url, + NotificationType.INFORMATION, + ).addAction( + NotificationAction.createSimple(MCDevBundle("fabric.annotater.pr.open")) { + BrowserUtil.browse(url) + }, + ).addAction( + NotificationAction.createSimpleExpiring(MCDevBundle("fabric.annotater.pr.start_new")) { + AnnotaterJavadocContributions.switchTo(project, checkout, null) + }, + ).notify(project) + BrowserUtil.browse(url) + } + + override fun onThrowable(error: Throwable) { + AnnotaterNotifications.notifyError(project, MCDevBundle("fabric.annotater.pr.failed"), error) + } + }.queue() + } + + private class PullRequest(val title: String, val body: String, val commitMessage: String, val branch: String) + + @Throws(IOException::class, VcsException::class) + private fun submit( + project: Project, + checkout: AnnotaterCheckout, + git: AnnotaterGitVcs, + account: GithubAccount, + token: String, + request: PullRequest, + hasChanges: Boolean, + indicator: ProgressIndicator, + ): String { + val api = GitHubApi(account.server.toApiUrl(), token) + val upstream = AnnotaterGitVcs.upstreamRepository + + // Otherwise GitHub only rejects the pull request with "base invalid" after committing, forking and pushing + if (!api.exists("/repos/$upstream/branches/${checkout.version}")) { + throw IOException(MCDevBundle("fabric.annotater.pr.no_base_branch", upstream, checkout.version)) + } + + if (hasChanges) { + indicator.text = MCDevBundle("fabric.annotater.pr.committing") + git.commitMappings(project, checkout, request.commitMessage) + } + + indicator.text = MCDevBundle("fabric.annotater.pr.forking") + val fork = api.post("/repos/$upstream/forks", JsonObject()) + val forkName = fork.get("full_name").asString + val forkOwner = fork.getAsJsonObject("owner").get("login").asString + val cloneUrl = fork.get("clone_url").asString + waitForFork(api, forkName, indicator) + + indicator.text = MCDevBundle("fabric.annotater.pr.pushing") + git.push(project, checkout, cloneUrl, request.branch) + + indicator.text = MCDevBundle("fabric.annotater.pr.opening") + val body = JsonObject().apply { + addProperty("title", request.title) + addProperty("body", request.body) + addProperty("head", "$forkOwner:${request.branch}") + addProperty("base", checkout.version) + addProperty("maintainer_can_modify", true) + } + return try { + api.post("/repos/$upstream/pulls", body).get("html_url").asString + } catch (e: GitHubApiException) { + // The branch was pushed to an existing pull request, which is now up to date + val head = "$forkOwner:${request.branch}" + val existing = if (e.statusCode == HTTP_UNPROCESSABLE_ENTITY) { + api.get("/repos/$upstream/pulls?state=open&head=${URLEncoder.encode(head, Charsets.UTF_8)}") + .asJsonArray.firstOrNull()?.asJsonObject?.get("html_url")?.asString + } else { + null + } + existing ?: throw e + } + } + + /** + * Forks are created asynchronously, so wait until GitHub has finished creating it before pushing. + */ + private fun waitForFork(api: GitHubApi, forkName: String, indicator: ProgressIndicator) { + repeat(FORK_POLL_ATTEMPTS) { + indicator.checkCanceled() + if (api.exists("/repos/$forkName")) { + return + } + Thread.sleep(FORK_POLL_DELAY_MS) + } + } + + private class GitHubApiException(val statusCode: Int, message: String) : IOException(message) + + private class GitHubApi(private val baseUrl: String, private val token: String) { + private val gson = Gson() + + fun post(path: String, body: JsonObject): JsonObject { + return HttpRequests.post(baseUrl + path, "application/json") + .tuner(::authorize) + .throwStatusCodeException(false) + .connect { request -> + request.write(gson.toJson(body)) + gson.fromJson(readResponse(request), JsonObject::class.java) + } + } + + fun get(path: String): JsonElement { + return HttpRequests.request(baseUrl + path) + .tuner(::authorize) + .throwStatusCodeException(false) + .connect { request -> JsonParser.parseString(readResponse(request)) } + } + + fun exists(path: String): Boolean { + return try { + get(path) + true + } catch (e: GitHubApiException) { + if (e.statusCode == HttpURLConnection.HTTP_NOT_FOUND) false else throw e + } + } + + /** + * Reads the response body, or throws an exception with GitHub's explanation of the error, which + * [HttpURLConnection] otherwise hides. + */ + private fun readResponse(request: HttpRequests.Request): String { + val connection = request.connection as HttpURLConnection + val statusCode = connection.responseCode + if (statusCode < HttpURLConnection.HTTP_BAD_REQUEST) { + return request.readString() + } + val error = connection.errorStream?.use { it.readBytes().toString(Charsets.UTF_8) }.orEmpty() + throw GitHubApiException(statusCode, describeError(statusCode, error)) + } + + private fun describeError(statusCode: Int, body: String): String { + val json = runCatching { JsonParser.parseString(body).asJsonObject }.getOrNull() + ?: return "GitHub returned HTTP $statusCode: $body" + val message = json.get("message")?.takeIf { it.isJsonPrimitive }?.asString ?: "HTTP $statusCode" + val details = json.get("errors")?.takeIf { it.isJsonArray }?.asJsonArray?.mapNotNull { error -> + when { + error.isJsonPrimitive -> error.asString + error.isJsonObject -> { + val obj = error.asJsonObject + obj.get("message")?.asString + ?: listOfNotNull(obj.get("field")?.asString, obj.get("code")?.asString) + .joinToString(" ") + .takeIf { it.isNotEmpty() } + } + + else -> null + } + }.orEmpty() + return if (details.isEmpty()) message else "$message: ${details.joinToString("; ")}" + } + + private fun authorize(connection: java.net.URLConnection) { + connection.setRequestProperty("Authorization", "Bearer $token") + connection.setRequestProperty("Accept", "application/vnd.github+json") + connection.setRequestProperty("X-GitHub-Api-Version", "2022-11-28") + } + } + + companion object { + private const val FORK_POLL_ATTEMPTS = 30 + private const val FORK_POLL_DELAY_MS = 2000L + private const val HTTP_UNPROCESSABLE_ENTITY = 422 + } +} diff --git a/src/main/kotlin/platform/fabric/annotater/github/AnnotaterPullRequestDialog.kt b/src/main/kotlin/platform/fabric/annotater/github/AnnotaterPullRequestDialog.kt new file mode 100644 index 000000000..e5191d620 --- /dev/null +++ b/src/main/kotlin/platform/fabric/annotater/github/AnnotaterPullRequestDialog.kt @@ -0,0 +1,88 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.platform.fabric.annotater.github + +import com.demonwav.mcdev.asset.MCDevBundle +import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterContribution +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.ui.CollectionComboBoxModel +import com.intellij.ui.SimpleListCellRenderer +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.bindText +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.dsl.builder.rows +import java.awt.Dimension +import org.jetbrains.plugins.github.authentication.accounts.GithubAccount + +class AnnotaterPullRequestDialog( + project: Project, + accounts: List, + defaultAccount: GithubAccount?, + private val changes: List, + private val uncommittedChanges: List, + existingTitle: String?, +) : DialogWrapper(project) { + private val accountModel = CollectionComboBoxModel(accounts, defaultAccount ?: accounts.first()) + + var prTitle = existingTitle ?: AnnotaterContribution.defaultTitle(changes) + var prDescription = "" + var commitMessage = AnnotaterContribution.defaultTitle(uncommittedChanges) + + val account: GithubAccount? + get() = accountModel.selected + + init { + title = MCDevBundle("fabric.annotater.create_pull_request") + setOKButtonText(MCDevBundle("fabric.annotater.pr.dialog.ok")) + init() + } + + override fun createCenterPanel() = panel { + row(MCDevBundle("fabric.annotater.pr.dialog.account")) { + comboBox(accountModel, SimpleListCellRenderer.create("") { it.name }) + .align(AlignX.FILL) + } + row(MCDevBundle("fabric.annotater.pr.dialog.title")) { + textField().bindText(::prTitle).align(AlignX.FILL).focused() + } + row(MCDevBundle("fabric.annotater.pr.dialog.description")) { + textArea().bindText(::prDescription).rows(6).align(AlignX.FILL) + } + if (uncommittedChanges.isNotEmpty()) { + row(MCDevBundle("fabric.annotater.pr.dialog.commit_message")) { + textField().bindText(::commitMessage).align(AlignX.FILL) + } + } + if (changes.isNotEmpty()) { + row { + comment( + MCDevBundle( + "fabric.annotater.pr.dialog.changes", + changes.joinToString("
") { it.substringAfterLast('/') }, + ), + ) + } + } + }.apply { + preferredSize = Dimension(560, preferredSize.height) + } +} diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocCodeStyleSettingsModifier.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocCodeStyleSettingsModifier.kt new file mode 100644 index 000000000..576f0bc3b --- /dev/null +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocCodeStyleSettingsModifier.kt @@ -0,0 +1,46 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.platform.fabric.annotater.javadoc + +import com.intellij.psi.PsiFile +import com.intellij.psi.codeStyle.JavaCodeStyleSettings +import com.intellij.psi.codeStyle.modifier.CodeStyleSettingsModifier +import com.intellij.psi.codeStyle.modifier.CodeStyleStatusBarUIContributor +import com.intellij.psi.codeStyle.modifier.TransientCodeStyleSettings + +/** + * Annotater requires fully qualified class names in Javadoc references, so make completion insert them. + */ +class AnnotaterJavadocCodeStyleSettingsModifier : CodeStyleSettingsModifier { + override fun modifySettings(settings: TransientCodeStyleSettings, file: PsiFile): Boolean { + if (AnnotaterJavadocVirtualFile.of(file) == null) { + return false + } + settings.getCustomSettings(JavaCodeStyleSettings::class.java).CLASS_NAMES_IN_JAVADOC = + JavaCodeStyleSettings.FULLY_QUALIFY_NAMES_ALWAYS + return true + } + + override fun getName() = "Annotater" + + override fun getStatusBarUiContributor(settings: TransientCodeStyleSettings): CodeStyleStatusBarUIContributor? = + null +} diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocCommentInspection.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocCommentInspection.kt new file mode 100644 index 000000000..ab88e4780 --- /dev/null +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocCommentInspection.kt @@ -0,0 +1,88 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.platform.fabric.annotater.javadoc + +import com.demonwav.mcdev.asset.MCDevBundle +import com.intellij.codeInspection.LocalInspectionTool +import com.intellij.codeInspection.LocalQuickFix +import com.intellij.codeInspection.ProblemHighlightType +import com.intellij.codeInspection.ProblemsHolder +import com.intellij.psi.JavaElementVisitor +import com.intellij.psi.PsiComment +import com.intellij.psi.PsiElementVisitor +import com.intellij.psi.javadoc.PsiDocComment +import com.intellij.psi.util.PsiTreeUtil +import com.siyeh.ig.migration.MarkdownDocumentationCommentsMigrationInspection + +/** + * Annotater only supports Markdown (`///`) Javadoc comments. + */ +class AnnotaterJavadocCommentInspection : LocalInspectionTool() { + override fun getStaticDescription() = MCDevBundle("fabric.annotater.inspection.comment.description") + + override fun buildVisitor(holder: ProblemsHolder, isOnTheFly: Boolean): PsiElementVisitor { + if (AnnotaterJavadocVirtualFile.of(holder.file) == null) { + return PsiElementVisitor.EMPTY_VISITOR + } + val document = holder.file.viewProvider.document + + // Comments in the read-only parts of the copied source file were already there + fun isOriginal(comment: PsiComment) = AnnotaterJavadocInlineEditor.isGuarded(document, comment.textRange) + + return object : JavaElementVisitor() { + override fun visitDocComment(comment: PsiDocComment) { + if (comment.isMarkdownComment || isOriginal(comment)) { + return + } + holder.registerProblem( + comment, + MCDevBundle("fabric.annotater.javadoc.error.not_markdown"), + ProblemHighlightType.GENERIC_ERROR, + *findMarkdownMigrationFixes(holder, comment), + ) + } + + override fun visitComment(comment: PsiComment) { + // Doc comments are visited by visitDocComment instead + if (PsiTreeUtil.getParentOfType(comment, PsiDocComment::class.java) != null || isOriginal(comment)) { + return + } + holder.registerProblem( + comment, + MCDevBundle("fabric.annotater.javadoc.error.plain_comment"), + ProblemHighlightType.GENERIC_ERROR, + ) + } + } + } + + /** + * Reuses the IDE's own "convert to Markdown documentation comment" quick fix. + */ + private fun findMarkdownMigrationFixes(holder: ProblemsHolder, comment: PsiDocComment): Array { + val migrationHolder = ProblemsHolder(holder.manager, holder.file, holder.isOnTheFly) + comment.accept(MarkdownDocumentationCommentsMigrationInspection().buildVisitor(migrationHolder, true)) + return migrationHolder.results + .flatMap { it.fixes.orEmpty().asIterable() } + .filterIsInstance() + .toTypedArray() + } +} diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocContributions.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocContributions.kt new file mode 100644 index 000000000..68e181f34 --- /dev/null +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocContributions.kt @@ -0,0 +1,222 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.platform.fabric.annotater.javadoc + +import com.demonwav.mcdev.asset.MCDevBundle +import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterCheckout +import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterConfigurable +import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterContribution +import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterNotifications +import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterVcs +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.actionSystem.Separator +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.Task +import com.intellij.openapi.project.DumbAwareAction +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.Messages +import com.intellij.openapi.ui.popup.JBPopupFactory +import java.util.concurrent.CompletableFuture +import javax.swing.JComponent + +/** + * Switching a managed annotater checkout between contributions, so that a pull request can be updated after review. + */ +object AnnotaterJavadocContributions { + /** + * The label for the contribution [checkout] is on, for the editor banner. + */ + fun currentLabel(vcs: AnnotaterVcs, checkout: AnnotaterCheckout): String = + vcs.currentContribution(checkout)?.displayName ?: MCDevBundle("fabric.annotater.contribution.new") + + /** + * Shows a popup under [component] for switching between contributions if [checkout] is managed, and configuring + * annotater. + */ + fun showPopup(project: Project, checkout: AnnotaterCheckout, component: JComponent) { + val vcs = AnnotaterVcs.instance?.takeIf { checkout.managed } + if (vcs == null) { + showPopup(project, checkout, component, null, null) + return + } + ApplicationManager.getApplication().executeOnPooledThread { + val contributions = try { + vcs.contributions(project, checkout) + } catch (e: Exception) { + AnnotaterNotifications.notifyError(project, MCDevBundle("fabric.annotater.contribution.failed"), e) + return@executeOnPooledThread + } + ApplicationManager.getApplication().invokeLater { + showPopup(project, checkout, component, vcs, contributions) + } + } + } + + private fun showPopup( + project: Project, + checkout: AnnotaterCheckout, + component: JComponent, + vcs: AnnotaterVcs?, + contributions: List?, + ) { + if (!component.isShowing) { + return + } + val group = DefaultActionGroup() + if (vcs != null && contributions != null) { + val current = vcs.currentContribution(checkout)?.branch + for (contribution in contributions) { + group.add(SwitchAction(project, checkout, contribution, contribution.branch == current)) + } + group.add( + DumbAwareAction.create(MCDevBundle("fabric.annotater.contribution.new"), AllIcons.General.Add) { + switchTo(project, checkout, null) + }, + ) + group.add(Separator.getInstance()) + } + group.add( + DumbAwareAction.create(MCDevBundle("fabric.annotater.configure"), AllIcons.General.Settings) { + AnnotaterConfigurable.show(project) + }, + ) + JBPopupFactory.getInstance() + .createActionGroupPopup( + null, + group, + DataContext.EMPTY_CONTEXT, + JBPopupFactory.ActionSelectionAid.SPEEDSEARCH, + true, + ) + .showUnderneathOf(component) + } + + private class SwitchAction( + private val project: Project, + private val checkout: AnnotaterCheckout, + private val contribution: AnnotaterContribution, + private val current: Boolean, + ) : DumbAwareAction( + contribution.displayName, + contribution.branch, + if (current) AllIcons.Actions.Checked else AllIcons.Vcs.Branch, + ) { + override fun actionPerformed(e: AnActionEvent) { + if (!current) { + switchTo(project, checkout, contribution.branch) + } + } + } + + /** + * Switches [checkout] to the contribution [branch], or to a new contribution if it's null. The Javadoc being + * edited is saved first, any uncommitted changes are committed to the current contribution, and editing is then + * restarted with the Javadoc from the new branch. + */ + fun switchTo(project: Project, checkout: AnnotaterCheckout, branch: String?) { + val vcs = AnnotaterVcs.instance ?: return + val saves = listOfNotNull(javadocEditor(project, checkout)?.save()) + CompletableFuture.allOf(*saves.toTypedArray()).thenAccept { + ApplicationManager.getApplication().invokeLater { + if (!saves.all { it.join() }) { + Messages.showErrorDialog( + project, + MCDevBundle("fabric.annotater.contribution.save_failed"), + MCDevBundle("fabric.annotater.contribution.failed"), + ) + return@invokeLater + } + checkUncommittedChanges(project, checkout, vcs, branch) + } + } + } + + private fun checkUncommittedChanges( + project: Project, + checkout: AnnotaterCheckout, + vcs: AnnotaterVcs, + branch: String?, + ) { + object : Task.Backgroundable(project, MCDevBundle("fabric.annotater.contribution.switching"), true) { + private var uncommitted = emptyList() + + override fun run(indicator: ProgressIndicator) { + uncommitted = vcs.uncommittedMappingFiles(project, checkout) + } + + override fun onSuccess() { + if (uncommitted.isEmpty()) { + switch(project, checkout, vcs, branch, null) + return + } + val current = vcs.currentContribution(checkout)?.displayName + ?: MCDevBundle("fabric.annotater.contribution.new") + val message = Messages.showInputDialog( + project, + MCDevBundle("fabric.annotater.contribution.commit", current), + MCDevBundle("fabric.annotater.contribution.switch"), + null, + AnnotaterContribution.defaultTitle(uncommitted), + null, + )?.takeIf { it.isNotBlank() } ?: return + switch(project, checkout, vcs, branch, message) + } + + override fun onThrowable(error: Throwable) { + AnnotaterNotifications.notifyError(project, MCDevBundle("fabric.annotater.contribution.failed"), error) + } + }.queue() + } + + private fun switch( + project: Project, + checkout: AnnotaterCheckout, + vcs: AnnotaterVcs, + branch: String?, + commitMessage: String?, + ) { + // The Javadoc being edited is from the old branch, so stop editing it and restart after switching + val javadocEditor = javadocEditor(project, checkout) + val reopen = javadocEditor?.source + javadocEditor?.finish(force = true) + + object : Task.Backgroundable(project, MCDevBundle("fabric.annotater.contribution.switching"), false) { + override fun run(indicator: ProgressIndicator) { + vcs.switchContribution(project, checkout, branch, commitMessage, indicator) + } + + override fun onThrowable(error: Throwable) { + AnnotaterNotifications.notifyError(project, MCDevBundle("fabric.annotater.contribution.failed"), error) + } + + override fun onFinished() { + reopen?.element?.let { AnnotaterJavadocEditing.open(project, it) } + } + }.queue() + } + + private fun javadocEditor(project: Project, checkout: AnnotaterCheckout): AnnotaterJavadocInlineEditor? = + AnnotaterJavadocInlineEditor.current(project)?.takeIf { it.checkout.root == checkout.root } +} diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocDocumentationProvider.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocDocumentationProvider.kt new file mode 100644 index 000000000..3ba0b052f --- /dev/null +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocDocumentationProvider.kt @@ -0,0 +1,41 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.platform.fabric.annotater.javadoc + +import com.intellij.lang.documentation.AbstractDocumentationProvider +import com.intellij.openapi.project.DumbService +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiMember + +/** + * Shows annotater Javadocs instead of the existing Javadoc of Minecraft classes in Quick Documentation. + */ +class AnnotaterJavadocDocumentationProvider : AbstractDocumentationProvider() { + override fun generateDoc(element: PsiElement?, originalElement: PsiElement?): String? { + if (element == null || DumbService.isDumb(element.project)) { + return null + } + val member = AnnotaterJavadocEditing.normalize(element) ?: return null + val checkout = AnnotaterJavadocRendering.checkoutForDisplay(member) ?: return null + val target = AnnotaterJavadocTarget.fromPsi(member) ?: return null + return AnnotaterJavadocRendering.generateDoc(member, AnnotaterJavadocRendering.getJavadoc(checkout, target)) + } +} diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocEditing.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocEditing.kt new file mode 100644 index 000000000..8006a1fdb --- /dev/null +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocEditing.kt @@ -0,0 +1,197 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.platform.fabric.annotater.javadoc + +import com.demonwav.mcdev.asset.MCDevBundle +import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterCheckout +import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterCheckoutService +import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterConfigurable +import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterNotifications +import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterSettings +import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterVcs +import com.intellij.codeInsight.TargetElementUtil +import com.intellij.openapi.application.ReadAction +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.Task +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.IdeFocusManager +import com.intellij.psi.PsiAnonymousClass +import com.intellij.psi.PsiClass +import com.intellij.psi.PsiDocumentManager +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiField +import com.intellij.psi.PsiFile +import com.intellij.psi.PsiJavaFile +import com.intellij.psi.PsiMember +import com.intellij.psi.PsiMethod +import com.intellij.psi.PsiTypeParameter +import com.intellij.psi.SmartPointerManager +import com.intellij.psi.impl.compiled.ClsElementImpl +import com.intellij.psi.util.PsiTreeUtil +import com.intellij.psi.util.PsiUtilCore +import java.io.IOException + +object AnnotaterJavadocEditing { + /** + * Finds the Minecraft class, field or method that the caret is on or in, which may be a reference to it. + */ + fun findTarget(editor: Editor, file: PsiFile): PsiMember? { + val referenced = TargetElementUtil.findTargetElement( + editor, + TargetElementUtil.ELEMENT_NAME_ACCEPTED or TargetElementUtil.REFERENCED_ELEMENT_ACCEPTED, + ) + val candidate = referenced?.let(::normalize) + ?: file.findElementAt(editor.caretModel.offset)?.let { element -> + PsiTreeUtil.getParentOfType(element, PsiMember::class.java, false)?.let(::normalize) + } + ?: return null + return candidate.takeIf { isDocumentable(it) } + } + + /** + * Edits the annotater Javadoc of the member found by [findTarget]. + */ + fun edit(project: Project, editor: Editor, file: PsiFile) { + open(project, findTarget(editor, file) ?: return) + } + + /** + * Converts an element of a decompiled class's text to the compiled element it was generated from, and filters out + * elements that can't have annotater Javadocs. + */ + fun normalize(element: PsiElement): PsiMember? { + val compiled = element.getUserData(ClsElementImpl.COMPILED_ELEMENT) ?: element + return when (compiled) { + is PsiTypeParameter, is PsiAnonymousClass -> null + is PsiClass -> compiled.takeIf { it.qualifiedName != null } + is PsiField, is PsiMethod -> compiled + else -> null + } + } + + /** + * Whether to offer editing annotater Javadocs in [project]. In Fabric projects it's offered even before annotater + * is set up, to make it discoverable. + */ + fun isOffered(project: Project): Boolean { + return AnnotaterSettings.instance.enabled || AnnotaterCheckoutService.isFabricProject(project) + } + + fun isDocumentable(member: PsiMember): Boolean { + val file = PsiUtilCore.getVirtualFile(member) ?: return false + return AnnotaterCheckoutService.isMinecraftFile(member.project, file) && + AnnotaterJavadocTarget.fromPsi(member) != null + } + + /** + * Starts editing the annotater Javadoc of [member], in an editor embedded in the Minecraft class it's displayed in. + */ + fun open(project: Project, member: PsiMember) { + val checkout = when (val status = AnnotaterCheckoutService.instance.status(member)) { + is AnnotaterCheckoutService.Status.Available -> status.checkout + AnnotaterCheckoutService.Status.Disabled -> { + AnnotaterConfigurable.show(project) + return + } + + else -> { + AnnotaterNotifications.notifyUnavailable(project, status) + return + } + } + + val pointer = SmartPointerManager.createPointer(member) + val vcs = AnnotaterVcs.instance + if (!checkout.managed || vcs == null) { + openEditor(project, checkout, pointer.element ?: return) + return + } + + object : Task.Backgroundable(project, MCDevBundle("fabric.annotater.preparing"), true) { + override fun run(indicator: ProgressIndicator) { + vcs.prepareForEditing(project, checkout) + } + + override fun onSuccess() { + pointer.element?.let { openEditor(project, checkout, it) } + } + + override fun onThrowable(error: Throwable) { + AnnotaterNotifications.notifyError(project, MCDevBundle("fabric.annotater.preparing.failed"), error) + } + }.queue() + } + + private fun openEditor(project: Project, checkout: AnnotaterCheckout, member: PsiMember) { + val target = AnnotaterJavadocTarget.fromPsi(member) ?: return + AnnotaterJavadocInlineEditor.current(project)?.let { current -> + if (current.target == target && current.checkout.root == checkout.root && current.editor != null) { + current.editor?.let { IdeFocusManager.getInstance(project).requestFocus(it.contentComponent, true) } + return + } + } + + val (host, hostMember) = findHost(project, member, target) ?: return + val copy = try { + ReadAction.compute { + AnnotaterJavadocSourceCopy.create(member, placeholderFor = target) { checkout.javadocStore.get(it) } + } + } catch (e: IOException) { + AnnotaterNotifications.notifyError(project, MCDevBundle("fabric.annotater.javadoc.open_failed"), e) + return + } ?: return + + val file = AnnotaterJavadocVirtualFile(checkout, copy, target) + val inlineEditor = + AnnotaterJavadocInlineEditor.start(project, file, SmartPointerManager.createPointer(hostMember)) ?: return + inlineEditor.show(host, AnnotaterJavadocRendering.displayOffset(hostMember)) + } + + /** + * Finds the editor that displays the top level class of [member], opening it if necessary, and the member that + * documents [target] in it. + */ + private fun findHost( + project: Project, + member: PsiMember, + target: AnnotaterJavadocTarget, + ): Pair? { + fun findIn(editor: Editor?): Pair? { + if (editor !is EditorEx) { + return null + } + val file = PsiDocumentManager.getInstance(project).getPsiFile(editor.document) as? PsiJavaFile + ?: return null + val hostMember = AnnotaterJavadocRendering.displayedMembers(file) + .firstOrNull { AnnotaterJavadocTarget.fromPsi(it) == target } + ?: return null + return editor to hostMember + } + + val editorManager = FileEditorManager.getInstance(project) + findIn(editorManager.selectedTextEditor)?.let { return it } + member.navigate(true) + return findIn(editorManager.selectedTextEditor) + } +} diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocEditorNotificationProvider.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocEditorNotificationProvider.kt new file mode 100644 index 000000000..9aecfc7ad --- /dev/null +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocEditorNotificationProvider.kt @@ -0,0 +1,93 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.platform.fabric.annotater.javadoc + +import com.demonwav.mcdev.asset.MCDevBundle +import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterPullRequests +import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterCheckoutService +import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterVcs +import com.intellij.icons.AllIcons +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.fileEditor.FileEditor +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.ui.EditorNotificationPanel +import com.intellij.ui.EditorNotificationProvider +import com.intellij.ui.HyperlinkLabel +import java.util.concurrent.CompletableFuture +import java.util.function.Function +import javax.swing.JComponent + +/** + * Shows a banner in Minecraft classes that annotater Javadocs are displayed in, i.e. that have an annotater checkout. + */ +class AnnotaterJavadocEditorNotificationProvider : EditorNotificationProvider { + override fun collectNotificationData( + project: Project, + file: VirtualFile, + ): Function? { + val checkout = AnnotaterCheckoutService.instance.checkoutFor(project, file) ?: return null + val javadocEditor = AnnotaterJavadocInlineEditor.current(project)?.takeIf { it.checkout.root == checkout.root } + val error = javadocEditor?.file?.error + + return Function { fileEditor -> + val status = + if (error != null) EditorNotificationPanel.Status.Error else EditorNotificationPanel.Status.Info + val panel = EditorNotificationPanel(fileEditor, status) + panel.text = error ?: MCDevBundle("fabric.annotater.banner", checkout.version, checkout.root) + if (javadocEditor != null) { + panel.createActionLabel(MCDevBundle("fabric.annotater.javadoc.finish")) { + javadocEditor.finish() + } + } + + val pullRequests = AnnotaterPullRequests.instance + if (pullRequests != null && pullRequests.canCreatePullRequest(checkout)) { + panel.createActionLabel(MCDevBundle("fabric.annotater.create_pull_request")) { + val saved = javadocEditor?.save() ?: CompletableFuture.completedFuture(true) + saved.thenAccept { success -> + if (success) { + ApplicationManager.getApplication().invokeLater { + pullRequests.createPullRequest(project, checkout) + } + } + } + } + } + // Switching contributions and configuring annotater are in one dropdown, to keep the banner short + val vcs = AnnotaterVcs.instance + val labelText = if (checkout.managed && vcs != null) { + AnnotaterJavadocContributions.currentLabel(vcs, checkout) + } else { + MCDevBundle("fabric.annotater.menu") + } + lateinit var label: HyperlinkLabel + label = panel.createActionLabel(MCDevBundle("fabric.annotater.dropdown", labelText)) { + AnnotaterJavadocContributions.showPopup(project, checkout, label) + } + if (checkout.managed && vcs != null) { + label.setIcon(AllIcons.Vcs.Branch) + label.toolTipText = MCDevBundle("fabric.annotater.contribution.tooltip") + } + panel + } + } +} diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocFormattingRestriction.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocFormattingRestriction.kt new file mode 100644 index 000000000..40fc82e7f --- /dev/null +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocFormattingRestriction.kt @@ -0,0 +1,32 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.platform.fabric.annotater.javadoc + +import com.intellij.lang.LanguageFormattingRestriction +import com.intellij.psi.PsiElement + +/** + * Prevents reformatting annotater Javadoc editors, which would change the read-only copied source code. + */ +class AnnotaterJavadocFormattingRestriction : LanguageFormattingRestriction { + override fun isFormatterAllowed(context: PsiElement) = + AnnotaterJavadocVirtualFile.of(context.containingFile) == null +} diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlayPassFactory.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlayPassFactory.kt new file mode 100644 index 000000000..6bfb05c1b --- /dev/null +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlayPassFactory.kt @@ -0,0 +1,211 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.platform.fabric.annotater.javadoc + +import com.demonwav.mcdev.asset.GeneralAssets +import com.demonwav.mcdev.asset.MCDevBundle +import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterCheckoutService +import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterSettings +import com.intellij.codeHighlighting.TextEditorHighlightingPass +import com.intellij.codeHighlighting.TextEditorHighlightingPassFactory +import com.intellij.codeHighlighting.TextEditorHighlightingPassFactoryRegistrar +import com.intellij.codeHighlighting.TextEditorHighlightingPassRegistrar +import com.intellij.codeInsight.documentation.render.DocRenderManager +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.FoldRegion +import com.intellij.openapi.editor.Inlay +import com.intellij.openapi.editor.markup.GutterIconRenderer +import com.intellij.openapi.editor.markup.HighlighterLayer +import com.intellij.openapi.editor.markup.HighlighterTargetArea +import com.intellij.openapi.editor.markup.RangeHighlighter +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.project.DumbAwareAction +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Key +import com.intellij.openapi.util.TextRange +import com.intellij.psi.PsiCompiledElement +import com.intellij.psi.PsiDocumentManager +import com.intellij.psi.PsiFile +import com.intellij.psi.PsiJavaDocumentedElement +import com.intellij.psi.PsiJavaFile +import com.intellij.psi.PsiMember +import com.intellij.psi.SmartPointerManager +import com.intellij.psi.SmartPsiElementPointer +import javax.swing.Icon + +/** + * Displays annotater Javadocs in Minecraft classes, both decompiled and attached sources. Existing doc comments are + * folded away, and annotater Javadocs are shown as block inlays above their declarations. Declarations with an + * annotater Javadoc also get a gutter icon to edit it. + * + * The IDE's rendered doc comments (reader mode) would show the existing Javadocs again, so they're turned off in these + * editors. Javadocs can't be added to the decompiled text itself, as the IDE requires decompiled classes' doc comments + * to match their compiled stubs. + */ +class AnnotaterJavadocInlayPassFactory : TextEditorHighlightingPassFactoryRegistrar, TextEditorHighlightingPassFactory { + override fun registerHighlightingPassFactory(registrar: TextEditorHighlightingPassRegistrar, project: Project) { + registrar.registerTextEditorHighlightingPass(this, null, null, false, -1) + } + + override fun createHighlightingPass(file: PsiFile, editor: Editor): TextEditorHighlightingPass? { + if (file !is PsiJavaFile || AnnotaterJavadocVirtualFile.of(file) != null) { + return null + } + if (PsiDocumentManager.getInstance(file.project).getDocument(file) !== editor.document) { + return null + } + // Passes are still created when nothing is displayed, so that previous inlays are removed + return Pass(file, editor) + } + + private class Item(val offset: Int, val javadoc: String, val pointer: SmartPsiElementPointer) + + private class GutterIcon(val offset: Int, val pointer: SmartPsiElementPointer) + + private class Pass(private val file: PsiJavaFile, private val editor: Editor) : + TextEditorHighlightingPass(file.project, editor.document, false) { + private val documentStamp = editor.document.modificationStamp + private var active = false + private var showJavadocs = false + private var items = emptyList() + private var gutterIcons = emptyList() + private var folds = emptyList() + + override fun doCollectInformation(progress: ProgressIndicator) { + val first = file.classes.firstOrNull() ?: return + val checkout = AnnotaterCheckoutService.instance.checkoutFor(first) ?: return + active = true + showJavadocs = AnnotaterSettings.instance.showAnnotaterJavadocs + val compiled = file is PsiCompiledElement + // The Javadoc being edited is displayed by its editor instead + val edited = AnnotaterJavadocInlineEditor.current(myProject)?.takeIf { it.hostEditor === editor }?.target + + val newItems = mutableListOf() + val newGutterIcons = mutableListOf() + val newFolds = mutableListOf() + for (member in AnnotaterJavadocRendering.displayedMembers(file)) { + progress.checkCanceled() + val target = AnnotaterJavadocTarget.fromPsi(member) ?: continue + val existing = if (compiled) null else (member as PsiJavaDocumentedElement).docComment + if (existing != null && showJavadocs) { + newFolds += existing.textRange + } + val javadoc = AnnotaterJavadocRendering.getJavadoc(checkout, target) ?: continue + val pointer = SmartPointerManager.createPointer(member) + newGutterIcons += GutterIcon(member.textOffset, pointer) + if (!showJavadocs || target == edited) { + continue + } + newItems += Item(AnnotaterJavadocRendering.displayOffset(member), javadoc, pointer) + } + items = newItems + gutterIcons = newGutterIcons + folds = newFolds + } + + override fun doApplyInformationToEditor() { + val document = editor.document + if (document.modificationStamp != documentStamp) { + return + } + val length = document.textLength + + editor.getUserData(INLAYS_KEY)?.forEach { it.dispose() } + val inlays = items.filter { it.offset in 0..length }.mapNotNull { item -> + val renderer = AnnotaterJavadocInlayRenderer(editor, item.offset, item.javadoc, item.pointer) + editor.inlayModel.addBlockElement(item.offset, false, true, 0, renderer) + } + editor.putUserData(INLAYS_KEY, inlays.takeIf { it.isNotEmpty() }) + + editor.getUserData(GUTTER_ICONS_KEY)?.forEach { it.dispose() } + val highlighters = gutterIcons.filter { it.offset in 0..length }.map { icon -> + editor.markupModel.addRangeHighlighter( + icon.offset, + icon.offset, + HighlighterLayer.ADDITIONAL_SYNTAX, + null, + HighlighterTargetArea.LINES_IN_RANGE, + ).also { it.gutterIconRenderer = EditGutterIconRenderer(icon.pointer) } + } + editor.putUserData(GUTTER_ICONS_KEY, highlighters.takeIf { it.isNotEmpty() }) + + val oldFolds = editor.getUserData(FOLDS_KEY).orEmpty() + if (oldFolds.isNotEmpty() || folds.isNotEmpty()) { + val foldingModel = editor.foldingModel + val newFolds = mutableListOf() + foldingModel.runBatchFoldingOperation { + oldFolds.filter { it.isValid }.forEach(foldingModel::removeFoldRegion) + for (range in folds) { + if (range.endOffset > length) { + continue + } + val region = foldingModel.getFoldRegion(range.startOffset, range.endOffset) + ?: foldingModel.addFoldRegion(range.startOffset, range.endOffset, "…") + ?.also(newFolds::add) + region?.isExpanded = false + } + } + editor.putUserData(FOLDS_KEY, newFolds.takeIf { it.isNotEmpty() }) + } + + val hideRenderedDocs = active && showJavadocs + if (hideRenderedDocs && DocRenderManager.isDocRenderingEnabled(editor)) { + DocRenderManager.setDocRenderingEnabled(editor, false) + editor.putUserData(DOC_RENDERING_DISABLED_KEY, true) + } else if (!hideRenderedDocs && editor.getUserData(DOC_RENDERING_DISABLED_KEY) == true) { + DocRenderManager.resetEditorToDefaultState(editor) + editor.putUserData(DOC_RENDERING_DISABLED_KEY, null) + } + } + } + + private class EditGutterIconRenderer(private val pointer: SmartPsiElementPointer) : + GutterIconRenderer() { + override fun getIcon(): Icon = GeneralAssets.ANNOTATER + + override fun getTooltipText() = MCDevBundle("fabric.annotater.javadoc.edit") + + override fun isNavigateAction() = true + + override fun getClickAction(): AnAction = EditAction(pointer) + + override fun equals(other: Any?) = other is EditGutterIconRenderer && other.pointer == pointer + + override fun hashCode() = pointer.hashCode() + } + + class EditAction(private val pointer: SmartPsiElementPointer) : + DumbAwareAction(MCDevBundle("fabric.annotater.javadoc.edit")) { + override fun actionPerformed(e: AnActionEvent) { + val member = pointer.element ?: return + AnnotaterJavadocEditing.open(member.project, member) + } + } + + companion object { + private val INLAYS_KEY = Key.create>>("mcdev.annotater.javadocInlays") + private val FOLDS_KEY = Key.create>("mcdev.annotater.javadocFolds") + private val GUTTER_ICONS_KEY = Key.create>("mcdev.annotater.javadocGutterIcons") + private val DOC_RENDERING_DISABLED_KEY = Key.create("mcdev.annotater.docRenderingDisabled") + } +} diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlayRenderer.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlayRenderer.kt new file mode 100644 index 000000000..3b67bffeb --- /dev/null +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlayRenderer.kt @@ -0,0 +1,136 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.platform.fabric.annotater.javadoc + +import com.intellij.ide.highlighter.JavaFileHighlighter +import com.intellij.openapi.actionSystem.ActionGroup +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.EditorCustomElementRenderer +import com.intellij.openapi.editor.Inlay +import com.intellij.openapi.editor.colors.EditorColorsScheme +import com.intellij.openapi.editor.colors.EditorFontType +import com.intellij.openapi.editor.ex.util.LexerEditorHighlighter +import com.intellij.openapi.editor.markup.TextAttributes +import com.intellij.pom.java.LanguageLevel +import com.intellij.psi.PsiMember +import com.intellij.psi.SmartPsiElementPointer +import com.intellij.util.text.CharArrayUtil +import java.awt.Graphics +import java.awt.Rectangle + +/** + * Displays an annotater Javadoc above its declaration as the `///` comment it would be in the source, highlighted in + * the same way as a doc comment in a Java file. + */ +class AnnotaterJavadocInlayRenderer( + private val editor: Editor, + private val initialOffset: Int, + private val javadoc: String, + private val pointer: SmartPsiElementPointer, +) : EditorCustomElementRenderer { + private var inlay: Inlay<*>? = null + + // The inlay constructor calculates the size before the inlay is added to the tree, while its offset is still + // meaningless + private val offset: Int + get() = (inlay?.takeIf { it.isValid }?.offset ?: initialOffset).coerceIn(0, editor.document.textLength) + + override fun calcWidthInPixels(inlay: Inlay<*>): Int { + this.inlay = inlay + val indent = maxOf(0, startX() - editor.insets.left) + return indent + lines().maxOf { line -> line.sumOf { it.width() } } + } + + override fun calcHeightInPixels(inlay: Inlay<*>): Int { + this.inlay = inlay + return lines().size * editor.lineHeight + } + + override fun paint(inlay: Inlay<*>, g: Graphics, targetRegion: Rectangle, textAttributes: TextAttributes) { + this.inlay = inlay + val startX = startX() + var y = targetRegion.y + editor.ascent + for (line in lines()) { + var x = startX + for (token in line) { + g.font = token.font() + g.color = token.attributes?.foregroundColor ?: editor.colorsScheme.defaultForeground + g.drawString(token.text, x, y) + x += token.width() + } + y += editor.lineHeight + } + } + + override fun getContextMenuGroup(inlay: Inlay<*>): ActionGroup = + DefaultActionGroup(AnnotaterJavadocInlayPassFactory.EditAction(pointer)) + + /** + * The x coordinate of the declaration, where the comment starts. + */ + private fun startX(): Int { + val document = editor.document + val lineStart = document.getLineStartOffset(document.getLineNumber(offset)) + val contentStart = CharArrayUtil.shiftForward(document.immutableCharSequence, lineStart, " \t") + return editor.offsetToXY(contentStart, false, true).x + } + + private inner class Token(val text: String, val attributes: TextAttributes?) { + fun font() = editor.colorsScheme.getFont(EditorFontType.forJavaStyle(attributes?.fontType ?: 0)) + + fun width() = editor.contentComponent.getFontMetrics(font()).stringWidth(text) + } + + private var lines: List>? = null + private var linesScheme: EditorColorsScheme? = null + + /** + * The doc comment, split into lines of tokens highlighted as they would be in a Java file. + */ + private fun lines(): List> { + val scheme = editor.colorsScheme + lines?.takeIf { linesScheme === scheme }?.let { return it } + + val text = javadoc.lines().joinToString("\n") { if (it.isEmpty()) "///" else "/// $it" } + val highlighter = LexerEditorHighlighter(JavaFileHighlighter(LanguageLevel.HIGHEST), scheme) + highlighter.setText(text) + val lines = mutableListOf>() + var tokens = mutableListOf() + val iterator = highlighter.createIterator(0) + while (!iterator.atEnd()) { + for ((index, part) in text.substring(iterator.start, iterator.end).split('\n').withIndex()) { + if (index > 0) { + lines += tokens + tokens = mutableListOf() + } + if (part.isNotEmpty()) { + tokens += Token(part, iterator.textAttributes) + } + } + iterator.advance() + } + lines += tokens + this.lines = lines + linesScheme = scheme + return lines + } +} diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlineEditor.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlineEditor.kt new file mode 100644 index 000000000..398d4a41e --- /dev/null +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlineEditor.kt @@ -0,0 +1,529 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + + +package com.demonwav.mcdev.platform.fabric.annotater.javadoc + +import com.demonwav.mcdev.asset.MCDevBundle +import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterCheckout +import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterListener +import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterNotifications +import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer +import com.intellij.codeInsight.documentation.render.DocRenderManager +import com.intellij.codeInsight.hint.HintManager +import com.intellij.codeInsight.lookup.LookupManager +import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.actionSystem.CommonShortcuts +import com.intellij.openapi.actionSystem.DataSink +import com.intellij.openapi.actionSystem.PlatformCoreDataKeys +import com.intellij.openapi.actionSystem.UiDataProvider +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.editor.Document +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.editor.EditorKind +import com.intellij.openapi.editor.Inlay +import com.intellij.openapi.editor.RangeMarker +import com.intellij.openapi.editor.ScrollType +import com.intellij.openapi.editor.actionSystem.EditorActionManager +import com.intellij.openapi.editor.event.CaretEvent +import com.intellij.openapi.editor.event.CaretListener +import com.intellij.openapi.editor.event.DocumentEvent +import com.intellij.openapi.editor.event.DocumentListener +import com.intellij.openapi.editor.event.EditorFactoryEvent +import com.intellij.openapi.editor.event.EditorFactoryListener +import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.editor.impl.EditorEmbeddedComponentManager +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.fileEditor.impl.text.TextEditorProvider +import com.intellij.openapi.project.DumbAwareAction +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.Key +import com.intellij.openapi.util.TextRange +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.wm.IdeFocusManager +import com.intellij.psi.PsiDocumentManager +import com.intellij.psi.PsiJavaDocumentedElement +import com.intellij.psi.PsiJavaFile +import com.intellij.psi.PsiMember +import com.intellij.psi.SmartPsiElementPointer +import com.intellij.ui.ColorUtil +import com.intellij.ui.EditorNotifications +import com.intellij.util.Alarm +import com.intellij.util.concurrency.AppExecutorUtil +import com.intellij.util.ui.JBUI +import java.awt.BorderLayout +import java.awt.Dimension +import java.awt.event.ComponentAdapter +import java.awt.event.ComponentEvent +import java.io.IOException +import java.util.concurrent.CompletableFuture +import javax.swing.JPanel + +/** + * The editing of one annotater Javadoc, in an editor embedded above its declaration in the Minecraft class it's + * displayed in. Only one Javadoc is edited at a time in each project; see [current]. + * + * The embedded editor edits [file], a copy of the class's source file, so that the IDE's Java support works as it + * would in a real source file. Everything except the edited doc comment is folded away and guarded against edits. + * The copy isn't updated if the annotater Javadocs change on disk while it's being edited, and saving only writes the + * edited Javadoc. + */ +class AnnotaterJavadocInlineEditor private constructor( + val project: Project, + val file: AnnotaterJavadocVirtualFile, + private val document: Document, + /** + * The edited member in the Minecraft class it's displayed in, so that editing can be restarted when the checkout + * switches branch. + */ + val source: SmartPsiElementPointer?, +) : Disposable { + private val alarm = Alarm(Alarm.ThreadToUse.SWING_THREAD, this) + + private val guards = mutableListOf() + + @Volatile + private var dirty = false + + /** + * The editor of the Minecraft class that the Javadoc is edited in. + */ + @Volatile + var hostEditor: Editor? = null + private set + + /** + * The editor embedded in [hostEditor] that edits the Javadoc. + */ + var editor: EditorEx? = null + private set + + private var inlay: Inlay<*>? = null + + val checkout: AnnotaterCheckout + get() = file.checkout + + val target: AnnotaterJavadocTarget + get() = file.target + + init { + installGuardedBlocks() + // The default handler shows a modal dialog, which is annoying when editing a guarded block by accident + EditorActionManager.getInstance().setReadonlyFragmentModificationHandler(document) { + val editor = editor ?: return@setReadonlyFragmentModificationHandler + HintManager.getInstance().showErrorHint(editor, MCDevBundle("fabric.annotater.javadoc.read_only")) + } + document.addDocumentListener( + object : DocumentListener { + override fun documentChanged(event: DocumentEvent) { + dirty = true + alarm.cancelAllRequests() + alarm.addRequest(::save, SAVE_DELAY_MS) + } + }, + this, + ) + } + + /** + * The whole lines of the edited doc comment, excluding the line break at the end. + */ + private fun commentRange(): TextRange? { + val documentManager = PsiDocumentManager.getInstance(project) + documentManager.commitDocument(document) + val psiFile = documentManager.getPsiFile(document) as? PsiJavaFile ?: return null + val member = file.copy.memberOf(psiFile, target) ?: return null + val comment = (member as PsiJavaDocumentedElement).docComment ?: return null + val start = document.getLineStartOffset(document.getLineNumber(comment.textRange.startOffset)) + val end = document.getLineEndOffset(document.getLineNumber(comment.textRange.endOffset)) + return TextRange(start, end) + } + + /** + * Makes everything outside of the edited doc comment read-only. The line break after the comment is read-only too, + * so that the declaration can't be joined onto the comment. + */ + private fun installGuardedBlocks() { + val range = commentRange() ?: return + addGuard(0, range.startOffset) + addGuard(range.endOffset, document.textLength) + } + + private fun addGuard(start: Int, end: Int) { + if (end <= start) { + return + } + val guard = document.createGuardedBlock(start, end) + guard.isGreedyToLeft = false + guard.isGreedyToRight = false + guards += guard + } + + /** + * Shows the embedded editor above [offset] in [host], and focuses it. + */ + fun show(host: EditorEx, offset: Int) { + val range = commentRange() ?: return + val editor = EditorFactory.getInstance().createEditor(document, project, file, false, EditorKind.MAIN_EDITOR) + as EditorEx + this.editor = editor + hostEditor = host + configure(editor, host) + + // Hide everything except the doc comment. Each fold includes a line break, so it doesn't leave an empty line + editor.foldingModel.runBatchFoldingOperation { + editor.foldingModel.createFoldRegion(0, range.startOffset, "", null, true) + editor.foldingModel.createFoldRegion(range.endOffset, document.textLength, "", null, true) + } + // The end of the last line is displayed at the end of the hidden text after it, and the start of the first line + // at the start of the hidden text before it, so keep the caret within the comment + editor.caretModel.addCaretListener( + object : CaretListener { + override fun caretPositionChanged(event: CaretEvent) { + val caret = event.caret + val start = guards.firstOrNull()?.endOffset ?: return + val end = guards.lastOrNull()?.startOffset ?: return + if (caret.offset < start) { + caret.moveToOffset(start) + } else if (caret.offset > end) { + caret.moveToOffset(end) + } + } + }, + this, + ) + FinishAction().registerCustomShortcutSet(CommonShortcuts.ESCAPE, editor.contentComponent, this) + + val component = object : JPanel(BorderLayout()), UiDataProvider { + override fun uiDataSnapshot(sink: DataSink) { + // Otherwise actions such as undo use the host editor, which is the file editor this is embedded in + sink[PlatformCoreDataKeys.FILE_EDITOR] = TextEditorProvider.getInstance().getTextEditor(editor) + sink[CommonDataKeys.VIRTUAL_FILE] = file + } + + override fun getPreferredSize(): Dimension { + val insets = insets + return Dimension( + super.getPreferredSize().width, + editor.contentComponent.preferredSize.height + insets.top + insets.bottom, + ) + } + } + component.border = JBUI.Borders.customLine( + ColorUtil.mix(host.colorsScheme.defaultBackground, host.colorsScheme.defaultForeground, 0.25), + 1, + ) + component.add(editor.component, BorderLayout.CENTER) + // The embedded editor's height depends on its text and its width, which changes with the host editor's width + document.addDocumentListener( + object : DocumentListener { + override fun documentChanged(event: DocumentEvent) { + ApplicationManager.getApplication().invokeLater({ component.revalidate() }) { isDisposed } + } + }, + this, + ) + component.addComponentListener( + object : ComponentAdapter() { + override fun componentResized(e: ComponentEvent) { + ApplicationManager.getApplication().invokeLater({ component.revalidate() }) { isDisposed } + } + }, + ) + + inlay = EditorEmbeddedComponentManager.getInstance().addComponent( + host, + component, + EditorEmbeddedComponentManager.Properties( + EditorEmbeddedComponentManager.ResizePolicy.none(), + null, + false, + true, + false, + true, + 0, + offset, + ), + ) + + // Finish editing when the Minecraft class is closed + EditorFactory.getInstance().addEditorFactoryListener( + object : EditorFactoryListener { + override fun editorReleased(event: EditorFactoryEvent) { + if (event.editor === host) { + finish(force = true) + } + } + }, + this, + ) + + host.scrollingModel.scrollTo(host.offsetToLogicalPosition(offset), ScrollType.MAKE_VISIBLE) + editor.caretModel.moveToOffset(document.getLineEndOffset(document.getLineNumber(range.startOffset))) + IdeFocusManager.getInstance(project).requestFocus(editor.contentComponent, true) + restartHostHighlighting() + } + + /** + * Finishes editing when Escape is pressed, unless there's something else for Escape to close. + */ + private inner class FinishAction : DumbAwareAction() { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun update(e: AnActionEvent) { + val editor = editor + e.presentation.isEnabled = editor != null && + LookupManager.getActiveLookup(editor) == null && + !editor.selectionModel.hasSelection() && + editor.caretModel.caretCount == 1 + } + + override fun actionPerformed(e: AnActionEvent) { + HintManager.getInstance().hideAllHints() + finish() + } + } + + private fun configure(editor: EditorEx, host: EditorEx) { + editor.settings.apply { + isLineNumbersShown = false + isFoldingOutlineShown = false + isLineMarkerAreaShown = false + setGutterIconsShown(false) + isRightMarginShown = false + isIndentGuidesShown = false + isVirtualSpace = false + isAdditionalPageAtBottom = false + additionalLinesCount = 0 + additionalColumnsCount = 0 + isUseSoftWraps = true + // Java folds doc comments and imports by default, which would hide the edited comment + isAutoCodeFoldingEnabled = false + } + editor.setVerticalScrollbarVisible(false) + editor.setHorizontalScrollbarVisible(false) + // Let the host editor scroll instead + editor.scrollPane.isWheelScrollingEnabled = false + editor.colorsScheme.setEditorFontSize(host.colorsScheme.editorFontSize2D) + editor.setBackgroundColor( + ColorUtil.mix(host.colorsScheme.defaultBackground, host.colorsScheme.defaultForeground, 0.04), + ) + DocRenderManager.setDocRenderingEnabled(editor, false) + } + + private fun restartHostHighlighting() { + val host = hostEditor ?: return + val psiFile = PsiDocumentManager.getInstance(project).getPsiFile(host.document) ?: return + DaemonCodeAnalyzer.getInstance(project).restart(psiFile, this) + } + + private val isDisposed: Boolean + get() = project.getUserData(CURRENT_KEY) !== this + + /** + * Saves pending changes to the annotater checkout. The returned future completes with false if the Javadoc + * couldn't be saved, in which case [AnnotaterJavadocVirtualFile.error] says why. + */ + fun save(): CompletableFuture { + ApplicationManager.getApplication().assertIsDispatchThread() + alarm.cancelAllRequests() + if (!dirty || project.isDisposed) { + return CompletableFuture.completedFuture(file.error == null) + } + dirty = false + + return when (val result = extract()) { + is AnnotaterJavadocSourceCopy.ExtractResult.Error -> { + saved(result.message) + CompletableFuture.completedFuture(false) + } + + is AnnotaterJavadocSourceCopy.ExtractResult.Success -> { + val javadoc = result.javadocs[target] + // Writing the mapping file is slow, so do it in the background, one save at a time + CompletableFuture.supplyAsync( + { + val error = write(javadoc) + ApplicationManager.getApplication().invokeLater { saved(error) } + error == null + }, + saveExecutor, + ) + } + } + } + + private fun extract(): AnnotaterJavadocSourceCopy.ExtractResult { + val documentManager = PsiDocumentManager.getInstance(project) + documentManager.commitDocument(document) + val psiFile = documentManager.getPsiFile(document) as? PsiJavaFile + ?: return AnnotaterJavadocSourceCopy.ExtractResult.Error( + MCDevBundle("fabric.annotater.javadoc.error.structure_changed"), + ) + return file.copy.extractJavadocs(psiFile) { isGuarded(document, it) } + } + + private fun saved(error: String?) { + if (error != null) { + dirty = true + } + if (error != file.error) { + file.error = error + EditorNotifications.getInstance(project).updateAllNotifications() + } + } + + private fun write(javadoc: String?): String? { + val store = checkout.javadocStore + val outerClass = file.outerClass + return try { + store.invalidate(outerClass) + if (store.get(target) != javadoc) { + store.setAll(mapOf(target to javadoc)) + LocalFileSystem.getInstance().refreshNioFiles(listOf(store.fileFor(outerClass))) + ApplicationManager.getApplication().messageBus.syncPublisher(AnnotaterListener.TOPIC) + .javadocsChanged(checkout.root, outerClass) + } + null + } catch (e: IOException) { + thisLogger().info("Failed to save annotater Javadoc of $target", e) + MCDevBundle("fabric.annotater.javadoc.error.io", e.message ?: e.toString()) + } + } + + /** + * Saves the Javadoc and stops editing it. If it can't be saved because the user wrote something annotater doesn't + * support, editing continues so that the user can fix it, unless [force] is true. + * + * @return whether editing stopped + */ + fun finish(force: Boolean = false): Boolean { + if (isDisposed) { + return true + } + if (!force && dirty && extract() is AnnotaterJavadocSourceCopy.ExtractResult.Error) { + save() + editor?.let { editor -> + file.error?.let { HintManager.getInstance().showErrorHint(editor, it) } + } + return false + } + save().thenAccept { success -> + if (!success) { + ApplicationManager.getApplication().invokeLater { + AnnotaterNotifications.notifyInfo( + project, + MCDevBundle("fabric.annotater.javadoc.save_failed"), + file.error.orEmpty(), + ) + } + } + } + Disposer.dispose(this) + return true + } + + override fun dispose() { + val wasCurrent = project.getUserData(CURRENT_KEY) === this + if (wasCurrent) { + project.putUserData(CURRENT_KEY, null) + } + inlay?.let(Disposer::dispose) + inlay = null + editor?.let(EditorFactory.getInstance()::releaseEditor) + editor = null + for (guard in guards) { + document.removeGuardedBlock(guard) + } + guards.clear() + EditorActionManager.getInstance().setReadonlyFragmentModificationHandler(document, null) + if (wasCurrent && !project.isDisposed) { + restartHostHighlighting() + EditorNotifications.getInstance(project).updateAllNotifications() + } + hostEditor = null + } + + companion object { + private const val SAVE_DELAY_MS = 750 + + private val CURRENT_KEY = Key.create("mcdev.annotater.javadocInlineEditor") + + private val saveExecutor = + AppExecutorUtil.createBoundedApplicationPoolExecutor("Annotater Javadoc saving", 1) + + /** + * The annotater Javadoc being edited in [project], if any. + */ + fun current(project: Project): AnnotaterJavadocInlineEditor? = project.getUserData(CURRENT_KEY) + + /** + * The parent of the inline editors of a project, so they're disposed with it. + */ + @Service(Service.Level.PROJECT) + private class ProjectDisposable : Disposable { + override fun dispose() { + } + } + + /** + * Whether [range] of [document] is entirely within one guarded block, so the user didn't write it. + */ + fun isGuarded(document: Document, range: TextRange): Boolean { + val guard = document.getRangeGuard(range.startOffset, range.endOffset) ?: return false + return guard.startOffset <= range.startOffset && range.endOffset <= guard.endOffset + } + + /** + * Starts editing the Javadoc of [file]'s target, finishing the previous editing in the project + * first. Returns null if the previous editing couldn't be finished, or the target's doc comment can't be found + * in [file]. + */ + fun start( + project: Project, + file: AnnotaterJavadocVirtualFile, + source: SmartPsiElementPointer? = null, + ): AnnotaterJavadocInlineEditor? { + current(project)?.let { previous -> + if (!previous.finish()) { + return null + } + } + val document = FileDocumentManager.getInstance().getDocument(file) ?: return null + val inlineEditor = AnnotaterJavadocInlineEditor(project, file, document, source) + if (inlineEditor.guards.isEmpty()) { + Disposer.dispose(inlineEditor) + return null + } + project.putUserData(CURRENT_KEY, inlineEditor) + Disposer.register(project.service(), inlineEditor) + EditorNotifications.getInstance(project).updateAllNotifications() + return inlineEditor + } + } +} diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocIntentionActionFilter.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocIntentionActionFilter.kt new file mode 100644 index 000000000..d480e8c64 --- /dev/null +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocIntentionActionFilter.kt @@ -0,0 +1,50 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.platform.fabric.annotater.javadoc + +import com.intellij.codeInsight.daemon.impl.IntentionActionFilter +import com.intellij.codeInsight.intention.IntentionAction +import com.intellij.psi.PsiFile +import com.intellij.psi.javadoc.PsiDocComment +import com.intellij.psi.util.PsiTreeUtil + +/** + * Hides intentions and quick fixes in the copies of Minecraft source files that annotater Javadocs are edited in, + * except in the edited Javadoc comment. Guarded blocks only stop the user from typing in the hidden source code, not + * other changes to the document, and fixes that apply to the whole file (with an offset of -1) could change it. + */ +class AnnotaterJavadocIntentionActionFilter : IntentionActionFilter { + override fun accept(intentionAction: IntentionAction, psiFile: PsiFile?, offset: Int): Boolean { + if (psiFile == null || AnnotaterJavadocVirtualFile.of(psiFile) == null) { + return true + } + if (offset < 0) { + return false + } + val document = psiFile.viewProvider.document + // The caret can be just after the end of a comment + return listOf(offset, offset - 1).any { candidate -> + val element = psiFile.findElementAt(candidate) ?: return@any false + val comment = PsiTreeUtil.getParentOfType(element, PsiDocComment::class.java, false) ?: return@any false + !AnnotaterJavadocInlineEditor.isGuarded(document, comment.textRange) + } + } +} diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocRefresher.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocRefresher.kt new file mode 100644 index 000000000..0d5bc2ee0 --- /dev/null +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocRefresher.kt @@ -0,0 +1,76 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.platform.fabric.annotater.javadoc + +import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterCheckoutService +import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterListener +import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.ProjectManager +import com.intellij.openapi.vfs.newvfs.BulkFileListener +import com.intellij.openapi.vfs.newvfs.events.VFileEvent +import com.intellij.ui.EditorNotifications +import java.nio.file.Path + +/** + * Refreshes the places annotater Javadocs are displayed when they change, whether through the plugin or on disk. + */ +class AnnotaterJavadocRefresher : AnnotaterListener, BulkFileListener { + override fun checkoutsChanged() { + refresh(updateNotifications = true) + } + + override fun javadocsChanged(checkoutRoot: Path, outerClass: String) { + refresh() + } + + override fun after(events: List) { + val paths = events.mapNotNull { event -> + val path = event.path + path.takeIf { it.endsWith(".mapping") || it.endsWith("/config.json") }?.let { Path.of(it) } + } + if (paths.isEmpty()) { + return + } + val (changedJavadocs, configChanged) = AnnotaterCheckoutService.instance.filesChanged(paths) + val publisher = ApplicationManager.getApplication().messageBus.syncPublisher(AnnotaterListener.TOPIC) + if (configChanged) { + publisher.checkoutsChanged() + } + for ((root, outerClass) in changedJavadocs) { + publisher.javadocsChanged(root, outerClass) + } + } + + private fun refresh(updateNotifications: Boolean = false) { + ApplicationManager.getApplication().invokeLater { + for (project in ProjectManager.getInstance().openProjects) { + if (!project.isDisposed) { + DaemonCodeAnalyzer.getInstance(project).restart(this) + if (updateNotifications) { + // The banner is shown wherever there's a checkout + EditorNotifications.getInstance(project).updateAllNotifications() + } + } + } + } + } +} diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocRendering.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocRendering.kt new file mode 100644 index 000000000..2e382d214 --- /dev/null +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocRendering.kt @@ -0,0 +1,97 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.platform.fabric.annotater.javadoc + +import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterCheckout +import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterCheckoutService +import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterSettings +import com.intellij.lang.java.JavaDocumentationProvider +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.psi.PsiClass +import com.intellij.psi.PsiCompiledElement +import com.intellij.psi.PsiJavaDocumentedElement +import com.intellij.psi.PsiJavaFile +import com.intellij.psi.PsiMember +import com.intellij.psi.util.PsiTreeUtil +import java.io.IOException + +/** + * Renders annotater Javadocs with the IDE's own Java documentation renderer, by rendering the declaration in a copy of + * its source file that has the annotater Javadoc as its doc comment. + */ +object AnnotaterJavadocRendering { + /** + * Finds the checkout whose Javadocs should be displayed instead of the existing Javadoc of [member], or null if + * the existing Javadoc should be shown. + */ + fun checkoutForDisplay(member: PsiMember): AnnotaterCheckout? { + if (!AnnotaterSettings.instance.showAnnotaterJavadocs) { + return null + } + return AnnotaterCheckoutService.instance.checkoutFor(member) + } + + /** + * All classes, fields and methods declared in [file] and its inner classes. + */ + fun displayedMembers(file: PsiJavaFile): Sequence = members(file.classes) + + private fun members(classes: Array): Sequence = classes.asSequence().flatMap { clazz -> + sequenceOf(clazz) + clazz.fields + clazz.methods + members(clazz.innerClasses) + } + + /** + * The offset that the annotater Javadoc of [member] is displayed above: its declaration, after any existing + * doc comment. + */ + fun displayOffset(member: PsiMember): Int { + val existing = if (member is PsiCompiledElement) null else (member as? PsiJavaDocumentedElement)?.docComment + return existing?.let { PsiTreeUtil.skipWhitespacesAndCommentsForward(it) }?.textRange?.startOffset + ?: member.textRange.startOffset + } + + fun getJavadoc(checkout: AnnotaterCheckout, target: AnnotaterJavadocTarget): String? { + return try { + if (checkout.javadocStore.hasFile(target.outerClass)) checkout.javadocStore.get(target) else null + } catch (e: IOException) { + thisLogger().info("Failed to read annotater Javadoc of $target", e) + null + } + } + + /** + * Finds the declaration of [member] in a copy of its source file with [javadoc] as its doc comment. + */ + private fun copiedDeclaration(member: PsiMember, javadoc: String?): PsiJavaDocumentedElement? { + val target = AnnotaterJavadocTarget.fromPsi(member) ?: return null + val copy = AnnotaterJavadocSourceCopy.create(member) { if (it == target) javadoc else null } ?: return null + val file = copy.createPsiFile(member.project) ?: return null + return copy.memberOf(file, target) as? PsiJavaDocumentedElement + } + + /** + * Generates the Quick Documentation of [member] using [javadoc] as its description. + */ + fun generateDoc(member: PsiMember, javadoc: String?): String? { + val declaration = copiedDeclaration(member, javadoc) ?: return null + return JavaDocumentationProvider.generateDocStatic(declaration, null) + } +} diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocSourceCopy.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocSourceCopy.kt new file mode 100644 index 000000000..38f398897 --- /dev/null +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocSourceCopy.kt @@ -0,0 +1,233 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.platform.fabric.annotater.javadoc + +import com.demonwav.mcdev.asset.MCDevBundle +import com.intellij.ide.highlighter.JavaFileType +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.TextRange +import com.intellij.pom.java.LanguageLevel +import com.intellij.psi.PsiClass +import com.intellij.psi.PsiComment +import com.intellij.psi.PsiCompiledElement +import com.intellij.psi.PsiJavaDocumentedElement +import com.intellij.psi.PsiJavaFile +import com.intellij.psi.PsiManager +import com.intellij.psi.PsiMember +import com.intellij.psi.PsiWhiteSpace +import com.intellij.psi.impl.compiled.ClsElementImpl +import com.intellij.psi.javadoc.PsiDocComment +import com.intellij.psi.util.PsiTreeUtil +import com.intellij.psi.util.PsiUtil +import com.intellij.testFramework.LightVirtualFile + +/** + * A copy of the source file of a Minecraft class (its attached sources, or otherwise its decompiled text), with the + * existing Javadocs replaced by the annotater Javadocs as Markdown doc comments. This lets the IDE's own Java support + * be used to edit and render annotater Javadocs in their real context. + * + * The member at index `i` of [members] documents `targets[i]`, or nothing if that is null, e.g. for members of other + * top level classes in the same file. + */ +class AnnotaterJavadocSourceCopy( + val fileName: String, + val text: String, + val targets: List, +) { + /** + * The internal name of the top level class whose Javadocs this copy edits. + */ + val outerClass: String + get() = targets.firstNotNullOf { it }.outerClass + + fun createPsiFile(project: Project): PsiJavaFile? { + return PsiManager.getInstance(project).findFile(createVirtualFile()) as? PsiJavaFile + } + + fun createVirtualFile(): LightVirtualFile = LightVirtualFile(fileName, JavaFileType.INSTANCE, text).also { + // Markdown doc comments are only parsed at a high enough language level + it.putUserData(PsiUtil.FILE_LANGUAGE_LEVEL_KEY, LanguageLevel.HIGHEST) + } + + /** + * Finds the member of [file], which must have been created from this copy, that documents [target]. + */ + fun memberOf(file: PsiJavaFile, target: AnnotaterJavadocTarget): PsiMember? { + val index = targets.indexOf(target) + return if (index < 0) null else members(file).getOrNull(index) + } + + /** + * Finds the target documented by [member] of [file], which must have been created from this copy. + */ + fun targetOf(file: PsiJavaFile, member: PsiMember): AnnotaterJavadocTarget? { + val index = members(file).indexOf(member) + return if (index < 0) null else targets.getOrNull(index) + } + + sealed class ExtractResult { + data class Success(val javadocs: Map) : ExtractResult() + data class Error(val message: String) : ExtractResult() + } + + /** + * Reads the Javadoc of every target back out of [file], which must have been created from this copy. + * + * @param isOriginal whether a range of [file] is part of the source that the user can't edit, so that comments + * there are allowed + */ + fun extractJavadocs(file: PsiJavaFile, isOriginal: (TextRange) -> Boolean = { false }): ExtractResult { + val comments = PsiTreeUtil.collectElementsOfType(file, PsiComment::class.java) + for (comment in comments) { + if (isOriginal(comment.textRange)) { + continue + } + if (comment is PsiDocComment) { + if (!comment.isMarkdownComment) { + return ExtractResult.Error(MCDevBundle("fabric.annotater.javadoc.error.not_markdown")) + } + } else if (PsiTreeUtil.getParentOfType(comment, PsiDocComment::class.java) == null) { + return ExtractResult.Error(MCDevBundle("fabric.annotater.javadoc.error.plain_comment")) + } + } + + val members = members(file) + if (members.size != targets.size) { + return ExtractResult.Error(MCDevBundle("fabric.annotater.javadoc.error.structure_changed")) + } + val javadocs = LinkedHashMap() + for ((member, target) in members.zip(targets)) { + if (target != null) { + javadocs[target] = (member as PsiJavaDocumentedElement).docComment?.let(::extractMarkdown) + } + } + return ExtractResult.Success(javadocs) + } + + companion object { + /** + * Finds the classes, fields and methods declared in [file], in the order matching [targets]. Local and + * anonymous classes are not included, and neither are members that don't appear in the source, such as the + * implicit methods of records. + */ + fun members(file: PsiJavaFile): List = members(file.classes).toList() + + private fun members(classes: Array): Sequence = classes.asSequence().flatMap { clazz -> + sequenceOf(clazz) + clazz.fields + clazz.methods + members(clazz.innerClasses) + }.filter { it.node != null } + + /** + * Extracts the Markdown text of a `///` doc comment, removing the `///` prefixes and the common indentation. + * Returns null when the comment is blank. + */ + fun extractMarkdown(comment: PsiDocComment): String? { + val lines = comment.text.lines().map { line -> + val trimmed = line.trimStart() + if (trimmed.startsWith("///")) trimmed.substring(3) else trimmed + } + val indent = lines.filter { it.isNotBlank() }.minOfOrNull { line -> line.takeWhile { it == ' ' }.length } + ?: return null + val text = lines.joinToString("\n") { if (it.isBlank()) "" else it.substring(indent).trimEnd() } + .trim('\n') + return text.ifEmpty { null } + } + + /** + * Formats Markdown as a `///` doc comment with the given indentation, ending with a newline. + */ + fun formatMarkdown(markdown: String, indent: String): String { + return markdown.lines().joinToString("") { line -> + if (line.isEmpty()) "$indent///\n" else "$indent/// $line\n" + } + } + + /** + * Formats Markdown as a `///` doc comment to insert at a column after [indent], followed by [indent] again for + * the declaration after it. + */ + fun formatInsertedMarkdown(markdown: String, indent: String): String { + // Leave a space after an empty first line, so the user can start typing there straight away + return "/// " + formatMarkdown(markdown, indent).removePrefix("$indent///").removePrefix(" ") + indent + } + + /** + * The source file that [clazz] is shown in: its attached sources, or otherwise its decompiled text. + */ + private fun sourceFile(clazz: PsiClass): PsiJavaFile? { + val file = clazz.navigationElement.containingFile + return (if (file is PsiCompiledElement) file.mirror else file) as? PsiJavaFile + } + + /** + * Copies the source file of the top level class containing [element], a class, field or method. + * + * @param placeholderFor a target to add an empty doc comment to if it doesn't have an annotater Javadoc, so + * that the user can start typing one straight away + * @param javadoc looks up the current annotater Javadoc of a target + */ + fun create( + element: PsiMember, + placeholderFor: AnnotaterJavadocTarget? = null, + javadoc: (AnnotaterJavadocTarget) -> String?, + ): AnnotaterJavadocSourceCopy? { + val outermost = generateSequence(element as? PsiClass ?: element.containingClass) { it.containingClass } + .lastOrNull() ?: return null + val outerClass = AnnotaterJavadocTarget.fromPsi(outermost)?.outerClass ?: return null + val source = sourceFile(outermost) ?: return null + val text = source.text + + val targets = mutableListOf() + // The replacements of the existing doc comments, in text order + val replacements = mutableListOf>() + for (member in members(source)) { + // Decompiled text is generated from the compiled class, which is more reliable to get the target of + val compiled = member.getUserData(ClsElementImpl.COMPILED_ELEMENT) ?: member + val target = AnnotaterJavadocTarget.fromPsi(compiled)?.takeIf { it.outerClass == outerClass } + val existing = (member as PsiJavaDocumentedElement).docComment + val start = existing?.textRange?.startOffset ?: member.textRange.startOffset + val lineStart = text.lastIndexOf('\n', start - 1) + 1 + val indent = text.substring(lineStart, start) + // Doc comments can only be inserted above members that start their own line + if (target == null || indent.isNotBlank()) { + targets += null + continue + } + targets += target + + val markdown = javadoc(target) ?: "".takeIf { target == placeholderFor } + val end = existing?.let { (it.nextSibling as? PsiWhiteSpace ?: it).textRange.endOffset } ?: start + if (markdown != null || existing != null) { + val replacement = markdown?.let { formatInsertedMarkdown(it, indent) }.orEmpty() + replacements += TextRange(start, end) to replacement + } + } + if (targets.all { it == null }) { + return null + } + + val sb = StringBuilder(text) + for ((range, replacement) in replacements.sortedByDescending { it.first.startOffset }) { + sb.replace(range.startOffset, range.endOffset, replacement) + } + return AnnotaterJavadocSourceCopy("${outermost.name}.java", sb.toString(), targets) + } + } +} diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocStore.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocStore.kt new file mode 100644 index 000000000..1c2411c99 --- /dev/null +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocStore.kt @@ -0,0 +1,250 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.platform.fabric.annotater.javadoc + +import java.io.IOException +import java.io.StringReader +import java.io.StringWriter +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.ConcurrentHashMap +import kotlin.io.path.deleteIfExists +import kotlin.io.path.exists +import kotlin.io.path.readText +import kotlin.io.path.writeText +import net.fabricmc.mappingio.MappedElementKind +import net.fabricmc.mappingio.MappingUtil +import net.fabricmc.mappingio.MappingVisitor +import net.fabricmc.mappingio.adapter.ForwardingMappingVisitor +import net.fabricmc.mappingio.format.enigma.EnigmaFileReader +import net.fabricmc.mappingio.format.enigma.EnigmaFileWriter +import net.fabricmc.mappingio.tree.MappingTree +import net.fabricmc.mappingio.tree.MemoryMappingTree +import net.fabricmc.mappingio.tree.VisitOrder + +/** + * Reads and writes the Javadoc comments stored in an annotater `mappings` directory. Comments are stored in Enigma + * format, with one file per top level class at `/.mapping`. The reading and writing + * matches mcsrc.dev so that files edited by either tool produce the same diffs. + */ +class AnnotaterJavadocStore(val mappingsDir: Path) { + private val cache = ConcurrentHashMap() + + fun fileFor(outerClass: String): Path = mappingsDir.resolve("$outerClass.mapping") + + fun hasFile(outerClass: String): Boolean = fileFor(outerClass).exists() + + @Throws(IOException::class) + fun get(target: AnnotaterJavadocTarget): String? { + val clazz = treeFor(target.outerClass).getClass(target.owner) ?: return null + return when (target) { + is AnnotaterJavadocTarget.ClassTarget -> clazz.comment + is AnnotaterJavadocTarget.FieldTarget -> clazz.getField(target.name, target.descriptor)?.comment + is AnnotaterJavadocTarget.MethodTarget -> clazz.getMethod(target.name, target.descriptor)?.comment + } + } + + /** + * Sets the comment for [target], or removes it when [comment] is null or empty, and writes the `.mapping` file + * of its outer class. The file is deleted when it no longer contains any comments. + */ + @Throws(IOException::class) + fun set(target: AnnotaterJavadocTarget, comment: String?) { + setAll(mapOf(target to comment)) + } + + /** + * Like [set], but for several targets at once. All targets must share the same outer class. + */ + @Throws(IOException::class) + @Synchronized + fun setAll(comments: Map) { + if (comments.isEmpty()) { + return + } + val outerClass = comments.keys.first().outerClass + require(comments.keys.all { it.outerClass == outerClass }) { "All targets must share the same outer class" } + + val tree = copyTree(treeFor(outerClass)) + for ((target, comment) in comments) { + setInTree(tree, target, comment?.takeIf { it.isNotEmpty() }) + } + + val text = writeTree(tree) + val file = fileFor(outerClass) + if (text.isEmpty()) { + file.deleteIfExists() + } else { + Files.createDirectories(file.parent) + file.writeText(text) + } + cache[outerClass] = tree + } + + fun invalidate(outerClass: String) { + cache.remove(outerClass) + } + + fun invalidateAll() { + cache.clear() + } + + @Throws(IOException::class) + private fun treeFor(outerClass: String): MemoryMappingTree { + cache[outerClass]?.let { return it } + val file = fileFor(outerClass) + val tree = if (file.exists()) readFile(file.readText(), file.toString(), outerClass) else newTree() + return cache.putIfAbsent(outerClass, tree) ?: tree + } + + companion object { + @Throws(IOException::class) + fun readFile(text: String, path: String, expectedOuterClass: String? = null): MemoryMappingTree { + val tree = MemoryMappingTree() + try { + EnigmaFileReader.read(StringReader(text), withoutMethodVariables(tree)) + } catch (e: IOException) { + throw IOException("Invalid Enigma mapping file $path: ${e.message}", e) + } catch (e: RuntimeException) { + throw IOException("Invalid Enigma mapping file $path: ${e.message}", e) + } + + val owners = tree.classes.mapTo(mutableSetOf()) { AnnotaterJavadocTarget.outerClassName(it.srcName) } + if (owners.size > 1) { + throw IOException("Enigma mapping file $path contains multiple outer classes") + } + if (expectedOuterClass != null && owners.isNotEmpty() && owners.single() != expectedOuterClass) { + throw IOException("Enigma mapping file $path contains ${owners.single()}, not $expectedOuterClass") + } + return tree + } + + @Throws(IOException::class) + fun writeTree(tree: MemoryMappingTree): String { + val meaningful = newTree() + for (clazz in tree.classes) { + if (isMeaningful(clazz)) { + meaningful.addClass(clazz) + } + } + if (meaningful.classes.isEmpty()) { + return "" + } + + val output = StringWriter() + EnigmaFileWriter(output).use { writer -> + meaningful.accept(writer, VisitOrder.createByName().classesBySrcNameShortFirst()) + } + return output.toString() + } + + private fun setInTree(tree: MemoryMappingTree, target: AnnotaterJavadocTarget, comment: String?) { + if (comment == null) { + val clazz = tree.getClass(target.owner) ?: return + when (target) { + is AnnotaterJavadocTarget.ClassTarget -> clazz.comment = null + is AnnotaterJavadocTarget.FieldTarget -> { + val field = clazz.getField(target.name, target.descriptor) + if (field != null) { + field.comment = null + if (!hasContent(field)) { + clazz.removeField(target.name, target.descriptor) + } + } + } + + is AnnotaterJavadocTarget.MethodTarget -> { + val method = clazz.getMethod(target.name, target.descriptor) + if (method != null) { + method.comment = null + if (!hasContent(method)) { + clazz.removeMethod(target.name, target.descriptor) + } + } + } + } + if (!isMeaningful(clazz)) { + tree.removeClass(clazz.srcName) + } + return + } + + tree.visitClass(target.owner) + tree.visitElementContent(MappedElementKind.CLASS) + when (target) { + is AnnotaterJavadocTarget.ClassTarget -> tree.visitComment(MappedElementKind.CLASS, comment) + is AnnotaterJavadocTarget.FieldTarget -> { + tree.visitField(target.name, target.descriptor) + tree.visitElementContent(MappedElementKind.FIELD) + tree.visitComment(MappedElementKind.FIELD, comment) + } + + is AnnotaterJavadocTarget.MethodTarget -> { + tree.visitMethod(target.name, target.descriptor) + tree.visitElementContent(MappedElementKind.METHOD) + tree.visitComment(MappedElementKind.METHOD, comment) + } + } + tree.visitEnd() + } + + private fun newTree(): MemoryMappingTree { + val tree = MemoryMappingTree() + tree.visitNamespaces(MappingUtil.NS_SOURCE_FALLBACK, listOf(MappingUtil.NS_TARGET_FALLBACK)) + tree.visitEnd() + return tree + } + + private fun copyTree(tree: MemoryMappingTree): MemoryMappingTree { + if (tree.srcNamespace == null) { + return newTree() + } + val copy = MemoryMappingTree() + tree.accept(copy) + return copy + } + + private fun withoutMethodVariables(next: MappingVisitor): MappingVisitor { + return object : ForwardingMappingVisitor(next) { + override fun visitMethodArg(argPosition: Int, lvIndex: Int, srcName: String?) = false + + override fun visitMethodVar( + lvtRowIndex: Int, + lvIndex: Int, + startOpIdx: Int, + endOpIdx: Int, + srcName: String?, + ) = false + } + } + + private fun isMeaningful(clazz: MappingTree.ClassMapping): Boolean { + return hasContent(clazz) || clazz.fields.any(::hasContent) || clazz.methods.any(::hasContent) + } + + private fun hasContent(element: MappingTree.ElementMapping): Boolean { + if (element.comment != null) { + return true + } + return (0 until element.tree.maxNamespaceId).any { element.getDstName(it) != null } + } + } +} diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocTarget.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocTarget.kt new file mode 100644 index 000000000..43fb33b4e --- /dev/null +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocTarget.kt @@ -0,0 +1,106 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.platform.fabric.annotater.javadoc + +import com.demonwav.mcdev.util.descriptor +import com.demonwav.mcdev.util.internalName +import com.intellij.psi.PsiClass +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiField +import com.intellij.psi.PsiMethod + +/** + * A class, field or method that can have a Javadoc comment in annotater. All names are internal (slash separated) + * official names, and members are identified by their name and JVM descriptor. + */ +sealed class AnnotaterJavadocTarget { + abstract val owner: String + + /** + * The internal name of the top level class whose `.mapping` file stores this target. + */ + val outerClass: String + get() = outerClassName(owner) + + data class ClassTarget(val internalName: String) : AnnotaterJavadocTarget() { + override val owner get() = internalName + } + + data class FieldTarget( + override val owner: String, + val name: String, + val descriptor: String, + ) : AnnotaterJavadocTarget() + + data class MethodTarget( + override val owner: String, + val name: String, + val descriptor: String, + ) : AnnotaterJavadocTarget() + + companion object { + fun fromPsi(element: PsiElement): AnnotaterJavadocTarget? { + return when (element) { + is PsiClass -> { + if (element.qualifiedName == null) { + // Anonymous and local classes can't be documented + return null + } + ClassTarget(element.internalName ?: return null) + } + + is PsiField -> { + val owner = element.containingClass?.takeIf { it.qualifiedName != null } ?: return null + FieldTarget(owner.internalName ?: return null, element.name, element.descriptor ?: return null) + } + + is PsiMethod -> { + val owner = element.containingClass?.takeIf { it.qualifiedName != null } ?: return null + MethodTarget( + owner.internalName ?: return null, + element.internalName, + element.descriptor ?: return null + ) + } + + else -> null + } + } + + /** + * Returns the top level class of a nested class name, treating `$` directly after a package separator or at + * the start of a name as part of the class name, matching mcsrc. + */ + fun outerClassName(name: String): String { + var start = 0 + while (true) { + val separator = name.indexOf('$', start) + if (separator < 0) { + return name + } + if (separator > 0 && name[separator - 1] != '/') { + return name.substring(0, separator) + } + start = separator + 1 + } + } + } +} diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocUnqualifiedReferenceInspection.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocUnqualifiedReferenceInspection.kt new file mode 100644 index 000000000..d43b7a8ac --- /dev/null +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocUnqualifiedReferenceInspection.kt @@ -0,0 +1,93 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.platform.fabric.annotater.javadoc + +import com.demonwav.mcdev.asset.MCDevBundle +import com.intellij.codeInspection.LocalInspectionTool +import com.intellij.codeInspection.LocalQuickFix +import com.intellij.codeInspection.ProblemDescriptor +import com.intellij.codeInspection.ProblemsHolder +import com.intellij.openapi.project.Project +import com.intellij.psi.JavaElementVisitor +import com.intellij.psi.PsiClass +import com.intellij.psi.PsiDocumentManager +import com.intellij.psi.PsiElementVisitor +import com.intellij.psi.PsiJavaCodeReferenceElement +import com.intellij.psi.javadoc.PsiDocComment +import com.intellij.psi.util.PsiTreeUtil + +/** + * Annotater requires classes in Javadoc to be referenced by their fully qualified name. + */ +class AnnotaterJavadocUnqualifiedReferenceInspection : LocalInspectionTool() { + override fun getStaticDescription() = MCDevBundle("fabric.annotater.inspection.unqualified.description") + + override fun buildVisitor(holder: ProblemsHolder, isOnTheFly: Boolean): PsiElementVisitor { + if (AnnotaterJavadocVirtualFile.of(holder.file) == null) { + return PsiElementVisitor.EMPTY_VISITOR + } + return object : JavaElementVisitor() { + override fun visitReferenceElement(reference: PsiJavaCodeReferenceElement) { + // Only check whole references, not their qualifiers + if (reference.parent is PsiJavaCodeReferenceElement) { + return + } + if (PsiTreeUtil.getParentOfType(reference, PsiDocComment::class.java) == null) { + return + } + val qualifiedName = qualifiedNameIfUnqualified(reference) ?: return + holder.registerProblem( + reference, + MCDevBundle("fabric.annotater.inspection.unqualified.problem", qualifiedName), + QualifyFix(qualifiedName), + ) + } + } + } + + private class QualifyFix(private val qualifiedName: String) : LocalQuickFix { + override fun getName() = MCDevBundle("fabric.annotater.inspection.unqualified.fix", qualifiedName) + + override fun getFamilyName() = MCDevBundle("fabric.annotater.inspection.unqualified.fix.family") + + override fun applyFix(project: Project, descriptor: ProblemDescriptor) { + val reference = descriptor.psiElement as? PsiJavaCodeReferenceElement ?: return + val file = reference.containingFile ?: return + val documentManager = PsiDocumentManager.getInstance(project) + val document = documentManager.getDocument(file) ?: return + val range = reference.textRange + document.replaceString(range.startOffset, range.endOffset, qualifiedName) + documentManager.commitDocument(document) + } + } + + companion object { + /** + * Returns the fully qualified name of the class [reference] refers to, if it isn't already written as such. + */ + fun qualifiedNameIfUnqualified(reference: PsiJavaCodeReferenceElement): String? { + val resolved = reference.resolve() as? PsiClass ?: return null + val qualifiedName = resolved.qualifiedName ?: return null + val written = reference.text.filterNot { it.isWhitespace() } + return qualifiedName.takeIf { written != it } + } + } +} diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocVetoRenameCondition.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocVetoRenameCondition.kt new file mode 100644 index 000000000..26eec473c --- /dev/null +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocVetoRenameCondition.kt @@ -0,0 +1,31 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.platform.fabric.annotater.javadoc + +import com.intellij.openapi.util.Condition +import com.intellij.psi.PsiElement + +/** + * Prevents renaming declarations in annotater Javadoc editors, which would change the read-only copied source code. + */ +class AnnotaterJavadocVetoRenameCondition : Condition { + override fun value(element: PsiElement): Boolean = AnnotaterJavadocVirtualFile.of(element.containingFile) != null +} diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocVirtualFile.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocVirtualFile.kt new file mode 100644 index 000000000..aace7bdf0 --- /dev/null +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocVirtualFile.kt @@ -0,0 +1,57 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.platform.fabric.annotater.javadoc + +import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterCheckout +import com.intellij.ide.highlighter.JavaFileType +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.pom.java.LanguageLevel +import com.intellij.psi.PsiFile +import com.intellij.psi.util.PsiUtil +import com.intellij.testFramework.LightVirtualFile + +/** + * The copy of a Minecraft source file that an annotater Javadoc is edited in. It's never opened as a tab, but shown + * through an [AnnotaterJavadocInlineEditor]'s inline editor, which only shows the Javadoc of [target]. + */ +class AnnotaterJavadocVirtualFile( + val checkout: AnnotaterCheckout, + val copy: AnnotaterJavadocSourceCopy, + val target: AnnotaterJavadocTarget, +) : LightVirtualFile(copy.fileName, JavaFileType.INSTANCE, copy.text) { + /** + * The reason the last save failed, shown in the editor banner. + */ + @Volatile + var error: String? = null + + val outerClass: String + get() = copy.outerClass + + init { + putUserData(PsiUtil.FILE_LANGUAGE_LEVEL_KEY, LanguageLevel.HIGHEST) + } + + companion object { + fun of(file: PsiFile?): AnnotaterJavadocVirtualFile? = + file?.viewProvider?.virtualFile as? AnnotaterJavadocVirtualFile + } +} diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/EditAnnotaterJavadocAction.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/EditAnnotaterJavadocAction.kt new file mode 100644 index 000000000..c9e2928b6 --- /dev/null +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/EditAnnotaterJavadocAction.kt @@ -0,0 +1,52 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.platform.fabric.annotater.javadoc + +import com.demonwav.mcdev.asset.GeneralAssets +import com.demonwav.mcdev.asset.MCDevBundle +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.CommonDataKeys + +class EditAnnotaterJavadocAction : AnAction() { + override fun getActionUpdateThread() = ActionUpdateThread.BGT + + override fun update(e: AnActionEvent) { + e.presentation.text = MCDevBundle("fabric.annotater.javadoc.edit") + e.presentation.icon = GeneralAssets.ANNOTATER + val editor = e.getData(CommonDataKeys.EDITOR) + val file = e.getData(CommonDataKeys.PSI_FILE) + val project = e.project + e.presentation.isEnabledAndVisible = project != null && + AnnotaterJavadocEditing.isOffered(project) && + editor != null && + file != null && + AnnotaterJavadocEditing.findTarget(editor, file) != null + } + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val editor = e.getData(CommonDataKeys.EDITOR) ?: return + val file = e.getData(CommonDataKeys.PSI_FILE) ?: return + AnnotaterJavadocEditing.edit(project, editor, file) + } +} diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/EditAnnotaterJavadocIntention.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/EditAnnotaterJavadocIntention.kt new file mode 100644 index 000000000..2b5f32399 --- /dev/null +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/EditAnnotaterJavadocIntention.kt @@ -0,0 +1,53 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.platform.fabric.annotater.javadoc + +import com.demonwav.mcdev.asset.MCDevBundle +import com.intellij.codeInsight.intention.IntentionAction +import com.intellij.codeInsight.intention.preview.IntentionPreviewInfo +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiFile + +class EditAnnotaterJavadocIntention : IntentionAction { + override fun getText() = MCDevBundle("fabric.annotater.javadoc.edit") + + override fun getFamilyName() = text + + override fun startInWriteAction() = false + + override fun isAvailable(project: Project, editor: Editor?, file: PsiFile?): Boolean { + if (editor == null || file == null || !AnnotaterJavadocEditing.isOffered(project)) { + return false + } + return AnnotaterJavadocEditing.findTarget(editor, file) != null + } + + override fun invoke(project: Project, editor: Editor?, file: PsiFile?) { + if (editor == null || file == null) { + return + } + AnnotaterJavadocEditing.edit(project, editor, file) + } + + override fun generatePreview(project: Project, editor: Editor, file: PsiFile): IntentionPreviewInfo = + IntentionPreviewInfo.EMPTY +} diff --git a/src/main/resources/META-INF/mcdev-annotater-git.xml b/src/main/resources/META-INF/mcdev-annotater-git.xml new file mode 100644 index 000000000..a7c1d86c9 --- /dev/null +++ b/src/main/resources/META-INF/mcdev-annotater-git.xml @@ -0,0 +1,28 @@ + + + + org.jetbrains.plugins.github + + + + + diff --git a/src/main/resources/META-INF/mcdev-annotater-github.xml b/src/main/resources/META-INF/mcdev-annotater-github.xml new file mode 100644 index 000000000..88574160d --- /dev/null +++ b/src/main/resources/META-INF/mcdev-annotater-github.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index c51fe71ce..845ebf2da 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -34,6 +34,7 @@ org.jetbrains.kotlin org.toml.lang org.jetbrains.plugins.yaml + Git4Idea com.demonwav.minecraft-dev Minecraft Development @@ -405,6 +406,10 @@ id="Settings.Minecraft" groupId="language" instance="com.demonwav.mcdev.MinecraftConfigurable"/> + + + + + + + + + + + + + + JAVA + com.demonwav.mcdev.platform.fabric.annotater.javadoc.EditAnnotaterJavadocIntention + Minecraft + EditAnnotaterJavadocIntention + @@ -1050,6 +1079,20 @@ + + + + @@ -1768,6 +1815,12 @@ text="Minecraft Class" description="Create skeleton classes used in Minecraft Mods"> + + + diff --git a/src/main/resources/assets/icons/general/Annotater.LICENSE.txt b/src/main/resources/assets/icons/general/Annotater.LICENSE.txt new file mode 100644 index 000000000..0f87cc2d1 --- /dev/null +++ b/src/main/resources/assets/icons/general/Annotater.LICENSE.txt @@ -0,0 +1,3 @@ +Annotater.png, Annotater@2x.png, AnnotaterLarge.png and AnnotaterLarge@2x.png are downscaled from +https://github.com/FabricMC/community/blob/main/media/tater-wall/png/potato.png by FabricMC, licensed under the +Creative Commons Attribution 4.0 International license (https://creativecommons.org/licenses/by/4.0/). diff --git a/src/main/resources/assets/icons/general/Annotater.png b/src/main/resources/assets/icons/general/Annotater.png new file mode 100644 index 0000000000000000000000000000000000000000..41f799e8542841151c95abc6591009ea63964f0e GIT binary patch literal 655 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GXl47_9?*LR^8|dk1&l*tz-f$-{RK z?0R(k;H8bLF05OAdDEJ!Ti0FMu=4cs1s6B0y1Hd8P}!aRJAoun8H9Xt>Ij4Z6nT8& z(DfY~f!v$BHs9L5;p&!kS2nG_vU%;T?dyRg&_JNzwasgQjEn16oL{>Ph=AgkH?F$4 zVI@!m$hfj)?X|7zfi?oUKq;UJKsH4E<&CR>T#%EttOMG0d*6<``*#ARZtvN8=fJMJ z2Y27vy%i|Cc0$+59$Py9?3$(5wt*Z2bjF>X z8|$;u)=%mIx~e8U`N)#FPY>(>+6(mip1ISrB14N4Vs_7&c4O(iv z#aA}1xw~`Iy9tVQbqeyr|g+nieD75Fr77<-3iou!qdeuMB;LCLc)W`=_x`2A_+-Po+mRf zr->;hW@l$7DvE`5G<2-kx^>2m8C$omU~zG{Skv)JTKQ`<%nfl&+e-F=Bcm%50*@6m@IjM5|q&7#XX6 z2{4+KF*`5Nbvq_AM}Sc{STE1Gm|HH+TU&vjA#k?puL>sF<)8rZboFyt=akR{0H=Lk APyhe` literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/icons/general/Annotater@2x.png b/src/main/resources/assets/icons/general/Annotater@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..596222373a880bbc025625e6b8f2ef0cad90fa1a GIT binary patch literal 1505 zcmbVLiBpqT5YMR*>i}&T@IVo5q4pT8IAR?HC5RM_2-rGOD^sXNil`NBDJTL8a^n?H z`@TpZBoItc#2_Sugb)QAOH~j&IFs&Is((P=%(rjfzJ0s<`|WHVOg5hYrrygi&eAw1J)ZY5&wz9vuVNk7nd`D&N z)VwhDT2%77W ztSIXhb4PXe&2n)!m!=moO)`N&%rVJ?1BwPi15YnxTa+>=1CoOLPf^NrLiUiR1&&ZT zNI(G=KxAqXL!D3<*g=907(s`tIr&f}pL=Du+vgO-6A2l{|yBZfObfMAVL_B zLKeUXh#)4wfhP7!xX>LSA6`K>4N?Kp6sVj_GdJ-1WCFkrF%3dieL?oA__$6+9Y6q9 z^&$@R&M0EnR!(oA9wuvd|Fde_!~4tg#7QXvM77L;D-6R~6!$3f;n6;Da$LUHE$3Kb z<=OOZ4wY7%yJE@Wtiy-u^Rta&ww}+pdL~uJr|U?aZcs;cas(+7_W!%^-fb!`EZQMw zD%$~r!Nok8wH`p@H`@H%s;05WU(dYyMb|T9v&~FSOixWCYjVamJJU6-MJJ8h#*)x3 z<+ImZ(D?PrDWxwSjlo4f9*V|cuoyztL?!`)#jjQT$&83u55@V;|0X@9!7y z#SP+1^$ZMvRel9Q4d9E4r|N5I9C8Ef9xZ zO2O5I`&DKUN{?r{MmYIk{SO5M%UZXQs+`@zf;|fVti-RmKA2ehYTOl9`H3Rxwkk{# zsJfG~tn}B90;)La&A!$>`t_RcJ(RDO?udCB5L1WWa3oYTB3?LRj-@a2ac@3c`dqr) z&pQ%J+SJ_Q*}1CeJD-;E2~8w>6FLwtuJTWR5pg+c%f1^ghXvN1+Gr|fs5IGCo;osR zaZb4s)Ky%y*)@G~xH<3!|+gd zzxFBTY$V*R*sy|5mV2za^!Z*fA!D8*zf>JWCTAo|ni5mC2jRY@x5+qz%|k zKHHSsZAl(B)&8B4q?qsMzWM#G+4$nh#@|z2Ph;%l%!1f2V&uqRhX#{~%&jq*{{YR{ BP6z-1 literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/icons/general/AnnotaterLarge.png b/src/main/resources/assets/icons/general/AnnotaterLarge.png new file mode 100644 index 0000000000000000000000000000000000000000..596222373a880bbc025625e6b8f2ef0cad90fa1a GIT binary patch literal 1505 zcmbVLiBpqT5YMR*>i}&T@IVo5q4pT8IAR?HC5RM_2-rGOD^sXNil`NBDJTL8a^n?H z`@TpZBoItc#2_Sugb)QAOH~j&IFs&Is((P=%(rjfzJ0s<`|WHVOg5hYrrygi&eAw1J)ZY5&wz9vuVNk7nd`D&N z)VwhDT2%77W ztSIXhb4PXe&2n)!m!=moO)`N&%rVJ?1BwPi15YnxTa+>=1CoOLPf^NrLiUiR1&&ZT zNI(G=KxAqXL!D3<*g=907(s`tIr&f}pL=Du+vgO-6A2l{|yBZfObfMAVL_B zLKeUXh#)4wfhP7!xX>LSA6`K>4N?Kp6sVj_GdJ-1WCFkrF%3dieL?oA__$6+9Y6q9 z^&$@R&M0EnR!(oA9wuvd|Fde_!~4tg#7QXvM77L;D-6R~6!$3f;n6;Da$LUHE$3Kb z<=OOZ4wY7%yJE@Wtiy-u^Rta&ww}+pdL~uJr|U?aZcs;cas(+7_W!%^-fb!`EZQMw zD%$~r!Nok8wH`p@H`@H%s;05WU(dYyMb|T9v&~FSOixWCYjVamJJU6-MJJ8h#*)x3 z<+ImZ(D?PrDWxwSjlo4f9*V|cuoyztL?!`)#jjQT$&83u55@V;|0X@9!7y z#SP+1^$ZMvRel9Q4d9E4r|N5I9C8Ef9xZ zO2O5I`&DKUN{?r{MmYIk{SO5M%UZXQs+`@zf;|fVti-RmKA2ehYTOl9`H3Rxwkk{# zsJfG~tn}B90;)La&A!$>`t_RcJ(RDO?udCB5L1WWa3oYTB3?LRj-@a2ac@3c`dqr) z&pQ%J+SJ_Q*}1CeJD-;E2~8w>6FLwtuJTWR5pg+c%f1^ghXvN1+Gr|fs5IGCo;osR zaZb4s)Ky%y*)@G~xH<3!|+gd zzxFBTY$V*R*sy|5mV2za^!Z*fA!D8*zf>JWCTAo|ni5mC2jRY@x5+qz%|k zKHHSsZAl(B)&8B4q?qsMzWM#G+4$nh#@|z2Ph;%l%!1f2V&uqRhX#{~%&jq*{{YR{ BP6z-1 literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/icons/general/AnnotaterLarge@2x.png b/src/main/resources/assets/icons/general/AnnotaterLarge@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..422a6e0addd40252e7fff1da3d68c6a24e49cf34 GIT binary patch literal 3192 zcmV-;42ScHP)Nklz(_KS5Ke3)ISCIL8;lbY98*qGkd$4fa8i}TaZ(Bpk=UtJ zoH!<6FnJWjxD17fV^csmF5@D$iEIoANnk8t>+R^ZT4^P;kJ+8M&wk~{+}T~pfM#cB zMw_a9e|GCu-|jwNpL4!*d%6j2#Mu7bsEoW0;kfYfc}$LuIQh~78I;H2(5(?CwD@QFa#g^~od%Ad>_O&+A9m!0NWmk+q{!6^vtO+l%=6-+mfFc^oo{ z`Ow}y41nZ<=a~?nlS%RuYs^*FnDuFr>|~sOmOCN`Ko0HR&C)31LAi`g4?l#VmtTZR z;Ii( zNDyOpq)~Lh7;`IXE38pl^|u<6Wo4M1CCep9B&UM9rd zjEg(~b#&J*CgYe_$Hwsan{LF=%P-EV^M-KnhtGmL4ofpxn;aRzf<=pNHClgehOEuS z-5bTsuoz~X|5asl^ed&_UdSYwlFgM@U)NA)4SRsr%3+Kd0E&pNEcWzl(Mmm%8819i1Q0Q4tz9e}=POFe&DNO9@#eDD zZk#EbSEw}IA{^&YSoDKPwwwUY`Clq2_ZwqAmJQs9thF1{vI$sY%q=oa_H)iRww!=q zqI_2xMPLZ>5m6sYCJAJkz#3aOyawBNLT*~N>XV4GjSDZAhj3k30B9o-L@aXOAK!b0VXq!WfQ7TEQvzMGz$yQ0|B!qf(Wn{Dotzd zZcL_ht*y73Ccs*Yoaez>i!hEM_;lhWB3NS(RVxrl3^$jDa2l7*8M1~<)>>|O9Zgkn z)>`4g-X+!(ao=V!!I^n#~W)Q zM9uB*`tqZAb=Phbayb}d-joPyE#fdhI5`28rkQrvyd(|+q;U+iILVz2P2~V0f>IjG zmoAxlW-S0ZU4X5%FxG-IcG|mBvgvtXj6+EYLi^(Ut}TQ zs#=x?O&4RYK>>9`e3FC7aWouyeBNEg=8^7-I;+5UG^N`QEh5Wo};$ zBYgHtkKm?1zY}{79E9hdDTNlZSghHg{Yh{p2}JbstBdL4XUD$%c;@-dDEK+(H%XE$ zM*Bmc4sFcO6*xqMBu%m6{AF0X`jRQ4<|F1*3KM{?QW4cKnkrT61W2XOx2PA7f8j2C z@$sin@O>z)r^@E}f~E-2#-OWI!eih37LK19!G@201mGNLJ)_r2kP=EMeDWhVBZy*D zf}m5L(B2$ivm`fB4e+HW{}Uhn)7x=$_ypWD>is$y zyZ;_v|Mqv_aE@X=hd4 zY`piMV2r`SQW2i#;ivn4hMRA@6F=Iy3pv;A*!YEi!80#x#(UST znQAvUd=i55PI*D+DnTMbl%)97AO1c*aK-cquIFBP6;J>01>C>!HUNN}+TH00+W))2 zOahcrSaJR`JoK4QqG|^u`sHBgUS?^g+q`QQ1zx*2c69fFQ)zF7_AvBxK>IXJ9Nmp zp5Jh%v#QljZr_5j;bFL*kA*$m7#=(-x)=4T(brGG^ZgYvP46+r{Lb`BfuC&*F3x(V zB1^bd00hR^6OQX{Oyc-RZ+{=gPmPGBE0(MB$OzInM%Usc=$pmOk*12(BhNpJ$?`Zj zi z=4|pqy{$vU#yIDHGIa3Z8f)$Mt+m2h%K*sg#H4jR510PNhnq6KX#(oF=(-y)uy!rH zoR16MzS0~&d_(~dJGN|vG3J4Mv3L<Vn1?ffV%@v3@_oNH7ZY0dnLf1jIZTXWsX#+WBASh;e5bN)9( z6xIv}C!&CH_Sef-4GdUgo-!trHLcajp&{#f9xi_0buBj@X!B0h$?e-PHhdhO@1wWB z4}<@6K=k(asZ+xz;Q9V?nWkT6To^Cs|3Mr^gMG`EVr2M)Sh0GQdi~gOBvFK(#Y?g1 eo$J~*$NvRXG6F2qhxVud0000. +--> + + + +Opens an editor for the annotater Javadoc of a Minecraft class, field or method. Annotater Javadocs are written as Markdown documentation comments (///) and saved to your annotater checkout. + + diff --git a/src/main/resources/messages/MinecraftDevelopment.properties b/src/main/resources/messages/MinecraftDevelopment.properties index e3c9242b9..37ce3d450 100644 --- a/src/main/resources/messages/MinecraftDevelopment.properties +++ b/src/main/resources/messages/MinecraftDevelopment.properties @@ -400,3 +400,90 @@ live.template.bukkit.event_handler.description=Create an event handler update.outdated_ide.title=IDE version no longer supported update.outdated_ide.content=Minecraft Development has dropped support for {0}. To receive further updates to Minecraft Development, please update your IDE. + + +fabric.annotater.settings.display_name=Annotater +fabric.annotater.settings.title=Annotater +fabric.annotater.settings.enabled=Enable annotater Javadoc editing +fabric.annotater.settings.enabled.comment=Edit Minecraft Javadocs for annotater with the Edit Annotater Javadoc action on Minecraft classes, fields and methods, or the annotater gutter icon on declarations that already have an annotater Javadoc. +fabric.annotater.settings.show_javadocs=Show annotater Javadocs instead of existing Minecraft Javadocs +fabric.annotater.settings.show_javadocs.comment=Applies to quick documentation, decompiled classes and attached Minecraft sources. Rendered doc comments (reader mode) are turned off in Minecraft classes. +fabric.annotater.settings.managed=Clone annotater automatically +fabric.annotater.settings.managed_root=Clone into\: +fabric.annotater.settings.clone_now=Clone or Update Annotater +fabric.annotater.settings.clone_now.version=Minecraft version to clone annotater for\: +fabric.annotater.settings.clone_now.no_versions=No Minecraft versions could be found in the open projects. Refresh the Gradle project, or set the Minecraft version in the Minecraft module settings. +fabric.annotater.settings.no_git=The Git plugin is required to clone annotater. +fabric.annotater.settings.custom=Use an existing annotater directory\: +fabric.annotater.settings.custom.comment=The plugin never runs git in this directory. Commit, push and open pull requests yourself. +fabric.annotater.settings.custom.version_mismatch=This annotater directory is for Minecraft {0}, but the open projects use Minecraft {1}. + +fabric.annotater.unavailable=Annotater unavailable +fabric.annotater.configure=Configure Annotater +fabric.annotater.clone=Clone annotater for {0} +fabric.annotater.cloned=Annotater for {0} is ready +fabric.annotater.cloned.content=Annotater is checked out in {0}. +fabric.annotater.clone_failed=Failed to clone annotater +fabric.annotater.cloning=Cloning annotater for {0} +fabric.annotater.cloning.clone=Cloning annotater... +fabric.annotater.cloning.fetch=Fetching annotater... +fabric.annotater.cloning.worktree=Checking out annotater... +fabric.annotater.no_branch=Annotater has no branch for Minecraft {0}. +fabric.annotater.invalid_after_clone=The annotater checkout at {0} is not valid. +fabric.annotater.uncommitted_changes=The annotater checkout has uncommitted changes. +fabric.annotater.preparing=Preparing annotater checkout +fabric.annotater.preparing.failed=Failed to prepare the annotater checkout +fabric.annotater.create_pull_request=Create Pull Request... +fabric.annotater.dropdown={0} \u25BE +fabric.annotater.menu=Annotater +fabric.annotater.contribution.tooltip=Switch to another contribution, for example to update a pull request after review +fabric.annotater.contribution.switch=Switch Contribution +fabric.annotater.contribution.new=New contribution +fabric.annotater.contribution.switching=Switching annotater contribution +fabric.annotater.contribution.failed=Failed to switch annotater contribution +fabric.annotater.contribution.save_failed=Fix the errors in the open annotater editors before switching contribution. +fabric.annotater.contribution.commit=The changes to "{0}" will be committed before switching. Commit message\: +fabric.annotater.status.disabled=Annotater is disabled in the Minecraft Development settings. +fabric.annotater.status.unknown_version=The Minecraft version of this project is unknown. +fabric.annotater.status.not_cloned=Annotater has not been cloned for Minecraft {0} yet. +fabric.annotater.status.not_cloned_no_git=Annotater has not been cloned for Minecraft {0}. Install the Git plugin, or select an existing annotater directory. +fabric.annotater.status.version_mismatch=The selected annotater directory is for Minecraft {0}, but this project uses Minecraft {1}. +fabric.annotater.status.invalid=Invalid annotater directory\: {0} + +fabric.annotater.javadoc.edit=Edit Annotater Javadoc +fabric.annotater.javadoc.open_failed=Failed to open annotater Javadoc +fabric.annotater.javadoc.read_only=Only the Javadoc comment can be edited. +fabric.annotater.banner=Annotater Javadocs for Minecraft {0} from {1}. Use Edit Annotater Javadoc on a class, field or method to edit its Javadoc. Changes are saved automatically. +fabric.annotater.javadoc.finish=Finish editing +fabric.annotater.javadoc.save_failed=Failed to save annotater Javadoc +fabric.annotater.javadoc.error.not_markdown=Annotater only supports Markdown Javadoc comments (///). Changes were not saved. +fabric.annotater.javadoc.error.plain_comment=Only Markdown Javadoc comments (///) can be saved to annotater. Changes were not saved. +fabric.annotater.javadoc.error.structure_changed=The declarations in the copy of the source file changed. Changes were not saved. +fabric.annotater.javadoc.error.io=Failed to save annotater Javadocs\: {0} + +fabric.annotater.inspection.unqualified.description=Reports classes referenced in annotater Javadocs without their fully qualified name, which annotater requires. +fabric.annotater.inspection.unqualified.problem=Annotater requires fully qualified class names, use ''{0}'' +fabric.annotater.inspection.unqualified.fix=Replace with ''{0}'' +fabric.annotater.inspection.unqualified.fix.family=Use fully qualified class name +fabric.annotater.inspection.comment.description=Reports comments in annotater Javadoc editors that are not Markdown Javadoc comments, which annotater does not support. + +fabric.annotater.pr.checking=Checking annotater changes +fabric.annotater.pr.nothing=Nothing to submit +fabric.annotater.pr.nothing.content=There are no annotater Javadoc changes for Minecraft {0} to submit. +fabric.annotater.pr.no_account=Log in to a GitHub.com account in the GitHub settings to create pull requests. +fabric.annotater.pr.failed=Failed to create annotater pull request +fabric.annotater.pr.no_base_branch={0} has no {1} branch to open a pull request against. +fabric.annotater.pr.creating=Creating annotater pull request +fabric.annotater.pr.committing=Committing changes... +fabric.annotater.pr.forking=Forking annotater... +fabric.annotater.pr.pushing=Pushing changes... +fabric.annotater.pr.opening=Opening pull request... +fabric.annotater.pr.created=Annotater pull request created +fabric.annotater.pr.open=Open in Browser +fabric.annotater.pr.start_new=Start New Contribution +fabric.annotater.pr.dialog.ok=Create Pull Request +fabric.annotater.pr.dialog.account=GitHub account\: +fabric.annotater.pr.dialog.title=Title\: +fabric.annotater.pr.dialog.description=Description\: +fabric.annotater.pr.dialog.commit_message=Commit message\: +fabric.annotater.pr.dialog.changes=Changed files\:
{0} diff --git a/src/test/kotlin/platform/fabric/annotater/AnnotaterJavadocEditorTest.kt b/src/test/kotlin/platform/fabric/annotater/AnnotaterJavadocEditorTest.kt new file mode 100644 index 000000000..865828f04 --- /dev/null +++ b/src/test/kotlin/platform/fabric/annotater/AnnotaterJavadocEditorTest.kt @@ -0,0 +1,403 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.platform.fabric.annotater + +import com.demonwav.mcdev.framework.BaseMinecraftTest +import com.demonwav.mcdev.framework.EdtInterceptor +import com.demonwav.mcdev.platform.fabric.annotater.javadoc.AnnotaterJavadocInlineEditor +import com.demonwav.mcdev.platform.fabric.annotater.javadoc.AnnotaterJavadocIntentionActionFilter +import com.demonwav.mcdev.platform.fabric.annotater.javadoc.AnnotaterJavadocRendering +import com.demonwav.mcdev.platform.fabric.annotater.javadoc.AnnotaterJavadocSourceCopy +import com.demonwav.mcdev.platform.fabric.annotater.javadoc.AnnotaterJavadocTarget +import com.demonwav.mcdev.platform.fabric.annotater.javadoc.AnnotaterJavadocUnqualifiedReferenceInspection +import com.demonwav.mcdev.platform.fabric.annotater.javadoc.AnnotaterJavadocVirtualFile +import com.intellij.codeInsight.intention.IntentionAction +import com.intellij.ide.DataManager +import com.intellij.ide.impl.HeadlessDataManager +import com.intellij.codeInsight.lookup.Lookup +import com.intellij.lang.annotation.HighlightSeverity +import com.intellij.openapi.actionSystem.PlatformCoreDataKeys +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.command.undo.UndoManager +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.fileEditor.TextEditor +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.TextRange +import com.intellij.psi.JavaPsiFacade +import com.intellij.psi.PsiClass +import com.intellij.psi.PsiFile +import com.intellij.psi.PsiMember +import com.intellij.psi.PsiMethod +import com.intellij.psi.search.GlobalSearchScope +import java.nio.file.Path +import java.util.concurrent.TimeUnit +import kotlin.io.path.readText +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.io.TempDir + +@ExtendWith(EdtInterceptor::class) +@DisplayName("Annotater Javadoc Editor Tests") +class AnnotaterJavadocEditorTest : BaseMinecraftTest() { + @TempDir + lateinit var root: Path + + private lateinit var checkout: AnnotaterCheckout + + private val clamp = AnnotaterJavadocTarget.MethodTarget("net/minecraft/util/Mth", "clamp", "(III)I") + + @BeforeEach + fun setUpProject() { + checkout = AnnotaterCheckout(root, AnnotaterConfig("26.3", "mappings", null), managed = false) + buildProject { + java( + "net/minecraft/util/Mth.java", + """ + package net.minecraft.util; + + /** Existing class docs. */ + public class Mth { + /** Existing method docs. */ + public static int clamp(int value, int min, int max) { + // Not clamped yet + return value; + } + + public static final float PI = 3.14F; + } + """.trimIndent(), + ) + java( + "net/minecraft/world/level/Level.java", + """ + package net.minecraft.world.level; + + public class Level { + } + """.trimIndent(), + ) + } + } + + private fun findMember(className: String, name: String? = null): PsiMember { + val clazz = JavaPsiFacade.getInstance(project).findClass(className, GlobalSearchScope.allScope(project))!! + return when (name) { + null -> clazz + else -> clazz.findMethodsByName(name, false).firstOrNull() ?: clazz.findFieldByName(name, false)!! + } + } + + private val pi = AnnotaterJavadocTarget.FieldTarget("net/minecraft/util/Mth", "PI", "F") + + private fun clampCopy(javadoc: String?, placeholderFor: AnnotaterJavadocTarget? = null) = + AnnotaterJavadocSourceCopy.create(findMember("net.minecraft.util.Mth", "clamp"), placeholderFor) { + if (it == clamp) javadoc else null + }!! + + @Test + @DisplayName("Source copy replaces the existing Javadocs with Markdown annotater Javadocs") + fun sourceCopyText() { + val copy = clampCopy("Clamps a value.\n\n@param value the value") + assertEquals( + """ + package net.minecraft.util; + + public class Mth { + /// Clamps a value. + /// + /// @param value the value + public static int clamp(int value, int min, int max) { + // Not clamped yet + return value; + } + + public static final float PI = 3.14F; + } + """.trimIndent(), + copy.text, + ) + assertEquals("Mth.java", copy.fileName) + assertEquals(listOf(AnnotaterJavadocTarget.ClassTarget("net/minecraft/util/Mth"), pi, clamp), copy.targets) + } + + @Test + @DisplayName("Source copy adds a placeholder Javadoc to the edited member") + fun sourceCopyPlaceholder() { + val copy = clampCopy(null, placeholderFor = clamp) + assertTrue(copy.text.contains("public class Mth {\n /// \n public static int clamp("), copy.text) + } + + @Test + @DisplayName("Javadocs are extracted from Markdown doc comments") + fun extract() { + val copy = clampCopy("Clamps a value.\n\n@param value the value") + val file = copy.createPsiFile(project)!! + val method = copy.memberOf(file, clamp) as PsiMethod + assertTrue(method.docComment!!.isMarkdownComment) + assertEquals(clamp, copy.targetOf(file, method)) + assertEquals( + AnnotaterJavadocSourceCopy.ExtractResult.Success( + mapOf( + AnnotaterJavadocTarget.ClassTarget("net/minecraft/util/Mth") to null, + pi to null, + clamp to "Clamps a value.\n\n@param value the value", + ), + ), + // The comment in the method body is part of the original source + copy.extractJavadocs(file) { it.substring(file.text) == "// Not clamped yet" }, + ) + } + + @Test + @DisplayName("Traditional Javadoc comments are rejected") + fun rejectTraditionalJavadoc() { + val copy = clampCopy("Clamps a value.") + val changed = AnnotaterJavadocSourceCopy( + copy.fileName, + copy.text.replace("/// Clamps a value.", "/** Clamps a value. */"), + copy.targets, + ) + val result = changed.extractJavadocs(changed.createPsiFile(project)!!) + assertInstanceOf(AnnotaterJavadocSourceCopy.ExtractResult.Error::class.java, result) + } + + private fun startEditing(copy: AnnotaterJavadocSourceCopy, target: AnnotaterJavadocTarget) = + AnnotaterJavadocInlineEditor.start( + project, + AnnotaterJavadocVirtualFile(checkout, copy, target), + )!! + + @Test + @DisplayName("Editing the Javadoc saves it to the annotater checkout") + fun save() { + val inlineEditor = startEditing(clampCopy(null, placeholderFor = clamp), clamp) + val document = FileDocumentManager.getInstance().getDocument(inlineEditor.file)!! + try { + WriteCommandAction.runWriteCommandAction(project) { + val offset = document.text.indexOf("/// ") + 4 + document.insertString(offset, "Clamps a value.") + } + assertTrue(inlineEditor.save().get(10, TimeUnit.SECONDS)) + assertEquals( + "CLASS net/minecraft/util/Mth\n\tMETHOD clamp (III)I\n\t\tCOMMENT Clamps a value.\n", + root.resolve("mappings/net/minecraft/util/Mth.mapping").readText(), + ) + } finally { + Disposer.dispose(inlineEditor) + } + } + + @Test + @DisplayName("Only the edited Javadoc is editable") + fun guards() { + val inlineEditor = startEditing(clampCopy("Clamps a value."), clamp) + val document = FileDocumentManager.getInstance().getDocument(inlineEditor.file)!! + try { + val text = document.text + fun guarded(start: Int, end: Int) = document.getRangeGuard(start, end) != null + val comment = text.indexOf("/// Clamps a value.") + val commentEnd = comment + "/// Clamps a value.".length + assertFalse(guarded(comment, commentEnd)) + assertTrue(guarded(commentEnd, commentEnd + 1), "The line break after the comment is read-only") + assertTrue(guarded(text.indexOf("return value"), text.indexOf("return value") + 1)) + assertTrue(guarded(text.indexOf("PI"), text.indexOf("PI") + 1)) + } finally { + Disposer.dispose(inlineEditor) + } + } + + @Test + @DisplayName("Saving doesn't overwrite Javadocs of other members that changed on disk") + fun saveOnlyEdited() { + checkout.javadocStore.setAll(mapOf(pi to "Pi.")) + val copy = AnnotaterJavadocSourceCopy.create(findMember("net.minecraft.util.Mth", "clamp"), clamp) { + checkout.javadocStore.get(it) + }!! + val inlineEditor = startEditing(copy, clamp) + val document = FileDocumentManager.getInstance().getDocument(inlineEditor.file)!! + try { + checkout.javadocStore.setAll(mapOf(pi to "Changed on disk.")) + WriteCommandAction.runWriteCommandAction(project) { + document.insertString(document.text.indexOf("/// \n public static int") + 4, "Clamps a value.") + } + assertTrue(inlineEditor.save().get(10, TimeUnit.SECONDS)) + checkout.javadocStore.invalidate(clamp.outerClass) + assertEquals("Clamps a value.", checkout.javadocStore.get(clamp)) + assertEquals("Changed on disk.", checkout.javadocStore.get(pi)) + } finally { + Disposer.dispose(inlineEditor) + } + } + + @Test + @DisplayName("The Javadoc is edited in an editor embedded in the class, which only shows the Javadoc") + fun inlineEditor() { + val method = findMember("net.minecraft.util.Mth", "clamp") + fixture.openFileInEditor(method.containingFile.virtualFile) + val host = fixture.editor as EditorEx + val inlineEditor = startEditing(clampCopy("Clamps a value.\n\n@param value the value"), clamp) + try { + inlineEditor.show(host, AnnotaterJavadocRendering.displayOffset(method)) + val editor = inlineEditor.editor!! + val document = editor.document + // Only the 3 comment lines are visible + assertEquals(2, editor.offsetToVisualLine(document.textLength, false)) + assertEquals(0, editor.offsetToVisualLine(0, false)) + assertEquals( + document.text.indexOf("/// Clamps a value.") + "/// Clamps a value.".length, + editor.caretModel.offset, + ) + assertEquals(1, host.inlayModel.getBlockElementsInRange(0, host.document.textLength).size) + + // The end of the document is displayed at the end of the last comment line + editor.caretModel.moveToOffset(document.textLength) + val lastLine = document.text.indexOf("/// @param value the value") + "/// @param value the value".length + assertEquals(lastLine, editor.caretModel.offset) + editor.caretModel.moveToOffset(0) + val firstLine = document.getLineStartOffset(document.getLineNumber(document.text.indexOf("/// Clamps"))) + assertEquals(firstLine, editor.caretModel.offset) + + WriteCommandAction.runWriteCommandAction(project) { + document.insertString(lastLine, " Or not.") + } + val edited = document.text + // Undo uses the embedded editor, not the host editor it's embedded in + val dataManager = DataManager.getInstance() as HeadlessDataManager + HeadlessDataManager.fallbackToProductionDataManager(inlineEditor) + dataManager.setTestDataProvider(null, inlineEditor) + val fileEditor = DataManager.getInstance().getDataContext(editor.contentComponent) + .getData(PlatformCoreDataKeys.FILE_EDITOR) as TextEditor + assertSame(editor, fileEditor.editor) + val undoManager = UndoManager.getInstance(project) + assertTrue(undoManager.isUndoAvailable(fileEditor)) + undoManager.undo(fileEditor) + assertFalse(document.text.contains("Or not.")) + undoManager.redo(fileEditor) + assertEquals(edited, document.text) + + assertTrue(inlineEditor.finish()) + assertNull(AnnotaterJavadocInlineEditor.current(project)) + assertTrue(host.inlayModel.getBlockElementsInRange(0, host.document.textLength).isEmpty()) + } finally { + Disposer.dispose(inlineEditor) + } + } + + @Test + @DisplayName("Editing can't be finished while the Javadoc can't be saved") + fun finishInvalid() { + val inlineEditor = startEditing(clampCopy("Clamps a value."), clamp) + val document = FileDocumentManager.getInstance().getDocument(inlineEditor.file)!! + try { + WriteCommandAction.runWriteCommandAction(project) { + val start = document.text.indexOf("/// Clamps a value.") + document.replaceString(start, start + "/// Clamps a value.".length, "/** Clamps a value. */") + } + assertFalse(inlineEditor.finish()) + assertSame(inlineEditor, AnnotaterJavadocInlineEditor.current(project)) + assertTrue(inlineEditor.finish(force = true)) + assertNull(AnnotaterJavadocInlineEditor.current(project)) + } finally { + Disposer.dispose(inlineEditor) + } + } + + @Test + @DisplayName("Intentions are only offered in the editable Javadocs") + fun intentionFilter() { + fixture.openFileInEditor(AnnotaterJavadocVirtualFile(checkout, clampCopy("Clamps a value."), clamp)) + val file = fixture.file + val text = fixture.editor.document.text + val intention = object : IntentionAction { + override fun getText() = "Test" + override fun getFamilyName() = text + override fun isAvailable(project: Project, editor: Editor?, file: PsiFile?) = true + override fun invoke(project: Project, editor: Editor?, file: PsiFile?) {} + override fun startInWriteAction() = true + } + val filter = AnnotaterJavadocIntentionActionFilter() + assertTrue(filter.accept(intention, file, text.indexOf("Clamps"))) + assertFalse(filter.accept(intention, file, text.indexOf("return value"))) + assertFalse(filter.accept(intention, file, text.indexOf("// Not clamped"))) + assertFalse(filter.accept(intention, file, -1)) + } + + @Test + @DisplayName("Unqualified class references are reported and fixed") + fun unqualifiedReferences() { + val copy = clampCopy( + "See [Mth], [net.minecraft.util.Mth#clamp], [#clamp] and [String].\n\n@see net.minecraft.world.level.Level", + ) + fixture.enableInspections(AnnotaterJavadocUnqualifiedReferenceInspection::class) + fixture.openFileInEditor(AnnotaterJavadocVirtualFile(checkout, copy, clamp)) + + val warnings = fixture.doHighlighting(HighlightSeverity.WARNING) + .filter { it.description?.contains("fully qualified") == true } + .map { fixture.editor.document.getText(TextRange(it.startOffset, it.endOffset)) } + assertEquals(listOf("Mth", "String"), warnings) + + fixture.editor.caretModel.moveToOffset(fixture.editor.document.text.indexOf("[Mth]") + 2) + // launchAction can't be used, since it tries to make the light file read only on disk + val fix = fixture.findSingleIntention("Replace with 'net.minecraft.util.Mth'") + WriteCommandAction.runWriteCommandAction(project) { fix.invoke(project, fixture.editor, fixture.file) } + assertTrue(fixture.editor.document.text.contains("See [net.minecraft.util.Mth], ")) + } + + @Test + @DisplayName("Class name completion inserts fully qualified names") + fun completion() { + val copy = clampCopy("See [Lev].") + fixture.openFileInEditor(AnnotaterJavadocVirtualFile(checkout, copy, clamp)) + fixture.editor.caretModel.moveToOffset(fixture.editor.document.text.indexOf("Lev]") + 3) + val lookup = fixture.completeBasic() + // java.util.logging.Level is also offered + val level = lookup.single { (it.psiElement as? PsiClass)?.qualifiedName == "net.minecraft.world.level.Level" } + fixture.lookup.currentItem = level + fixture.finishLookup(Lookup.NORMAL_SELECT_CHAR) + assertTrue( + fixture.editor.document.text.contains("/// See [net.minecraft.world.level.Level]."), + fixture.editor.document.text, + ) + } + + @Test + @DisplayName("Quick documentation shows the annotater Javadoc instead of the existing one") + fun quickDocumentation() { + val clampMethod = findMember("net.minecraft.util.Mth", "clamp") + val doc = AnnotaterJavadocRendering.generateDoc(clampMethod, "Clamps a **value**.")!! + assertTrue("Clamps a value." in doc || "Clamps a value." in doc, doc) + assertFalse("Existing method docs" in doc, doc) + + val hidden = AnnotaterJavadocRendering.generateDoc(clampMethod, null)!! + assertFalse("Existing method docs" in hidden, hidden) + } +} diff --git a/src/test/kotlin/platform/fabric/annotater/AnnotaterJavadocStoreTest.kt b/src/test/kotlin/platform/fabric/annotater/AnnotaterJavadocStoreTest.kt new file mode 100644 index 000000000..0328db7d0 --- /dev/null +++ b/src/test/kotlin/platform/fabric/annotater/AnnotaterJavadocStoreTest.kt @@ -0,0 +1,141 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.platform.fabric.annotater + +import com.demonwav.mcdev.platform.fabric.annotater.javadoc.AnnotaterJavadocStore +import com.demonwav.mcdev.platform.fabric.annotater.javadoc.AnnotaterJavadocTarget.ClassTarget +import com.demonwav.mcdev.platform.fabric.annotater.javadoc.AnnotaterJavadocTarget.FieldTarget +import com.demonwav.mcdev.platform.fabric.annotater.javadoc.AnnotaterJavadocTarget.MethodTarget +import java.nio.file.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.exists +import kotlin.io.path.readText +import kotlin.io.path.writeText +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir + +@DisplayName("Annotater Javadoc Store Tests") +class AnnotaterJavadocStoreTest { + @TempDir + lateinit var mappings: Path + + private val mth = "net/minecraft/util/Mth" + + // The only file on annotater's 26.3 branch at the time of writing + private val mthMapping = """ + |CLASS net/minecraft/util/Mth + | COMMENT Math utilities. + | METHOD clamp (III)I + | COMMENT Clamps `value` between `min` and `max` (inclusive). + | COMMENT + | COMMENT @param value the value to clamp + | COMMENT @param min the minimum value + | COMMENT @param max the maximum value + | COMMENT @return the clamped value + | + """.trimMargin() + + private fun writeMth(): Path { + val file = mappings.resolve("$mth.mapping") + file.parent.createDirectories() + file.writeText(mthMapping) + return file + } + + @Test + @DisplayName("Reads class and method comments") + fun read() { + writeMth() + val store = AnnotaterJavadocStore(mappings) + assertEquals("Math utilities.", store.get(ClassTarget(mth))) + assertEquals( + """ + Clamps `value` between `min` and `max` (inclusive). + + @param value the value to clamp + @param min the minimum value + @param max the maximum value + @return the clamped value + """.trimIndent(), + store.get(MethodTarget(mth, "clamp", "(III)I")), + ) + assertNull(store.get(MethodTarget(mth, "clamp", "(FFF)F"))) + } + + @Test + @DisplayName("Rewriting unchanged comments produces an identical file") + fun roundTrip() { + val file = writeMth() + val store = AnnotaterJavadocStore(mappings) + store.set(ClassTarget(mth), store.get(ClassTarget(mth))) + assertEquals(mthMapping, file.readText()) + } + + @Test + @DisplayName("Adding a comment keeps the rest of the file") + fun addComment() { + val file = writeMth() + val store = AnnotaterJavadocStore(mappings) + store.set(FieldTarget(mth, "PI", "F"), "Pi as a float.") + assertEquals( + mthMapping.replace("\tMETHOD clamp", "\tFIELD PI F\n\t\tCOMMENT Pi as a float.\n\tMETHOD clamp"), + file.readText(), + ) + assertEquals("Pi as a float.", AnnotaterJavadocStore(mappings).get(FieldTarget(mth, "PI", "F"))) + } + + @Test + @DisplayName("Removing the last comment deletes the file") + fun removeComments() { + val file = writeMth() + val store = AnnotaterJavadocStore(mappings) + store.set(MethodTarget(mth, "clamp", "(III)I"), null) + assertEquals("CLASS net/minecraft/util/Mth\n\tCOMMENT Math utilities.\n", file.readText()) + store.set(ClassTarget(mth), "") + assertFalse(file.exists()) + } + + @Test + @DisplayName("Inner classes are stored in their outer class's file") + fun innerClass() { + val store = AnnotaterJavadocStore(mappings) + val outer = "net/minecraft/world/level/Level" + store.set(ClassTarget("$outer\$Inner"), "An inner class.") + store.set(ClassTarget(outer), "The level.") + // Enigma nests inner classes inside their outer class + assertEquals( + "CLASS $outer\n\tCOMMENT The level.\n\tCLASS Inner\n\t\tCOMMENT An inner class.\n", + mappings.resolve("$outer.mapping").readText(), + ) + assertEquals("An inner class.", AnnotaterJavadocStore(mappings).get(ClassTarget("$outer\$Inner"))) + } + + @Test + @DisplayName("Outer class names") + fun outerClassName() { + assertEquals("a/B", ClassTarget("a/B\$C\$D").outerClass) + assertEquals("a/\$B", ClassTarget("a/\$B").outerClass) + } +} From 31ff07e672a89f38cf405960582f13213d9af620 Mon Sep 17 00:00:00 2001 From: joe Date: Wed, 11 Nov 2026 02:43:34 +0000 Subject: [PATCH 2/5] Apply review --- annotater-review.md | 28 --- gradle/libs.versions.toml | 2 +- src/main/kotlin/asset/GeneralAssets.kt | 4 +- .../fabric/annotater/AnnotaterCheckout.kt | 2 +- .../annotater/AnnotaterCheckoutService.kt | 18 +- .../fabric/annotater/AnnotaterConfig.kt | 18 +- .../fabric/annotater/AnnotaterConfigurable.kt | 52 ++++-- .../annotater/AnnotaterNotifications.kt | 4 +- .../platform/fabric/annotater/AnnotaterVcs.kt | 25 ++- .../fabric/annotater/git/AnnotaterGitVcs.kt | 165 +++++++++++++++++- .../annotater/github/AnnotaterBuildCheck.kt | 123 +++++++++++++ .../github/AnnotaterGitHubPullRequests.kt | 73 ++++++-- .../github/AnnotaterPullRequestDialog.kt | 37 +++- .../AnnotaterJavadocCommentInspection.kt | 18 +- .../javadoc/AnnotaterJavadocContributions.kt | 95 ++++++++-- .../AnnotaterJavadocDocumentationProvider.kt | 3 +- .../javadoc/AnnotaterJavadocEditing.kt | 56 +++--- ...otaterJavadocEditorNotificationProvider.kt | 15 +- .../AnnotaterJavadocInlayPassFactory.kt | 43 ++--- .../javadoc/AnnotaterJavadocInlayRenderer.kt | 19 +- .../javadoc/AnnotaterJavadocInlineEditor.kt | 71 ++++---- .../javadoc/AnnotaterJavadocRefresher.kt | 4 +- .../javadoc/AnnotaterJavadocRendering.kt | 22 --- .../javadoc/AnnotaterJavadocSourceCopy.kt | 52 +++--- .../javadoc/AnnotaterJavadocStore.kt | 53 ++++-- .../javadoc/AnnotaterJavadocTarget.kt | 62 +++++-- ...erJavadocUnqualifiedReferenceInspection.kt | 4 +- .../javadoc/AnnotaterJavadocVirtualFile.kt | 4 +- .../javadoc/EditAnnotaterJavadocAction.kt | 4 +- .../javadoc/EditAnnotaterJavadocIntention.kt | 2 +- src/main/kotlin/util/McdevReflection.kt | 34 ++++ src/main/resources/META-INF/plugin.xml | 2 +- ...er.LICENSE.txt => tiny_potato.LICENSE.txt} | 2 +- .../{Annotater.png => tiny_potato.png} | Bin .../{Annotater@2x.png => tiny_potato@2x.png} | Bin ...notaterLarge.png => tiny_potato_large.png} | Bin ...rLarge@2x.png => tiny_potato_large@2x.png} | Bin .../after.java.template | 6 + .../before.java.template | 3 + .../messages/MinecraftDevelopment.properties | 23 ++- .../annotater/AnnotaterJavadocEditorTest.kt | 42 ++++- 41 files changed, 873 insertions(+), 317 deletions(-) delete mode 100644 annotater-review.md create mode 100644 src/main/kotlin/platform/fabric/annotater/github/AnnotaterBuildCheck.kt rename src/main/resources/assets/icons/general/{Annotater.LICENSE.txt => tiny_potato.LICENSE.txt} (66%) rename src/main/resources/assets/icons/general/{Annotater.png => tiny_potato.png} (100%) rename src/main/resources/assets/icons/general/{Annotater@2x.png => tiny_potato@2x.png} (100%) rename src/main/resources/assets/icons/general/{AnnotaterLarge.png => tiny_potato_large.png} (100%) rename src/main/resources/assets/icons/general/{AnnotaterLarge@2x.png => tiny_potato_large@2x.png} (100%) create mode 100644 src/main/resources/intentionDescriptions/EditAnnotaterJavadocIntention/after.java.template create mode 100644 src/main/resources/intentionDescriptions/EditAnnotaterJavadocIntention/before.java.template diff --git a/annotater-review.md b/annotater-review.md deleted file mode 100644 index cd14dc54d..000000000 --- a/annotater-review.md +++ /dev/null @@ -1,28 +0,0 @@ -- Add a way to delete a contribution (with an "are you sure" dialog). Only delete it locally, don't touch the remotes or any pull request. -- Add a checkbox in the pull request dialog (on by default) to check the build before creating the pull request. This is done by running `./gradlew check` in the annotater directory. This can take a minute or so to run, so a progress dialog is required. If the check fails, the user should be presented with a dialog containing the failure log, with options to cancel (default) or submit anyway. -- If a pull request has already been created for a contribution, the "create pull request" button should be replaced with an "update pull request" button, which should only appear if there are modifications to be pushed (just as "create pull request" should). -- When you open a pull request, why do you browse to it and then add a button to browse to it? I would just leave it at the button. -- `AnnotaterJavadocCommentInspection.findMarkdownMigrationFixes` should instead use reflection to construct the fix. Put the reference to the constructor in `McdevReflection`. -- The method names in `AnnotaterJavadocContributions`: `switchTo`, `checkUncommittedChanges`, and `switch` aren't clear enough, especially `checkUncommittedChanges` doesn't really describe that it does the switch afterwards. -- The `DocumentationProvider` docs recommend migrating to `DocumentationTarget`. Evaluate whether we can do that for `AnnotaterJavadocDocumentationProvider`, and if we can, do it. -- `AnnotaterJavadocEditing.edit` can be renamed to an overload of `open` (can therefore move the methods into a more logical order). -- `AnnotaterJavadocEditing.openEditor` uses `ReadAction.compute`, which is deprecated. -- Rename the icon to `tiny_potato`. -- What is `AnnotaterJavadocInlayPassFactory.Pass.active` for? If it's a workaround for something, a comment would be nice. -- Companion objects inside extension point impl classes sometimes generate an intellij warning. Where these appear, change them to named objects, as we do in the rest of the project. -- Storing the inlay for `offset` in `AnnotaterJavadocInlayRenderer` looks like a bit of a hack. Is there precedent for this in IntelliJ's codebase? Why can't `initialOffset` always be used? -- `AnnotaterJavadocInlayRenderer` attempts to get proper Javadoc highlighting colors with the token thing, and while it does properly turn the text green, it doesn't highlight other things, like turning the color of referenced parameters. Fix this if it's an easy fix, otherwise tell me how you would fix it and wait for my approval before fixing it. -- `AnnotaterJavadocInlineEditor` uses `Alarm` which is obsolete. Follow the advice in the `Alarm` docs to replace it, if possible. If that's kinda complicated, then not saving until editing is finished is acceptable. -- Since it now should be difficult to accidentally edit readonly sections, the `ReadonlyFragmentModificationHandler` can be removed. -- The document modification guards should be not greedy at the end close to the edited javadoc, and greedy on the other end. -- In `AnnotaterJavadocInlineEditor.dispose`, it disposes the inlay directly. Isn't the normal solution to this to register the inlay with `Disposer.register` instead? -- Why does `AnnotaterJavadocInlineEditor.ProjectDisposable` exist, why not register the inline editor disposable directly with the project (which is itself disposable)? -- In `AnnotaterJavadocRefresher`, use `BulkFileListenerBackgroundable` instead. -- Why is `AnnotaterJavadocRendering.getJavadoc` in that class? I feel like that would fit better in another class. -- Make it so `AnnotaterJavadocSourceCopy.createVirtualFile` uses the language level of the file it's copied from, or Java 23 (the version Markdown Javadocs were introduced), whichever is newer. Same in `AnnotaterJavadocVirtualFile`. -- Why do we have both `AnnotaterJavadocSourceCopy.createVirtualFile` and `AnnotaterJavadocVirtualFile`? -- Local classes should be possible to document. Members within local and anonymous classes should also be possible to document. -- There's a lot of methods throughout this feature that are public but could be made private. -- The `EnigmaFileWriter` `VisitOrder` doesn't match what is used by annotater. Also you should update `mapping-io` in mcdev to the latest version. -- Fix the lint error on `EditAnnotaterJavadocIntention`. -- Find a built-in or library equivalent to `AnnotaterConfig.validatePath`, rather than writing it yourself. \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0994211a1..015cf580a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -39,7 +39,7 @@ changelog-plugin = { module = "org.jetbrains.changelog:org.jetbrains.changelog.g coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "coroutines" } -mappingIo = "net.fabricmc:mapping-io:0.8.0" +mappingIo = "net.fabricmc:mapping-io:0.9.2" mixinExtras-expressions = "io.github.llamalad7:mixinextras-expressions:0.0.6" jgraphx = "com.github.vlsi.mxgraph:jgraphx:4.2.2" diff --git a/src/main/kotlin/asset/GeneralAssets.kt b/src/main/kotlin/asset/GeneralAssets.kt index b9046bdb9..7a29b2622 100644 --- a/src/main/kotlin/asset/GeneralAssets.kt +++ b/src/main/kotlin/asset/GeneralAssets.kt @@ -26,6 +26,6 @@ object GeneralAssets : Assets() { val MC_TEMPLATE = loadIcon("/assets/icons/general/MinecraftTemplate.png") - val ANNOTATER = loadIcon("/assets/icons/general/Annotater.png") - val ANNOTATER_LARGE = loadIcon("/assets/icons/general/AnnotaterLarge.png") + val TINY_POTATO = loadIcon("/assets/icons/general/tiny_potato.png") + val TINY_POTATO_LARGE = loadIcon("/assets/icons/general/tiny_potato_large.png") } diff --git a/src/main/kotlin/platform/fabric/annotater/AnnotaterCheckout.kt b/src/main/kotlin/platform/fabric/annotater/AnnotaterCheckout.kt index bfaf4a324..6d9f3e4cc 100644 --- a/src/main/kotlin/platform/fabric/annotater/AnnotaterCheckout.kt +++ b/src/main/kotlin/platform/fabric/annotater/AnnotaterCheckout.kt @@ -34,5 +34,5 @@ class AnnotaterCheckout(val root: Path, val config: AnnotaterConfig, val managed val mappingsDir: Path = root.resolve(config.mappings) - val javadocStore = AnnotaterJavadocStore(mappingsDir) + val javadocStore = AnnotaterJavadocStore.forDirectory(mappingsDir) } diff --git a/src/main/kotlin/platform/fabric/annotater/AnnotaterCheckoutService.kt b/src/main/kotlin/platform/fabric/annotater/AnnotaterCheckoutService.kt index 3a81265f0..b86172f4b 100644 --- a/src/main/kotlin/platform/fabric/annotater/AnnotaterCheckoutService.kt +++ b/src/main/kotlin/platform/fabric/annotater/AnnotaterCheckoutService.kt @@ -22,6 +22,7 @@ package com.demonwav.mcdev.platform.fabric.annotater import com.demonwav.mcdev.facet.MinecraftFacet import com.demonwav.mcdev.platform.fabric.FabricModuleType +import com.demonwav.mcdev.platform.fabric.annotater.javadoc.AnnotaterJavadocStore import com.demonwav.mcdev.platform.minecraft.MinecraftModuleSettings import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.Service @@ -32,7 +33,6 @@ import com.intellij.openapi.roots.ProjectFileIndex import com.intellij.openapi.util.Key import com.intellij.openapi.vfs.VirtualFile import com.intellij.psi.PsiElement -import com.intellij.psi.util.PsiUtilCore import java.io.IOException import java.nio.file.Path import java.util.concurrent.ConcurrentHashMap @@ -52,6 +52,7 @@ class AnnotaterCheckoutService { object : AnnotaterListener { override fun checkoutsChanged() { checkouts.clear() + AnnotaterJavadocStore.invalidateAll() } }, ) @@ -69,7 +70,7 @@ class AnnotaterCheckoutService { /** * Finds the checkout for [version] according to the user's settings, ignoring whether the feature is enabled. */ - fun statusForVersion(version: String): Status { + private fun statusForVersion(version: String): Status { val settings = AnnotaterSettings.instance return when (settings.checkoutMode) { AnnotaterSettings.CheckoutMode.MANAGED -> { @@ -105,7 +106,7 @@ class AnnotaterCheckoutService { if (!AnnotaterSettings.instance.enabled) { return Status.Disabled } - val file = PsiUtilCore.getVirtualFile(element) ?: return Status.UnknownMinecraftVersion + val file = virtualFileOf(element) ?: return Status.UnknownMinecraftVersion val version = minecraftVersion(element.project, file) ?: return Status.UnknownMinecraftVersion return statusForVersion(version) } @@ -118,7 +119,7 @@ class AnnotaterCheckoutService { if (!AnnotaterSettings.instance.enabled) { return null } - val file = PsiUtilCore.getVirtualFile(element) ?: return null + val file = virtualFileOf(element) ?: return null return checkoutFor(element.project, file) } @@ -179,9 +180,16 @@ class AnnotaterCheckoutService { * prominently there. */ fun isFabricProject(project: Project): Boolean { - return ModuleManager.getInstance(project).modules.any { MinecraftFacet.getInstance(it, FabricModuleType) != null } + return ModuleManager.getInstance(project).modules.any { + MinecraftFacet.getInstance(it, FabricModuleType) != null + } } + /** + * The file containing [element]. Elements of a class file's decompiled text are in the class file. + */ + fun virtualFileOf(element: PsiElement): VirtualFile? = element.containingFile?.originalFile?.virtualFile + /** * Whether [file] is a class or source file from a Minecraft jar, as opposed to project sources or another * library. diff --git a/src/main/kotlin/platform/fabric/annotater/AnnotaterConfig.kt b/src/main/kotlin/platform/fabric/annotater/AnnotaterConfig.kt index 37d3c642a..4d2ef27f2 100644 --- a/src/main/kotlin/platform/fabric/annotater/AnnotaterConfig.kt +++ b/src/main/kotlin/platform/fabric/annotater/AnnotaterConfig.kt @@ -22,6 +22,7 @@ package com.demonwav.mcdev.platform.fabric.annotater import com.google.gson.Gson import com.google.gson.JsonParseException +import com.intellij.openapi.util.io.FileUtil import java.io.IOException import java.nio.file.Path import kotlin.io.path.exists @@ -58,23 +59,18 @@ data class AnnotaterConfig( val version = raw.version?.takeIf { it.isNotBlank() } ?: throw IOException("$FILE_NAME is missing \"version\"") - val mappings = validatePath(raw.mappings ?: throw IOException("$FILE_NAME is missing \"mappings\"")) - val unpick = raw.unpickDefinitions?.let(::validatePath) + val mappings = validatePath(root, raw.mappings ?: throw IOException("$FILE_NAME is missing \"mappings\"")) + val unpick = raw.unpickDefinitions?.let { validatePath(root, it) } return AnnotaterConfig(version, mappings, unpick) } /** - * Paths must be relative subdirectories using forward slashes, like mcsrc requires. + * Paths must be relative paths to subdirectories, like mcsrc requires. */ @Throws(IOException::class) - private fun validatePath(path: String): String { - val segments = path.split('/') - val valid = path.isNotEmpty() && - !path.startsWith('/') && - '\\' !in path && - ':' !in path && - segments.none { it.isEmpty() || it == "." || it == ".." } - if (!valid) { + private fun validatePath(root: Path, path: String): String { + val resolved = runCatching { root.resolve(path).normalize() }.getOrNull() + if (resolved == null || !FileUtil.isAncestor(root.normalize().toFile(), resolved.toFile(), true)) { throw IOException("Invalid path in $FILE_NAME: \"$path\"") } return path diff --git a/src/main/kotlin/platform/fabric/annotater/AnnotaterConfigurable.kt b/src/main/kotlin/platform/fabric/annotater/AnnotaterConfigurable.kt index 28b1948cd..870b42764 100644 --- a/src/main/kotlin/platform/fabric/annotater/AnnotaterConfigurable.kt +++ b/src/main/kotlin/platform/fabric/annotater/AnnotaterConfigurable.kt @@ -59,7 +59,11 @@ class AnnotaterConfigurable : Configurable { override fun createComponent(): JComponent = panel { row( - JLabel(MCDevBundle("fabric.annotater.settings.title"), GeneralAssets.ANNOTATER_LARGE, SwingConstants.LEADING) + JLabel( + MCDevBundle("fabric.annotater.settings.title"), + GeneralAssets.TINY_POTATO_LARGE, + SwingConstants.LEADING, + ) .apply { font = font.deriveFont(Font.BOLD, font.size * 1.5f) }, ) {}.bottomGap(BottomGap.MEDIUM) @@ -71,6 +75,9 @@ class AnnotaterConfigurable : Configurable { .comment(MCDevBundle("fabric.annotater.settings.enabled.comment")) } indent { + row { + text(contributionGuidelinesNote()) + } row { checkBox(MCDevBundle("fabric.annotater.settings.show_javadocs")) .bindSelected(settings::showAnnotaterJavadocs) @@ -162,6 +169,26 @@ class AnnotaterConfigurable : Configurable { return null } + /** + * A reminder to follow annotater's contribution guidelines, which can differ between version branches, linking to + * those of the versions used by open projects, or of the default branch if there are none. + */ + private fun contributionGuidelinesNote(): String { + fun link(branch: String, text: String) = + "$text" + + val versions = knownMinecraftVersions() + val links = when (versions.size) { + 0 -> link("HEAD", MCDevBundle("fabric.annotater.settings.guidelines.link")) + 1 -> link(versions.single(), MCDevBundle("fabric.annotater.settings.guidelines.link")) + else -> MCDevBundle( + "fabric.annotater.settings.guidelines.link.version", + versions.joinToString(", ") { link(it, it) }, + ) + } + return MCDevBundle("fabric.annotater.settings.guidelines", links) + } + private fun cloneOrUpdate(vcs: AnnotaterVcs) { val versions = knownMinecraftVersions() val version = when (versions.size) { @@ -173,6 +200,7 @@ class AnnotaterConfigurable : Configurable { ) return } + 1 -> versions.single() else -> Messages.showEditableChooseDialog( MCDevBundle("fabric.annotater.settings.clone_now.version"), @@ -209,18 +237,18 @@ class AnnotaterConfigurable : Configurable { override fun reset() = panel.reset() - companion object { - /** - * The Minecraft versions of all modules in open projects, as configured in their Minecraft module settings. - */ - fun knownMinecraftVersions(): List { - return ProjectManager.getInstance().openProjects.flatMap { project -> - ModuleManager.getInstance(project).modules.mapNotNull { - MinecraftModuleSettings.getInstance(it).state.minecraftVersion - } - }.filter { it.isNotBlank() }.distinct() - } + /** + * The Minecraft versions of all modules in open projects, as configured in their Minecraft module settings. + */ + private fun knownMinecraftVersions(): List { + return ProjectManager.getInstance().openProjects.flatMap { project -> + ModuleManager.getInstance(project).modules.mapNotNull { + MinecraftModuleSettings.getInstance(it).state.minecraftVersion + } + }.filter { it.isNotBlank() }.distinct() + } + object Util { fun show(project: Project?) { ShowSettingsUtil.getInstance().showSettingsDialog(project, AnnotaterConfigurable::class.java) } diff --git a/src/main/kotlin/platform/fabric/annotater/AnnotaterNotifications.kt b/src/main/kotlin/platform/fabric/annotater/AnnotaterNotifications.kt index ab4c2a8a4..30fac8eaa 100644 --- a/src/main/kotlin/platform/fabric/annotater/AnnotaterNotifications.kt +++ b/src/main/kotlin/platform/fabric/annotater/AnnotaterNotifications.kt @@ -29,7 +29,7 @@ import com.intellij.openapi.project.Project object AnnotaterNotifications { const val GROUP_ID = "Minecraft annotater" - fun describe(status: AnnotaterCheckoutService.Status): String = when (status) { + private fun describe(status: AnnotaterCheckoutService.Status): String = when (status) { is AnnotaterCheckoutService.Status.Available -> status.checkout.root.toString() AnnotaterCheckoutService.Status.Disabled -> MCDevBundle("fabric.annotater.status.disabled") AnnotaterCheckoutService.Status.UnknownMinecraftVersion -> @@ -64,7 +64,7 @@ object AnnotaterNotifications { } notification.addAction( NotificationAction.createSimpleExpiring(MCDevBundle("fabric.annotater.configure")) { - AnnotaterConfigurable.show(project) + AnnotaterConfigurable.Util.show(project) }, ) notification.notify(project) diff --git a/src/main/kotlin/platform/fabric/annotater/AnnotaterVcs.kt b/src/main/kotlin/platform/fabric/annotater/AnnotaterVcs.kt index 3ccbd2d91..2832f5708 100644 --- a/src/main/kotlin/platform/fabric/annotater/AnnotaterVcs.kt +++ b/src/main/kotlin/platform/fabric/annotater/AnnotaterVcs.kt @@ -73,6 +73,18 @@ interface AnnotaterVcs { indicator: ProgressIndicator, ) + /** + * Deletes the contribution [branch] from the managed [checkout]. Only the local branch is deleted, not any branch + * pushed for it or its pull request. If [branch] is checked out, its uncommitted changes are discarded and the + * upstream version branch is checked out instead. Must not be called on the EDT. + */ + fun deleteContribution( + project: Project?, + checkout: AnnotaterCheckout, + branch: String, + indicator: ProgressIndicator, + ) + companion object { val instance: AnnotaterVcs? get() = ApplicationManager.getApplication().getService(AnnotaterVcs::class.java) @@ -108,9 +120,18 @@ data class AnnotaterContribution(val branch: String, val title: String?) { * Creating pull requests to annotater. Only available when the GitHub plugin is installed. */ interface AnnotaterPullRequests { - fun canCreatePullRequest(checkout: AnnotaterCheckout): Boolean = checkout.managed + enum class Action { CREATE, UPDATE } + + /** + * Whether a pull request can be created or updated with the changes in [checkout], or null if there's nothing to + * submit. This is cheap enough to call from any thread, so it may be null until the changes have been looked up. + */ + fun availableAction(checkout: AnnotaterCheckout): Action? - fun createPullRequest(project: Project, checkout: AnnotaterCheckout) + /** + * Creates a pull request with the changes in [checkout], or pushes them to its existing pull request. + */ + fun submit(project: Project, checkout: AnnotaterCheckout) companion object { val instance: AnnotaterPullRequests? diff --git a/src/main/kotlin/platform/fabric/annotater/git/AnnotaterGitVcs.kt b/src/main/kotlin/platform/fabric/annotater/git/AnnotaterGitVcs.kt index c2e9ed054..ea3743575 100644 --- a/src/main/kotlin/platform/fabric/annotater/git/AnnotaterGitVcs.kt +++ b/src/main/kotlin/platform/fabric/annotater/git/AnnotaterGitVcs.kt @@ -36,6 +36,7 @@ import com.intellij.openapi.project.ProjectManager import com.intellij.openapi.vcs.VcsException import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.ui.EditorNotifications +import com.intellij.util.concurrency.AppExecutorUtil import git4idea.commands.Git import git4idea.commands.GitCommand import git4idea.commands.GitLineHandler @@ -68,6 +69,39 @@ class AnnotaterGitVcs : AnnotaterVcs { */ private val titledBranches = ConcurrentHashMap.newKeySet() + /** + * The [PushStatus] of each managed checkout by root, so that the editor banner can show it without running git. + */ + private val pushStatuses = ConcurrentHashMap() + + /** + * The checkouts whose [pushStatuses] have been requested, to refresh them when they change. + */ + private val pushStatusCheckouts = ConcurrentHashMap() + + /** + * The checkouts whose [pushStatuses] are waiting to be refreshed. + */ + private val pendingPushStatuses = ConcurrentHashMap.newKeySet() + + private val pushStatusExecutor = + AppExecutorUtil.createBoundedApplicationPoolExecutor("Annotater push status", 1) + + init { + ApplicationManager.getApplication().messageBus.connect().subscribe( + AnnotaterListener.TOPIC, + object : AnnotaterListener { + override fun checkoutsChanged() { + pushStatusCheckouts.values.forEach(::refreshPushStatus) + } + + override fun javadocsChanged(checkoutRoot: Path, outerClass: String) { + pushStatusCheckouts[checkoutRoot]?.let(::refreshPushStatus) + } + }, + ) + } + override fun cloneOrUpdate( project: Project?, version: String, @@ -118,7 +152,7 @@ class AnnotaterGitVcs : AnnotaterVcs { } @Throws(VcsException::class) - fun cloneOrUpdateNow(project: Project?, version: String, indicator: ProgressIndicator): AnnotaterCheckout { + private fun cloneOrUpdateNow(project: Project?, version: String, indicator: ProgressIndicator): AnnotaterCheckout { val gitProject = project ?: ProjectManager.getInstance().defaultProject synchronized(lock) { val root = AnnotaterSettings.instance.effectiveManagedRoot @@ -290,6 +324,85 @@ class AnnotaterGitVcs : AnnotaterVcs { EditorNotifications.updateAll() } + /** + * Whether the contribution checked out in a managed checkout has a pull request, and whether it has changes that + * haven't been pushed to it, or to a new pull request if it doesn't have one. + */ + data class PushStatus(val pullRequestUrl: String?, val hasUnpushedChanges: Boolean) + + /** + * The [PushStatus] of the managed [checkout], or null if it isn't known yet, in which case it's looked up in the + * background and the editor banners are updated. This is cheap enough to call from any thread. + */ + fun pushStatus(checkout: AnnotaterCheckout): PushStatus? { + if (!checkout.managed) { + return null + } + if (pushStatusCheckouts.put(checkout.root, checkout) == null) { + refreshPushStatus(checkout) + } + return pushStatuses[checkout.root] + } + + private fun refreshPushStatus(checkout: AnnotaterCheckout) { + val root = checkout.root + if (!pendingPushStatuses.add(root)) { + return + } + pushStatusExecutor.execute { + pendingPushStatuses.remove(root) + val status = runCatching { computePushStatus(ProjectManager.getInstance().defaultProject, checkout) } + .getOrNull() + val old = if (status == null) pushStatuses.remove(root) else pushStatuses.put(root, status) + if (old != status) { + EditorNotifications.updateAll() + } + } + } + + private fun computePushStatus(project: Project, checkout: AnnotaterCheckout): PushStatus { + val branch = currentBranch(project, checkout.root) ?: return PushStatus(null, false) + val pullRequestUrl = getBranchConfig(project, checkout, branch, PULL_REQUEST_KEY) + val pushedCommit = getBranchConfig(project, checkout, branch, PUSHED_COMMIT_KEY) + val hasUnpushedChanges = when { + uncommittedMappingFiles(project, checkout).isNotEmpty() -> true + pullRequestUrl != null && pushedCommit != null -> headCommit(project, checkout) != pushedCommit + else -> commitsAheadOfUpstream(project, checkout) > 0 + } + return PushStatus(pullRequestUrl, hasUnpushedChanges) + } + + /** + * The URL of the pull request opened for the contribution [branch], if any. + */ + fun pullRequestUrl(project: Project, checkout: AnnotaterCheckout, branch: String): String? = + getBranchConfig(project, checkout, branch, PULL_REQUEST_KEY) + + /** + * Remembers that the contribution [branch] has been pushed to the pull request at [url], so that the editor banner + * offers to update it when there are new changes. + */ + fun setPullRequest(project: Project, checkout: AnnotaterCheckout, branch: String, url: String) { + run(project, checkout.root, GitCommand.CONFIG, "branch.$branch.$PULL_REQUEST_KEY", url) + run( + project, + checkout.root, + GitCommand.CONFIG, + "branch.$branch.$PUSHED_COMMIT_KEY", + headCommit(project, checkout), + ) + refreshPushStatus(checkout) + } + + private fun getBranchConfig(project: Project, checkout: AnnotaterCheckout, branch: String, key: String): String? { + // git config exits with 1 when the key isn't set + return runCatching { run(project, checkout.root, GitCommand.CONFIG, "--get", "branch.$branch.$key") } + .getOrNull()?.firstOrNull()?.trim()?.takeIf { it.isNotEmpty() } + } + + private fun headCommit(project: Project, checkout: AnnotaterCheckout): String = + run(project, checkout.root, GitCommand.REV_PARSE, "HEAD").first().trim() + override fun uncommittedMappingFiles(project: Project?, checkout: AnnotaterCheckout): List { val output = run( project ?: ProjectManager.getInstance().defaultProject, @@ -374,6 +487,45 @@ class AnnotaterGitVcs : AnnotaterVcs { ApplicationManager.getApplication().messageBus.syncPublisher(AnnotaterListener.TOPIC).checkoutsChanged() } + override fun deleteContribution( + project: Project?, + checkout: AnnotaterCheckout, + branch: String, + indicator: ProgressIndicator, + ) { + val gitProject = project ?: ProjectManager.getInstance().defaultProject + synchronized(lock) { + if (currentBranch(gitProject, checkout.root) == branch) { + // Discard the contribution's uncommitted changes, including new mapping files + val untracked = run( + gitProject, + checkout.root, + GitCommand.STATUS, + "--porcelain", + "--untracked-files=all", + "--", + checkout.config.mappings, + ).filter { it.startsWith("?? ") }.map { checkout.root.resolve(it.substring(3)) } + run( + gitProject, + checkout.root, + GitCommand.CHECKOUT, + "--force", + "--detach", + "$UPSTREAM_REMOTE/${checkout.version}", + ) + untracked.forEach { Files.deleteIfExists(it) } + } + // Also deletes the branch's configuration, such as its pull request title + run(gitProject, checkout.root, GitCommand.BRANCH, "-D", branch) + titles.remove(branch) + contributionTitles.remove(branch) + titledBranches.remove(branch) + } + LocalFileSystem.getInstance().refreshNioFiles(listOf(checkout.root), true, true, null) + ApplicationManager.getApplication().messageBus.syncPublisher(AnnotaterListener.TOPIC).checkoutsChanged() + } + private fun branchPrefix(checkout: AnnotaterCheckout) = "mcdev/${checkout.version}/" private fun currentBranch(project: Project, dir: Path): String? { @@ -411,14 +563,21 @@ class AnnotaterGitVcs : AnnotaterVcs { companion object { private const val REPO_DIR = ".repo" private const val HEAD_REF_PREFIX = "ref: refs/heads/" - const val UPSTREAM_REMOTE = "origin" + private const val UPSTREAM_REMOTE = "origin" + + /** + * The git config keys of contribution branches, under `branch.`, storing the URL of their pull request + * and the commit last pushed to it. + */ + private const val PULL_REQUEST_KEY = "mcdevPullRequest" + private const val PUSHED_COMMIT_KEY = "mcdevPushedCommit" /** * The GitHub repository annotater is cloned from and pull requests are opened against, as `owner/name`. Can * be overridden to test against a fork without opening real pull requests. */ val upstreamRepository: String = System.getProperty("mcdev.annotater.upstream", "FabricMC/annotater") - val upstreamUrl = "https://github.com/$upstreamRepository.git" + private val upstreamUrl = "https://github.com/$upstreamRepository.git" val instance: AnnotaterGitVcs? get() = AnnotaterVcs.instance as? AnnotaterGitVcs diff --git a/src/main/kotlin/platform/fabric/annotater/github/AnnotaterBuildCheck.kt b/src/main/kotlin/platform/fabric/annotater/github/AnnotaterBuildCheck.kt new file mode 100644 index 000000000..8480235fa --- /dev/null +++ b/src/main/kotlin/platform/fabric/annotater/github/AnnotaterBuildCheck.kt @@ -0,0 +1,123 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + + +package com.demonwav.mcdev.platform.fabric.annotater.github + +import com.demonwav.mcdev.asset.MCDevBundle +import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterCheckout +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.process.CapturingProcessHandler +import com.intellij.execution.process.ProcessOutput +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.progress.Task +import com.intellij.openapi.project.Project +import com.intellij.openapi.projectRoots.JavaSdkType +import com.intellij.openapi.roots.ProjectRootManager +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.util.SystemInfo +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.components.JBTextArea +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.AlignY +import com.intellij.ui.dsl.builder.panel +import com.intellij.util.ui.JBUI +import java.awt.Dimension +import java.awt.Font + +/** + * Runs annotater's own checks with `gradlew check` before a pull request is submitted. + */ +object AnnotaterBuildCheck { + /** + * Runs the checks in [checkout] with a modal progress dialog. If they fail, the user is shown the output and can + * choose to submit anyway. + * + * @return whether to continue submitting + */ + fun run(project: Project, checkout: AnnotaterCheckout): Boolean { + var result: ProcessOutput? = null + var error: Throwable? = null + // Modal tasks finish before this returns + ProgressManager.getInstance().run( + object : Task.Modal(project, MCDevBundle("fabric.annotater.pr.check_build.progress"), true) { + override fun run(indicator: ProgressIndicator) { + indicator.isIndeterminate = true + result = CapturingProcessHandler(commandLine(project, checkout)) + .runProcessWithProgressIndicator(indicator) + } + + override fun onThrowable(e: Throwable) { + error = e + } + }, + ) + error?.let { return FailedDialog(project, it.message ?: it.toString()).showAndGet() } + val output = result?.takeUnless { it.isCancelled } ?: return false + if (output.exitCode == 0 && !output.isTimeout) { + return true + } + val log = (output.stdout + "\n" + output.stderr).trim() + return FailedDialog(project, log).showAndGet() + } + + private fun commandLine(project: Project, checkout: AnnotaterCheckout): GeneralCommandLine { + val wrapper = checkout.root.resolve(if (SystemInfo.isWindows) "gradlew.bat" else "gradlew") + val commandLine = GeneralCommandLine(wrapper.toString(), "check", "--console=plain") + .withWorkingDirectory(checkout.root) + .withCharset(Charsets.UTF_8) + // Minecraft projects need a recent Java version, which the environment's JAVA_HOME may not be + val sdk = ProjectRootManager.getInstance(project).projectSdk?.takeIf { it.sdkType is JavaSdkType } + sdk?.homePath?.let { commandLine.withEnvironment("JAVA_HOME", it) } + return commandLine + } + + /** + * Shows the output of the failed checks, offering to cancel submitting, which is the default, or submit anyway. + */ + private class FailedDialog(project: Project, private val log: String) : DialogWrapper(project) { + init { + title = MCDevBundle("fabric.annotater.pr.check_build.failed") + setOKButtonText(MCDevBundle("fabric.annotater.pr.check_build.submit_anyway")) + okAction.putValue(DEFAULT_ACTION, null) + cancelAction.putValue(DEFAULT_ACTION, true) + init() + } + + override fun createCenterPanel() = panel { + row { + label(MCDevBundle("fabric.annotater.pr.check_build.failed.message")) + } + row { + val textArea = JBTextArea(log).apply { + isEditable = false + font = JBUI.Fonts.create(Font.MONOSPACED, font.size) + // The cause of a failure is usually at the end + caretPosition = document.length + } + cell(JBScrollPane(textArea)) + .align(AlignX.FILL) + .align(AlignY.FILL) + .applyToComponent { preferredSize = Dimension(JBUI.scale(800), JBUI.scale(400)) } + }.resizableRow() + } + } +} diff --git a/src/main/kotlin/platform/fabric/annotater/github/AnnotaterGitHubPullRequests.kt b/src/main/kotlin/platform/fabric/annotater/github/AnnotaterGitHubPullRequests.kt index 338895b52..fa5d1ce21 100644 --- a/src/main/kotlin/platform/fabric/annotater/github/AnnotaterGitHubPullRequests.kt +++ b/src/main/kotlin/platform/fabric/annotater/github/AnnotaterGitHubPullRequests.kt @@ -52,10 +52,16 @@ import org.jetbrains.plugins.github.util.GHCompatibilityUtil * request, using accounts from the GitHub plugin. */ class AnnotaterGitHubPullRequests : AnnotaterPullRequests { - override fun canCreatePullRequest(checkout: AnnotaterCheckout) = - checkout.managed && AnnotaterGitVcs.instance != null + override fun availableAction(checkout: AnnotaterCheckout): AnnotaterPullRequests.Action? { + val status = AnnotaterGitVcs.instance?.pushStatus(checkout)?.takeIf { it.hasUnpushedChanges } ?: return null + return if (status.pullRequestUrl != null) { + AnnotaterPullRequests.Action.UPDATE + } else { + AnnotaterPullRequests.Action.CREATE + } + } - override fun createPullRequest(project: Project, checkout: AnnotaterCheckout) { + override fun submit(project: Project, checkout: AnnotaterCheckout) { val git = AnnotaterGitVcs.instance ?: return object : Task.Backgroundable(project, MCDevBundle("fabric.annotater.pr.checking"), true) { @@ -64,6 +70,7 @@ class AnnotaterGitHubPullRequests : AnnotaterPullRequests { private var commitsAhead = 0 private var branch: String? = null private var existingTitle: String? = null + private var existingUrl: String? = null override fun run(indicator: ProgressIndicator) { changes = git.mappingFilesChangedSinceUpstream(project, checkout) @@ -71,6 +78,7 @@ class AnnotaterGitHubPullRequests : AnnotaterPullRequests { commitsAhead = git.commitsAheadOfUpstream(project, checkout) branch = git.currentContribution(checkout)?.branch existingTitle = branch?.let { git.pullRequestTitle(project, checkout, it) } + existingUrl = branch?.let { git.pullRequestUrl(project, checkout, it) } } override fun onSuccess() { @@ -83,7 +91,7 @@ class AnnotaterGitHubPullRequests : AnnotaterPullRequests { ) return } - showDialog(project, checkout, git, branch, changes, uncommittedChanges, existingTitle) + showDialog(project, checkout, git, branch, changes, uncommittedChanges, existingTitle, existingUrl) } override fun onThrowable(error: Throwable) { @@ -100,6 +108,7 @@ class AnnotaterGitHubPullRequests : AnnotaterPullRequests { changes: List, uncommittedChanges: List, existingTitle: String?, + existingUrl: String?, ) { if (GHAccountsUtil.accounts.isEmpty()) { GHAccountsUtil.requestNewAccount(GithubServerPath.DEFAULT_SERVER, null, project) ?: return @@ -115,30 +124,47 @@ class AnnotaterGitHubPullRequests : AnnotaterPullRequests { } val defaultAccount = GHAccountsUtil.getSingleOrDefaultAccount(project)?.takeIf { it in accounts } - val dialog = - AnnotaterPullRequestDialog(project, accounts, defaultAccount, changes, uncommittedChanges, existingTitle) + val dialog = AnnotaterPullRequestDialog( + project, + accounts, + defaultAccount, + changes, + uncommittedChanges, + existingTitle, + update = existingUrl != null, + ) if (!dialog.showAndGet()) { return } val account = dialog.account ?: return + if (dialog.checkBuild && !AnnotaterBuildCheck.run(project, checkout)) { + return + } val token = GHCompatibilityUtil.getOrRequestToken(account, project) ?: return - val request = PullRequest(dialog.prTitle, dialog.prDescription, dialog.commitMessage, branch) - object : Task.Backgroundable(project, MCDevBundle("fabric.annotater.pr.creating"), false) { + val request = PullRequest(dialog.prTitle, dialog.prDescription, dialog.commitMessage, branch, existingUrl) + val title = if (existingUrl != null) "fabric.annotater.pr.updating" else "fabric.annotater.pr.creating" + object : Task.Backgroundable(project, MCDevBundle(title), false) { private var url: String? = null override fun run(indicator: ProgressIndicator) { - url = + val url = submit(project, checkout, git, account, token, request, uncommittedChanges.isNotEmpty(), indicator) - // Label the contribution with its pull request, for switching back to it later - runCatching { git.setContributionTitle(project, checkout, request.branch, request.title) } + this.url = url + git.setPullRequest(project, checkout, request.branch, url) + if (existingUrl == null) { + // Label the contribution with its pull request, for switching back to it later + runCatching { git.setContributionTitle(project, checkout, request.branch, request.title) } + } } override fun onSuccess() { val url = url ?: return + val notificationTitle = + if (existingUrl != null) "fabric.annotater.pr.updated" else "fabric.annotater.pr.created" Notification( AnnotaterNotifications.GROUP_ID, - MCDevBundle("fabric.annotater.pr.created"), + MCDevBundle(notificationTitle), url, NotificationType.INFORMATION, ).addAction( @@ -147,10 +173,9 @@ class AnnotaterGitHubPullRequests : AnnotaterPullRequests { }, ).addAction( NotificationAction.createSimpleExpiring(MCDevBundle("fabric.annotater.pr.start_new")) { - AnnotaterJavadocContributions.switchTo(project, checkout, null) + AnnotaterJavadocContributions.switchContribution(project, checkout, null) }, ).notify(project) - BrowserUtil.browse(url) } override fun onThrowable(error: Throwable) { @@ -159,8 +184,20 @@ class AnnotaterGitHubPullRequests : AnnotaterPullRequests { }.queue() } - private class PullRequest(val title: String, val body: String, val commitMessage: String, val branch: String) + /** + * @property existingUrl the URL of the pull request to update, or null to create one + */ + private class PullRequest( + val title: String, + val body: String, + val commitMessage: String, + val branch: String, + val existingUrl: String?, + ) + /** + * Commits and pushes the changes, and creates the pull request unless it already exists. Returns its URL. + */ @Throws(IOException::class, VcsException::class) private fun submit( project: Project, @@ -176,7 +213,7 @@ class AnnotaterGitHubPullRequests : AnnotaterPullRequests { val upstream = AnnotaterGitVcs.upstreamRepository // Otherwise GitHub only rejects the pull request with "base invalid" after committing, forking and pushing - if (!api.exists("/repos/$upstream/branches/${checkout.version}")) { + if (request.existingUrl == null && !api.exists("/repos/$upstream/branches/${checkout.version}")) { throw IOException(MCDevBundle("fabric.annotater.pr.no_base_branch", upstream, checkout.version)) } @@ -185,6 +222,7 @@ class AnnotaterGitHubPullRequests : AnnotaterPullRequests { git.commitMappings(project, checkout, request.commitMessage) } + // Returns the existing fork if there is one indicator.text = MCDevBundle("fabric.annotater.pr.forking") val fork = api.post("/repos/$upstream/forks", JsonObject()) val forkName = fork.get("full_name").asString @@ -194,6 +232,9 @@ class AnnotaterGitHubPullRequests : AnnotaterPullRequests { indicator.text = MCDevBundle("fabric.annotater.pr.pushing") git.push(project, checkout, cloneUrl, request.branch) + if (request.existingUrl != null) { + return request.existingUrl + } indicator.text = MCDevBundle("fabric.annotater.pr.opening") val body = JsonObject().apply { diff --git a/src/main/kotlin/platform/fabric/annotater/github/AnnotaterPullRequestDialog.kt b/src/main/kotlin/platform/fabric/annotater/github/AnnotaterPullRequestDialog.kt index e5191d620..fa950bea5 100644 --- a/src/main/kotlin/platform/fabric/annotater/github/AnnotaterPullRequestDialog.kt +++ b/src/main/kotlin/platform/fabric/annotater/github/AnnotaterPullRequestDialog.kt @@ -27,6 +27,7 @@ import com.intellij.openapi.ui.DialogWrapper import com.intellij.ui.CollectionComboBoxModel import com.intellij.ui.SimpleListCellRenderer import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.bindSelected import com.intellij.ui.dsl.builder.bindText import com.intellij.ui.dsl.builder.panel import com.intellij.ui.dsl.builder.rows @@ -40,19 +41,29 @@ class AnnotaterPullRequestDialog( private val changes: List, private val uncommittedChanges: List, existingTitle: String?, + /** + * Whether the changes are pushed to an existing pull request, so there's no title or description to enter. + */ + private val update: Boolean, ) : DialogWrapper(project) { private val accountModel = CollectionComboBoxModel(accounts, defaultAccount ?: accounts.first()) var prTitle = existingTitle ?: AnnotaterContribution.defaultTitle(changes) var prDescription = "" var commitMessage = AnnotaterContribution.defaultTitle(uncommittedChanges) + var checkBuild = true val account: GithubAccount? get() = accountModel.selected init { - title = MCDevBundle("fabric.annotater.create_pull_request") - setOKButtonText(MCDevBundle("fabric.annotater.pr.dialog.ok")) + if (update) { + title = MCDevBundle("fabric.annotater.update_pull_request") + setOKButtonText(MCDevBundle("fabric.annotater.pr.dialog.update")) + } else { + title = MCDevBundle("fabric.annotater.create_pull_request") + setOKButtonText(MCDevBundle("fabric.annotater.pr.dialog.ok")) + } init() } @@ -61,17 +72,27 @@ class AnnotaterPullRequestDialog( comboBox(accountModel, SimpleListCellRenderer.create("") { it.name }) .align(AlignX.FILL) } - row(MCDevBundle("fabric.annotater.pr.dialog.title")) { - textField().bindText(::prTitle).align(AlignX.FILL).focused() - } - row(MCDevBundle("fabric.annotater.pr.dialog.description")) { - textArea().bindText(::prDescription).rows(6).align(AlignX.FILL) + if (!update) { + row(MCDevBundle("fabric.annotater.pr.dialog.title")) { + textField().bindText(::prTitle).align(AlignX.FILL).focused() + } + row(MCDevBundle("fabric.annotater.pr.dialog.description")) { + textArea().bindText(::prDescription).rows(6).align(AlignX.FILL) + } } if (uncommittedChanges.isNotEmpty()) { row(MCDevBundle("fabric.annotater.pr.dialog.commit_message")) { - textField().bindText(::commitMessage).align(AlignX.FILL) + val field = textField().bindText(::commitMessage).align(AlignX.FILL) + if (update) { + field.focused() + } } } + row { + checkBox(MCDevBundle("fabric.annotater.pr.dialog.check_build")) + .bindSelected(::checkBuild) + .comment(MCDevBundle("fabric.annotater.pr.dialog.check_build.comment")) + } if (changes.isNotEmpty()) { row { comment( diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocCommentInspection.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocCommentInspection.kt index ab88e4780..e97603969 100644 --- a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocCommentInspection.kt +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocCommentInspection.kt @@ -21,8 +21,9 @@ package com.demonwav.mcdev.platform.fabric.annotater.javadoc import com.demonwav.mcdev.asset.MCDevBundle +import com.demonwav.mcdev.util.McdevReflection +import com.demonwav.mcdev.util.McdevReflection.newInstanceUnwrap import com.intellij.codeInspection.LocalInspectionTool -import com.intellij.codeInspection.LocalQuickFix import com.intellij.codeInspection.ProblemHighlightType import com.intellij.codeInspection.ProblemsHolder import com.intellij.psi.JavaElementVisitor @@ -30,7 +31,6 @@ import com.intellij.psi.PsiComment import com.intellij.psi.PsiElementVisitor import com.intellij.psi.javadoc.PsiDocComment import com.intellij.psi.util.PsiTreeUtil -import com.siyeh.ig.migration.MarkdownDocumentationCommentsMigrationInspection /** * Annotater only supports Markdown (`///`) Javadoc comments. @@ -56,7 +56,7 @@ class AnnotaterJavadocCommentInspection : LocalInspectionTool() { comment, MCDevBundle("fabric.annotater.javadoc.error.not_markdown"), ProblemHighlightType.GENERIC_ERROR, - *findMarkdownMigrationFixes(holder, comment), + McdevReflection.CONSTRUCTOR_MARKDOWN_DOCUMENTATION_COMMENTS_MIGRATION_FIX.newInstanceUnwrap(), ) } @@ -73,16 +73,4 @@ class AnnotaterJavadocCommentInspection : LocalInspectionTool() { } } } - - /** - * Reuses the IDE's own "convert to Markdown documentation comment" quick fix. - */ - private fun findMarkdownMigrationFixes(holder: ProblemsHolder, comment: PsiDocComment): Array { - val migrationHolder = ProblemsHolder(holder.manager, holder.file, holder.isOnTheFly) - comment.accept(MarkdownDocumentationCommentsMigrationInspection().buildVisitor(migrationHolder, true)) - return migrationHolder.results - .flatMap { it.fixes.orEmpty().asIterable() } - .filterIsInstance() - .toTypedArray() - } } diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocContributions.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocContributions.kt index 68e181f34..f18e078e2 100644 --- a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocContributions.kt +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocContributions.kt @@ -42,7 +42,8 @@ import java.util.concurrent.CompletableFuture import javax.swing.JComponent /** - * Switching a managed annotater checkout between contributions, so that a pull request can be updated after review. + * Switching a managed annotater checkout between contributions, so that a pull request can be updated after review, + * and deleting contributions. */ object AnnotaterJavadocContributions { /** @@ -52,8 +53,8 @@ object AnnotaterJavadocContributions { vcs.currentContribution(checkout)?.displayName ?: MCDevBundle("fabric.annotater.contribution.new") /** - * Shows a popup under [component] for switching between contributions if [checkout] is managed, and configuring - * annotater. + * Shows a popup under [component] for switching between and deleting contributions if [checkout] is managed, and + * configuring annotater. */ fun showPopup(project: Project, checkout: AnnotaterCheckout, component: JComponent) { val vcs = AnnotaterVcs.instance?.takeIf { checkout.managed } @@ -92,14 +93,27 @@ object AnnotaterJavadocContributions { } group.add( DumbAwareAction.create(MCDevBundle("fabric.annotater.contribution.new"), AllIcons.General.Add) { - switchTo(project, checkout, null) + switchContribution(project, checkout, null) }, ) + if (contributions.isNotEmpty()) { + val deleteGroup = + DefaultActionGroup.createPopupGroup { MCDevBundle("fabric.annotater.contribution.delete") } + deleteGroup.templatePresentation.icon = AllIcons.General.Delete + for (contribution in contributions) { + deleteGroup.add( + DumbAwareAction.create(contribution.displayName) { + deleteContribution(project, checkout, vcs, contribution, contribution.branch == current) + }, + ) + } + group.add(deleteGroup) + } group.add(Separator.getInstance()) } group.add( DumbAwareAction.create(MCDevBundle("fabric.annotater.configure"), AllIcons.General.Settings) { - AnnotaterConfigurable.show(project) + AnnotaterConfigurable.Util.show(project) }, ) JBPopupFactory.getInstance() @@ -125,7 +139,7 @@ object AnnotaterJavadocContributions { ) { override fun actionPerformed(e: AnActionEvent) { if (!current) { - switchTo(project, checkout, contribution.branch) + switchContribution(project, checkout, contribution.branch) } } } @@ -135,7 +149,7 @@ object AnnotaterJavadocContributions { * edited is saved first, any uncommitted changes are committed to the current contribution, and editing is then * restarted with the Javadoc from the new branch. */ - fun switchTo(project: Project, checkout: AnnotaterCheckout, branch: String?) { + fun switchContribution(project: Project, checkout: AnnotaterCheckout, branch: String?) { val vcs = AnnotaterVcs.instance ?: return val saves = listOfNotNull(javadocEditor(project, checkout)?.save()) CompletableFuture.allOf(*saves.toTypedArray()).thenAccept { @@ -148,12 +162,15 @@ object AnnotaterJavadocContributions { ) return@invokeLater } - checkUncommittedChanges(project, checkout, vcs, branch) + askForCommitMessageThenSwitch(project, checkout, vcs, branch) } } } - private fun checkUncommittedChanges( + /** + * Asks for a message to commit the uncommitted changes with, if there are any, then switches to [branch]. + */ + private fun askForCommitMessageThenSwitch( project: Project, checkout: AnnotaterCheckout, vcs: AnnotaterVcs, @@ -168,7 +185,7 @@ object AnnotaterJavadocContributions { override fun onSuccess() { if (uncommitted.isEmpty()) { - switch(project, checkout, vcs, branch, null) + commitAndCheckOut(project, checkout, vcs, branch, null) return } val current = vcs.currentContribution(checkout)?.displayName @@ -181,7 +198,7 @@ object AnnotaterJavadocContributions { AnnotaterContribution.defaultTitle(uncommitted), null, )?.takeIf { it.isNotBlank() } ?: return - switch(project, checkout, vcs, branch, message) + commitAndCheckOut(project, checkout, vcs, branch, message) } override fun onThrowable(error: Throwable) { @@ -190,7 +207,11 @@ object AnnotaterJavadocContributions { }.queue() } - private fun switch( + /** + * Commits the uncommitted changes with [commitMessage] if it isn't null, then checks out [branch], restarting the + * editing of the Javadoc being edited. + */ + private fun commitAndCheckOut( project: Project, checkout: AnnotaterCheckout, vcs: AnnotaterVcs, @@ -217,6 +238,56 @@ object AnnotaterJavadocContributions { }.queue() } + /** + * Deletes [contribution] after asking for confirmation. Only the local branch is deleted. + */ + private fun deleteContribution( + project: Project, + checkout: AnnotaterCheckout, + vcs: AnnotaterVcs, + contribution: AnnotaterContribution, + current: Boolean, + ) { + val message = MCDevBundle("fabric.annotater.contribution.delete.confirm", contribution.displayName) + + if (current) "\n\n" + MCDevBundle("fabric.annotater.contribution.delete.confirm.current") else "" + val confirmed = Messages.showOkCancelDialog( + project, + message, + MCDevBundle("fabric.annotater.contribution.delete.title"), + MCDevBundle("fabric.annotater.contribution.delete.ok"), + Messages.getCancelButton(), + Messages.getWarningIcon(), + ) == Messages.OK + if (!confirmed) { + return + } + if (current) { + // The Javadoc being edited is from the deleted branch, and its changes are discarded + javadocEditor(project, checkout)?.finish(force = true) + } + object : Task.Backgroundable(project, MCDevBundle("fabric.annotater.contribution.deleting"), false) { + override fun run(indicator: ProgressIndicator) { + vcs.deleteContribution(project, checkout, contribution.branch, indicator) + } + + override fun onSuccess() { + AnnotaterNotifications.notifyInfo( + project, + MCDevBundle("fabric.annotater.contribution.deleted"), + MCDevBundle("fabric.annotater.contribution.deleted.content", contribution.displayName), + ) + } + + override fun onThrowable(error: Throwable) { + AnnotaterNotifications.notifyError( + project, + MCDevBundle("fabric.annotater.contribution.delete.failed"), + error, + ) + } + }.queue() + } + private fun javadocEditor(project: Project, checkout: AnnotaterCheckout): AnnotaterJavadocInlineEditor? = AnnotaterJavadocInlineEditor.current(project)?.takeIf { it.checkout.root == checkout.root } } diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocDocumentationProvider.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocDocumentationProvider.kt index 3ba0b052f..1a3c5261c 100644 --- a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocDocumentationProvider.kt +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocDocumentationProvider.kt @@ -23,7 +23,6 @@ package com.demonwav.mcdev.platform.fabric.annotater.javadoc import com.intellij.lang.documentation.AbstractDocumentationProvider import com.intellij.openapi.project.DumbService import com.intellij.psi.PsiElement -import com.intellij.psi.PsiMember /** * Shows annotater Javadocs instead of the existing Javadoc of Minecraft classes in Quick Documentation. @@ -36,6 +35,6 @@ class AnnotaterJavadocDocumentationProvider : AbstractDocumentationProvider() { val member = AnnotaterJavadocEditing.normalize(element) ?: return null val checkout = AnnotaterJavadocRendering.checkoutForDisplay(member) ?: return null val target = AnnotaterJavadocTarget.fromPsi(member) ?: return null - return AnnotaterJavadocRendering.generateDoc(member, AnnotaterJavadocRendering.getJavadoc(checkout, target)) + return AnnotaterJavadocRendering.generateDoc(member, checkout.javadocStore.getOrNull(target)) } } diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocEditing.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocEditing.kt index 8006a1fdb..5dfd9bcc3 100644 --- a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocEditing.kt +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocEditing.kt @@ -36,23 +36,25 @@ import com.intellij.openapi.progress.ProgressIndicator import com.intellij.openapi.progress.Task import com.intellij.openapi.project.Project import com.intellij.openapi.wm.IdeFocusManager -import com.intellij.psi.PsiAnonymousClass -import com.intellij.psi.PsiClass import com.intellij.psi.PsiDocumentManager import com.intellij.psi.PsiElement -import com.intellij.psi.PsiField import com.intellij.psi.PsiFile import com.intellij.psi.PsiJavaFile import com.intellij.psi.PsiMember -import com.intellij.psi.PsiMethod -import com.intellij.psi.PsiTypeParameter import com.intellij.psi.SmartPointerManager import com.intellij.psi.impl.compiled.ClsElementImpl import com.intellij.psi.util.PsiTreeUtil -import com.intellij.psi.util.PsiUtilCore import java.io.IOException object AnnotaterJavadocEditing { + /** + * Whether to offer editing annotater Javadocs in [project]. In Fabric projects it's offered even before annotater + * is set up, to make it discoverable. + */ + fun isOffered(project: Project): Boolean { + return AnnotaterSettings.instance.enabled || AnnotaterCheckoutService.isFabricProject(project) + } + /** * Finds the Minecraft class, field or method that the caret is on or in, which may be a reference to it. */ @@ -70,38 +72,24 @@ object AnnotaterJavadocEditing { } /** - * Edits the annotater Javadoc of the member found by [findTarget]. + * Converts an element of a decompiled class's text to the compiled element it was generated from, if there is + * one, and filters out elements that can't have annotater Javadocs. */ - fun edit(project: Project, editor: Editor, file: PsiFile) { - open(project, findTarget(editor, file) ?: return) + fun normalize(element: PsiElement): PsiMember? { + val member = (element.getUserData(ClsElementImpl.COMPILED_ELEMENT) ?: element) as? PsiMember ?: return null + return member.takeIf { AnnotaterJavadocTarget.fromPsi(it) != null } } - /** - * Converts an element of a decompiled class's text to the compiled element it was generated from, and filters out - * elements that can't have annotater Javadocs. - */ - fun normalize(element: PsiElement): PsiMember? { - val compiled = element.getUserData(ClsElementImpl.COMPILED_ELEMENT) ?: element - return when (compiled) { - is PsiTypeParameter, is PsiAnonymousClass -> null - is PsiClass -> compiled.takeIf { it.qualifiedName != null } - is PsiField, is PsiMethod -> compiled - else -> null - } + private fun isDocumentable(member: PsiMember): Boolean { + val file = AnnotaterCheckoutService.virtualFileOf(member) ?: return false + return AnnotaterCheckoutService.isMinecraftFile(member.project, file) } /** - * Whether to offer editing annotater Javadocs in [project]. In Fabric projects it's offered even before annotater - * is set up, to make it discoverable. + * Starts editing the annotater Javadoc of the member found by [findTarget]. */ - fun isOffered(project: Project): Boolean { - return AnnotaterSettings.instance.enabled || AnnotaterCheckoutService.isFabricProject(project) - } - - fun isDocumentable(member: PsiMember): Boolean { - val file = PsiUtilCore.getVirtualFile(member) ?: return false - return AnnotaterCheckoutService.isMinecraftFile(member.project, file) && - AnnotaterJavadocTarget.fromPsi(member) != null + fun open(project: Project, editor: Editor, file: PsiFile) { + open(project, findTarget(editor, file) ?: return) } /** @@ -111,7 +99,7 @@ object AnnotaterJavadocEditing { val checkout = when (val status = AnnotaterCheckoutService.instance.status(member)) { is AnnotaterCheckoutService.Status.Available -> status.checkout AnnotaterCheckoutService.Status.Disabled -> { - AnnotaterConfigurable.show(project) + AnnotaterConfigurable.Util.show(project) return } @@ -154,7 +142,7 @@ object AnnotaterJavadocEditing { val (host, hostMember) = findHost(project, member, target) ?: return val copy = try { - ReadAction.compute { + ReadAction.computeBlocking { AnnotaterJavadocSourceCopy.create(member, placeholderFor = target) { checkout.javadocStore.get(it) } } } catch (e: IOException) { @@ -183,7 +171,7 @@ object AnnotaterJavadocEditing { } val file = PsiDocumentManager.getInstance(project).getPsiFile(editor.document) as? PsiJavaFile ?: return null - val hostMember = AnnotaterJavadocRendering.displayedMembers(file) + val hostMember = AnnotaterJavadocTarget.declarations(file) .firstOrNull { AnnotaterJavadocTarget.fromPsi(it) == target } ?: return null return editor to hostMember diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocEditorNotificationProvider.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocEditorNotificationProvider.kt index 9aecfc7ad..727524c5e 100644 --- a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocEditorNotificationProvider.kt +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocEditorNotificationProvider.kt @@ -21,8 +21,8 @@ package com.demonwav.mcdev.platform.fabric.annotater.javadoc import com.demonwav.mcdev.asset.MCDevBundle -import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterPullRequests import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterCheckoutService +import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterPullRequests import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterVcs import com.intellij.icons.AllIcons import com.intellij.openapi.application.ApplicationManager @@ -47,6 +47,7 @@ class AnnotaterJavadocEditorNotificationProvider : EditorNotificationProvider { val checkout = AnnotaterCheckoutService.instance.checkoutFor(project, file) ?: return null val javadocEditor = AnnotaterJavadocInlineEditor.current(project)?.takeIf { it.checkout.root == checkout.root } val error = javadocEditor?.file?.error + val pullRequests = AnnotaterPullRequests.instance return Function { fileEditor -> val status = @@ -59,14 +60,18 @@ class AnnotaterJavadocEditorNotificationProvider : EditorNotificationProvider { } } - val pullRequests = AnnotaterPullRequests.instance - if (pullRequests != null && pullRequests.canCreatePullRequest(checkout)) { - panel.createActionLabel(MCDevBundle("fabric.annotater.create_pull_request")) { + val pullRequestAction = pullRequests?.availableAction(checkout) + if (pullRequests != null && pullRequestAction != null) { + val text = when (pullRequestAction) { + AnnotaterPullRequests.Action.CREATE -> MCDevBundle("fabric.annotater.create_pull_request") + AnnotaterPullRequests.Action.UPDATE -> MCDevBundle("fabric.annotater.update_pull_request") + } + panel.createActionLabel(text) { val saved = javadocEditor?.save() ?: CompletableFuture.completedFuture(true) saved.thenAccept { success -> if (success) { ApplicationManager.getApplication().invokeLater { - pullRequests.createPullRequest(project, checkout) + pullRequests.submit(project, checkout) } } } diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlayPassFactory.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlayPassFactory.kt index 6bfb05c1b..631191de4 100644 --- a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlayPassFactory.kt +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlayPassFactory.kt @@ -85,7 +85,10 @@ class AnnotaterJavadocInlayPassFactory : TextEditorHighlightingPassFactoryRegist private class Pass(private val file: PsiJavaFile, private val editor: Editor) : TextEditorHighlightingPass(file.project, editor.document, false) { private val documentStamp = editor.document.modificationStamp - private var active = false + + /** + * Whether annotater Javadocs replace the existing ones, which is never the case without a checkout. + */ private var showJavadocs = false private var items = emptyList() private var gutterIcons = emptyList() @@ -94,7 +97,6 @@ class AnnotaterJavadocInlayPassFactory : TextEditorHighlightingPassFactoryRegist override fun doCollectInformation(progress: ProgressIndicator) { val first = file.classes.firstOrNull() ?: return val checkout = AnnotaterCheckoutService.instance.checkoutFor(first) ?: return - active = true showJavadocs = AnnotaterSettings.instance.showAnnotaterJavadocs val compiled = file is PsiCompiledElement // The Javadoc being edited is displayed by its editor instead @@ -103,14 +105,14 @@ class AnnotaterJavadocInlayPassFactory : TextEditorHighlightingPassFactoryRegist val newItems = mutableListOf() val newGutterIcons = mutableListOf() val newFolds = mutableListOf() - for (member in AnnotaterJavadocRendering.displayedMembers(file)) { + for (member in AnnotaterJavadocTarget.declarations(file)) { progress.checkCanceled() val target = AnnotaterJavadocTarget.fromPsi(member) ?: continue val existing = if (compiled) null else (member as PsiJavaDocumentedElement).docComment if (existing != null && showJavadocs) { newFolds += existing.textRange } - val javadoc = AnnotaterJavadocRendering.getJavadoc(checkout, target) ?: continue + val javadoc = checkout.javadocStore.getOrNull(target) ?: continue val pointer = SmartPointerManager.createPointer(member) newGutterIcons += GutterIcon(member.textOffset, pointer) if (!showJavadocs || target == edited) { @@ -130,14 +132,14 @@ class AnnotaterJavadocInlayPassFactory : TextEditorHighlightingPassFactoryRegist } val length = document.textLength - editor.getUserData(INLAYS_KEY)?.forEach { it.dispose() } + editor.getUserData(Const.INLAYS_KEY)?.forEach { it.dispose() } val inlays = items.filter { it.offset in 0..length }.mapNotNull { item -> val renderer = AnnotaterJavadocInlayRenderer(editor, item.offset, item.javadoc, item.pointer) editor.inlayModel.addBlockElement(item.offset, false, true, 0, renderer) } - editor.putUserData(INLAYS_KEY, inlays.takeIf { it.isNotEmpty() }) + editor.putUserData(Const.INLAYS_KEY, inlays.takeIf { it.isNotEmpty() }) - editor.getUserData(GUTTER_ICONS_KEY)?.forEach { it.dispose() } + editor.getUserData(Const.GUTTER_ICONS_KEY)?.forEach { it.dispose() } val highlighters = gutterIcons.filter { it.offset in 0..length }.map { icon -> editor.markupModel.addRangeHighlighter( icon.offset, @@ -147,9 +149,9 @@ class AnnotaterJavadocInlayPassFactory : TextEditorHighlightingPassFactoryRegist HighlighterTargetArea.LINES_IN_RANGE, ).also { it.gutterIconRenderer = EditGutterIconRenderer(icon.pointer) } } - editor.putUserData(GUTTER_ICONS_KEY, highlighters.takeIf { it.isNotEmpty() }) + editor.putUserData(Const.GUTTER_ICONS_KEY, highlighters.takeIf { it.isNotEmpty() }) - val oldFolds = editor.getUserData(FOLDS_KEY).orEmpty() + val oldFolds = editor.getUserData(Const.FOLDS_KEY).orEmpty() if (oldFolds.isNotEmpty() || folds.isNotEmpty()) { val foldingModel = editor.foldingModel val newFolds = mutableListOf() @@ -165,23 +167,22 @@ class AnnotaterJavadocInlayPassFactory : TextEditorHighlightingPassFactoryRegist region?.isExpanded = false } } - editor.putUserData(FOLDS_KEY, newFolds.takeIf { it.isNotEmpty() }) + editor.putUserData(Const.FOLDS_KEY, newFolds.takeIf { it.isNotEmpty() }) } - val hideRenderedDocs = active && showJavadocs - if (hideRenderedDocs && DocRenderManager.isDocRenderingEnabled(editor)) { + if (showJavadocs && DocRenderManager.isDocRenderingEnabled(editor)) { DocRenderManager.setDocRenderingEnabled(editor, false) - editor.putUserData(DOC_RENDERING_DISABLED_KEY, true) - } else if (!hideRenderedDocs && editor.getUserData(DOC_RENDERING_DISABLED_KEY) == true) { + editor.putUserData(Const.DOC_RENDERING_DISABLED_KEY, true) + } else if (!showJavadocs && editor.getUserData(Const.DOC_RENDERING_DISABLED_KEY) == true) { DocRenderManager.resetEditorToDefaultState(editor) - editor.putUserData(DOC_RENDERING_DISABLED_KEY, null) + editor.putUserData(Const.DOC_RENDERING_DISABLED_KEY, null) } } } private class EditGutterIconRenderer(private val pointer: SmartPsiElementPointer) : GutterIconRenderer() { - override fun getIcon(): Icon = GeneralAssets.ANNOTATER + override fun getIcon(): Icon = GeneralAssets.TINY_POTATO override fun getTooltipText() = MCDevBundle("fabric.annotater.javadoc.edit") @@ -202,10 +203,10 @@ class AnnotaterJavadocInlayPassFactory : TextEditorHighlightingPassFactoryRegist } } - companion object { - private val INLAYS_KEY = Key.create>>("mcdev.annotater.javadocInlays") - private val FOLDS_KEY = Key.create>("mcdev.annotater.javadocFolds") - private val GUTTER_ICONS_KEY = Key.create>("mcdev.annotater.javadocGutterIcons") - private val DOC_RENDERING_DISABLED_KEY = Key.create("mcdev.annotater.docRenderingDisabled") + private object Const { + val INLAYS_KEY = Key.create>>("mcdev.annotater.javadocInlays") + val FOLDS_KEY = Key.create>("mcdev.annotater.javadocFolds") + val GUTTER_ICONS_KEY = Key.create>("mcdev.annotater.javadocGutterIcons") + val DOC_RENDERING_DISABLED_KEY = Key.create("mcdev.annotater.docRenderingDisabled") } } diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlayRenderer.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlayRenderer.kt index 3b67bffeb..f2ea6f13b 100644 --- a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlayRenderer.kt +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlayRenderer.kt @@ -47,27 +47,17 @@ class AnnotaterJavadocInlayRenderer( private val javadoc: String, private val pointer: SmartPsiElementPointer, ) : EditorCustomElementRenderer { - private var inlay: Inlay<*>? = null - - // The inlay constructor calculates the size before the inlay is added to the tree, while its offset is still - // meaningless - private val offset: Int - get() = (inlay?.takeIf { it.isValid }?.offset ?: initialOffset).coerceIn(0, editor.document.textLength) - override fun calcWidthInPixels(inlay: Inlay<*>): Int { - this.inlay = inlay - val indent = maxOf(0, startX() - editor.insets.left) + val indent = maxOf(0, startX(inlay) - editor.insets.left) return indent + lines().maxOf { line -> line.sumOf { it.width() } } } override fun calcHeightInPixels(inlay: Inlay<*>): Int { - this.inlay = inlay return lines().size * editor.lineHeight } override fun paint(inlay: Inlay<*>, g: Graphics, targetRegion: Rectangle, textAttributes: TextAttributes) { - this.inlay = inlay - val startX = startX() + val startX = startX(inlay) var y = targetRegion.y + editor.ascent for (line in lines()) { var x = startX @@ -87,7 +77,10 @@ class AnnotaterJavadocInlayRenderer( /** * The x coordinate of the declaration, where the comment starts. */ - private fun startX(): Int { + private fun startX(inlay: Inlay<*>): Int { + // The size is first calculated while the inlay is being created, before it has a valid offset. The offset + // differs from initialOffset after the document changes, until the highlighting pass replaces the inlay + val offset = (if (inlay.isValid) inlay.offset else initialOffset).coerceIn(0, editor.document.textLength) val document = editor.document val lineStart = document.getLineStartOffset(document.getLineNumber(offset)) val contentStart = CharArrayUtil.shiftForward(document.immutableCharSequence, lineStart, " \t") diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlineEditor.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlineEditor.kt index 398d4a41e..85443b88b 100644 --- a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlineEditor.kt +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlineEditor.kt @@ -38,6 +38,7 @@ import com.intellij.openapi.actionSystem.DataSink import com.intellij.openapi.actionSystem.PlatformCoreDataKeys import com.intellij.openapi.actionSystem.UiDataProvider import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.EDT import com.intellij.openapi.components.Service import com.intellij.openapi.components.service import com.intellij.openapi.diagnostic.thisLogger @@ -45,10 +46,8 @@ import com.intellij.openapi.editor.Document import com.intellij.openapi.editor.Editor import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.editor.EditorKind -import com.intellij.openapi.editor.Inlay import com.intellij.openapi.editor.RangeMarker import com.intellij.openapi.editor.ScrollType -import com.intellij.openapi.editor.actionSystem.EditorActionManager import com.intellij.openapi.editor.event.CaretEvent import com.intellij.openapi.editor.event.CaretListener import com.intellij.openapi.editor.event.DocumentEvent @@ -64,7 +63,7 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.Key import com.intellij.openapi.util.TextRange -import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.vfs.VfsUtil import com.intellij.openapi.wm.IdeFocusManager import com.intellij.psi.PsiDocumentManager import com.intellij.psi.PsiJavaDocumentedElement @@ -73,7 +72,6 @@ import com.intellij.psi.PsiMember import com.intellij.psi.SmartPsiElementPointer import com.intellij.ui.ColorUtil import com.intellij.ui.EditorNotifications -import com.intellij.util.Alarm import com.intellij.util.concurrency.AppExecutorUtil import com.intellij.util.ui.JBUI import java.awt.BorderLayout @@ -83,6 +81,12 @@ import java.awt.event.ComponentEvent import java.io.IOException import java.util.concurrent.CompletableFuture import javax.swing.JPanel +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch /** * The editing of one annotater Javadoc, in an editor embedded above its declaration in the Minecraft class it's @@ -103,7 +107,10 @@ class AnnotaterJavadocInlineEditor private constructor( */ val source: SmartPsiElementPointer?, ) : Disposable { - private val alarm = Alarm(Alarm.ThreadToUse.SWING_THREAD, this) + /** + * The pending automatic save, which is delayed until the user stops typing. + */ + private var saveJob: Job? = null private val guards = mutableListOf() @@ -123,8 +130,6 @@ class AnnotaterJavadocInlineEditor private constructor( var editor: EditorEx? = null private set - private var inlay: Inlay<*>? = null - val checkout: AnnotaterCheckout get() = file.checkout @@ -133,17 +138,17 @@ class AnnotaterJavadocInlineEditor private constructor( init { installGuardedBlocks() - // The default handler shows a modal dialog, which is annoying when editing a guarded block by accident - EditorActionManager.getInstance().setReadonlyFragmentModificationHandler(document) { - val editor = editor ?: return@setReadonlyFragmentModificationHandler - HintManager.getInstance().showErrorHint(editor, MCDevBundle("fabric.annotater.javadoc.read_only")) - } document.addDocumentListener( object : DocumentListener { override fun documentChanged(event: DocumentEvent) { dirty = true - alarm.cancelAllRequests() - alarm.addRequest(::save, SAVE_DELAY_MS) + saveJob?.cancel() + saveJob = project.service().coroutineScope.launch(Dispatchers.EDT) { + delay(SAVE_DELAY_MS.milliseconds) + // Otherwise save would cancel this job, which cancels the save partway through + saveJob = null + save() + } } }, this, @@ -166,21 +171,22 @@ class AnnotaterJavadocInlineEditor private constructor( /** * Makes everything outside of the edited doc comment read-only. The line break after the comment is read-only too, - * so that the declaration can't be joined onto the comment. + * so that the declaration can't be joined onto the comment. The guards are greedy at the ends of the document, so + * nothing can be inserted there, but not next to the comment, so that text can be inserted at its start and end. */ private fun installGuardedBlocks() { val range = commentRange() ?: return - addGuard(0, range.startOffset) - addGuard(range.endOffset, document.textLength) + addGuard(0, range.startOffset, greedyToLeft = true, greedyToRight = false) + addGuard(range.endOffset, document.textLength, greedyToLeft = false, greedyToRight = true) } - private fun addGuard(start: Int, end: Int) { + private fun addGuard(start: Int, end: Int, greedyToLeft: Boolean, greedyToRight: Boolean) { if (end <= start) { return } val guard = document.createGuardedBlock(start, end) - guard.isGreedyToLeft = false - guard.isGreedyToRight = false + guard.isGreedyToLeft = greedyToLeft + guard.isGreedyToRight = greedyToRight guards += guard } @@ -256,7 +262,7 @@ class AnnotaterJavadocInlineEditor private constructor( }, ) - inlay = EditorEmbeddedComponentManager.getInstance().addComponent( + val inlay = EditorEmbeddedComponentManager.getInstance().addComponent( host, component, EditorEmbeddedComponentManager.Properties( @@ -270,6 +276,7 @@ class AnnotaterJavadocInlineEditor private constructor( offset, ), ) + inlay?.let { Disposer.register(this, it) } // Finish editing when the Minecraft class is closed EditorFactory.getInstance().addEditorFactoryListener( @@ -351,7 +358,8 @@ class AnnotaterJavadocInlineEditor private constructor( */ fun save(): CompletableFuture { ApplicationManager.getApplication().assertIsDispatchThread() - alarm.cancelAllRequests() + saveJob?.cancel() + saveJob = null if (!dirty || project.isDisposed) { return CompletableFuture.completedFuture(file.error == null) } @@ -405,7 +413,9 @@ class AnnotaterJavadocInlineEditor private constructor( store.invalidate(outerClass) if (store.get(target) != javadoc) { store.setAll(mapOf(target to javadoc)) - LocalFileSystem.getInstance().refreshNioFiles(listOf(store.fileFor(outerClass))) + // Refreshing only the file wouldn't notice it if the VFS hasn't loaded it yet, such as when it's created + val mappingFile = store.fileFor(outerClass).toFile() + VfsUtil.markDirtyAndRefresh(false, false, true, mappingFile.parentFile, mappingFile) ApplicationManager.getApplication().messageBus.syncPublisher(AnnotaterListener.TOPIC) .javadocsChanged(checkout.root, outerClass) } @@ -453,15 +463,14 @@ class AnnotaterJavadocInlineEditor private constructor( if (wasCurrent) { project.putUserData(CURRENT_KEY, null) } - inlay?.let(Disposer::dispose) - inlay = null + saveJob?.cancel() + saveJob = null editor?.let(EditorFactory.getInstance()::releaseEditor) editor = null for (guard in guards) { document.removeGuardedBlock(guard) } guards.clear() - EditorActionManager.getInstance().setReadonlyFragmentModificationHandler(document, null) if (wasCurrent && !project.isDisposed) { restartHostHighlighting() EditorNotifications.getInstance(project).updateAllNotifications() @@ -470,7 +479,7 @@ class AnnotaterJavadocInlineEditor private constructor( } companion object { - private const val SAVE_DELAY_MS = 750 + private const val SAVE_DELAY_MS = 750L private val CURRENT_KEY = Key.create("mcdev.annotater.javadocInlineEditor") @@ -483,10 +492,12 @@ class AnnotaterJavadocInlineEditor private constructor( fun current(project: Project): AnnotaterJavadocInlineEditor? = project.getUserData(CURRENT_KEY) /** - * The parent of the inline editors of a project, so they're disposed with it. + * The parent of the inline editors of a project, so they're disposed with it, and the scope of their + * coroutines. Plugins shouldn't use the project itself as a parent disposable, as that leaks the plugin's + * classes if it's unloaded while the project is open. */ @Service(Service.Level.PROJECT) - private class ProjectDisposable : Disposable { + private class ProjectScope(val coroutineScope: CoroutineScope) : Disposable { override fun dispose() { } } @@ -521,7 +532,7 @@ class AnnotaterJavadocInlineEditor private constructor( return null } project.putUserData(CURRENT_KEY, inlineEditor) - Disposer.register(project.service(), inlineEditor) + Disposer.register(project.service(), inlineEditor) EditorNotifications.getInstance(project).updateAllNotifications() return inlineEditor } diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocRefresher.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocRefresher.kt index 0d5bc2ee0..b4a98a4fc 100644 --- a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocRefresher.kt +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocRefresher.kt @@ -25,7 +25,7 @@ import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterListener import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.project.ProjectManager -import com.intellij.openapi.vfs.newvfs.BulkFileListener +import com.intellij.openapi.vfs.newvfs.BulkFileListenerBackgroundable import com.intellij.openapi.vfs.newvfs.events.VFileEvent import com.intellij.ui.EditorNotifications import java.nio.file.Path @@ -33,7 +33,7 @@ import java.nio.file.Path /** * Refreshes the places annotater Javadocs are displayed when they change, whether through the plugin or on disk. */ -class AnnotaterJavadocRefresher : AnnotaterListener, BulkFileListener { +class AnnotaterJavadocRefresher : AnnotaterListener, BulkFileListenerBackgroundable { override fun checkoutsChanged() { refresh(updateNotifications = true) } diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocRendering.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocRendering.kt index 2e382d214..2f72d7eec 100644 --- a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocRendering.kt +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocRendering.kt @@ -24,14 +24,10 @@ import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterCheckout import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterCheckoutService import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterSettings import com.intellij.lang.java.JavaDocumentationProvider -import com.intellij.openapi.diagnostic.thisLogger -import com.intellij.psi.PsiClass import com.intellij.psi.PsiCompiledElement import com.intellij.psi.PsiJavaDocumentedElement -import com.intellij.psi.PsiJavaFile import com.intellij.psi.PsiMember import com.intellij.psi.util.PsiTreeUtil -import java.io.IOException /** * Renders annotater Javadocs with the IDE's own Java documentation renderer, by rendering the declaration in a copy of @@ -49,15 +45,6 @@ object AnnotaterJavadocRendering { return AnnotaterCheckoutService.instance.checkoutFor(member) } - /** - * All classes, fields and methods declared in [file] and its inner classes. - */ - fun displayedMembers(file: PsiJavaFile): Sequence = members(file.classes) - - private fun members(classes: Array): Sequence = classes.asSequence().flatMap { clazz -> - sequenceOf(clazz) + clazz.fields + clazz.methods + members(clazz.innerClasses) - } - /** * The offset that the annotater Javadoc of [member] is displayed above: its declaration, after any existing * doc comment. @@ -68,15 +55,6 @@ object AnnotaterJavadocRendering { ?: member.textRange.startOffset } - fun getJavadoc(checkout: AnnotaterCheckout, target: AnnotaterJavadocTarget): String? { - return try { - if (checkout.javadocStore.hasFile(target.outerClass)) checkout.javadocStore.get(target) else null - } catch (e: IOException) { - thisLogger().info("Failed to read annotater Javadoc of $target", e) - null - } - } - /** * Finds the declaration of [member] in a copy of its source file with [javadoc] as its doc comment. */ diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocSourceCopy.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocSourceCopy.kt index 38f398897..8e45c4c45 100644 --- a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocSourceCopy.kt +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocSourceCopy.kt @@ -33,7 +33,6 @@ import com.intellij.psi.PsiJavaFile import com.intellij.psi.PsiManager import com.intellij.psi.PsiMember import com.intellij.psi.PsiWhiteSpace -import com.intellij.psi.impl.compiled.ClsElementImpl import com.intellij.psi.javadoc.PsiDocComment import com.intellij.psi.util.PsiTreeUtil import com.intellij.psi.util.PsiUtil @@ -44,13 +43,18 @@ import com.intellij.testFramework.LightVirtualFile * existing Javadocs replaced by the annotater Javadocs as Markdown doc comments. This lets the IDE's own Java support * be used to edit and render annotater Javadocs in their real context. * - * The member at index `i` of [members] documents `targets[i]`, or nothing if that is null, e.g. for members of other + * The member at index `i` of [AnnotaterJavadocTarget.declarations] documents `targets[i]`, or nothing if that is null, e.g. for members of other * top level classes in the same file. */ class AnnotaterJavadocSourceCopy( val fileName: String, val text: String, val targets: List, + /** + * The language level to parse the copy at: that of the source file, but at least the first that supports Markdown + * doc comments. + */ + val languageLevel: LanguageLevel, ) { /** * The internal name of the top level class whose Javadocs this copy edits. @@ -62,9 +66,8 @@ class AnnotaterJavadocSourceCopy( return PsiManager.getInstance(project).findFile(createVirtualFile()) as? PsiJavaFile } - fun createVirtualFile(): LightVirtualFile = LightVirtualFile(fileName, JavaFileType.INSTANCE, text).also { - // Markdown doc comments are only parsed at a high enough language level - it.putUserData(PsiUtil.FILE_LANGUAGE_LEVEL_KEY, LanguageLevel.HIGHEST) + private fun createVirtualFile(): LightVirtualFile = LightVirtualFile(fileName, JavaFileType.INSTANCE, text).also { + it.putUserData(PsiUtil.FILE_LANGUAGE_LEVEL_KEY, languageLevel) } /** @@ -72,14 +75,14 @@ class AnnotaterJavadocSourceCopy( */ fun memberOf(file: PsiJavaFile, target: AnnotaterJavadocTarget): PsiMember? { val index = targets.indexOf(target) - return if (index < 0) null else members(file).getOrNull(index) + return if (index < 0) null else AnnotaterJavadocTarget.declarations(file).getOrNull(index) } /** * Finds the target documented by [member] of [file], which must have been created from this copy. */ fun targetOf(file: PsiJavaFile, member: PsiMember): AnnotaterJavadocTarget? { - val index = members(file).indexOf(member) + val index = AnnotaterJavadocTarget.declarations(file).indexOf(member) return if (index < 0) null else targets.getOrNull(index) } @@ -109,7 +112,7 @@ class AnnotaterJavadocSourceCopy( } } - val members = members(file) + val members = AnnotaterJavadocTarget.declarations(file) if (members.size != targets.size) { return ExtractResult.Error(MCDevBundle("fabric.annotater.javadoc.error.structure_changed")) } @@ -123,22 +126,11 @@ class AnnotaterJavadocSourceCopy( } companion object { - /** - * Finds the classes, fields and methods declared in [file], in the order matching [targets]. Local and - * anonymous classes are not included, and neither are members that don't appear in the source, such as the - * implicit methods of records. - */ - fun members(file: PsiJavaFile): List = members(file.classes).toList() - - private fun members(classes: Array): Sequence = classes.asSequence().flatMap { clazz -> - sequenceOf(clazz) + clazz.fields + clazz.methods + members(clazz.innerClasses) - }.filter { it.node != null } - /** * Extracts the Markdown text of a `///` doc comment, removing the `///` prefixes and the common indentation. * Returns null when the comment is blank. */ - fun extractMarkdown(comment: PsiDocComment): String? { + private fun extractMarkdown(comment: PsiDocComment): String? { val lines = comment.text.lines().map { line -> val trimmed = line.trimStart() if (trimmed.startsWith("///")) trimmed.substring(3) else trimmed @@ -153,7 +145,7 @@ class AnnotaterJavadocSourceCopy( /** * Formats Markdown as a `///` doc comment with the given indentation, ending with a newline. */ - fun formatMarkdown(markdown: String, indent: String): String { + private fun formatMarkdown(markdown: String, indent: String): String { return markdown.lines().joinToString("") { line -> if (line.isEmpty()) "$indent///\n" else "$indent/// $line\n" } @@ -163,7 +155,7 @@ class AnnotaterJavadocSourceCopy( * Formats Markdown as a `///` doc comment to insert at a column after [indent], followed by [indent] again for * the declaration after it. */ - fun formatInsertedMarkdown(markdown: String, indent: String): String { + private fun formatInsertedMarkdown(markdown: String, indent: String): String { // Leave a space after an empty first line, so the user can start typing there straight away return "/// " + formatMarkdown(markdown, indent).removePrefix("$indent///").removePrefix(" ") + indent } @@ -188,8 +180,9 @@ class AnnotaterJavadocSourceCopy( placeholderFor: AnnotaterJavadocTarget? = null, javadoc: (AnnotaterJavadocTarget) -> String?, ): AnnotaterJavadocSourceCopy? { - val outermost = generateSequence(element as? PsiClass ?: element.containingClass) { it.containingClass } - .lastOrNull() ?: return null + val outermost = PsiTreeUtil.getTopmostParentOfType(element, PsiClass::class.java) + ?: element as? PsiClass + ?: return null val outerClass = AnnotaterJavadocTarget.fromPsi(outermost)?.outerClass ?: return null val source = sourceFile(outermost) ?: return null val text = source.text @@ -197,10 +190,8 @@ class AnnotaterJavadocSourceCopy( val targets = mutableListOf() // The replacements of the existing doc comments, in text order val replacements = mutableListOf>() - for (member in members(source)) { - // Decompiled text is generated from the compiled class, which is more reliable to get the target of - val compiled = member.getUserData(ClsElementImpl.COMPILED_ELEMENT) ?: member - val target = AnnotaterJavadocTarget.fromPsi(compiled)?.takeIf { it.outerClass == outerClass } + for (member in AnnotaterJavadocTarget.declarations(source)) { + val target = AnnotaterJavadocTarget.fromPsi(member)?.takeIf { it.outerClass == outerClass } val existing = (member as PsiJavaDocumentedElement).docComment val start = existing?.textRange?.startOffset ?: member.textRange.startOffset val lineStart = text.lastIndexOf('\n', start - 1) + 1 @@ -227,7 +218,10 @@ class AnnotaterJavadocSourceCopy( for ((range, replacement) in replacements.sortedByDescending { it.first.startOffset }) { sb.replace(range.startOffset, range.endOffset, replacement) } - return AnnotaterJavadocSourceCopy("${outermost.name}.java", sb.toString(), targets) + // Need at least Java 23 to parse Markdown Javadocs + val languageLevel = PsiUtil.getLanguageLevel(source).takeIf { it.isAtLeast(LanguageLevel.JDK_23) } + ?: LanguageLevel.JDK_23 + return AnnotaterJavadocSourceCopy("${outermost.name}.java", sb.toString(), targets, languageLevel) } } } diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocStore.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocStore.kt index 1c2411c99..9a78973e9 100644 --- a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocStore.kt +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocStore.kt @@ -20,6 +20,7 @@ package com.demonwav.mcdev.platform.fabric.annotater.javadoc +import com.intellij.openapi.diagnostic.thisLogger import java.io.IOException import java.io.StringReader import java.io.StringWriter @@ -33,6 +34,7 @@ import kotlin.io.path.writeText import net.fabricmc.mappingio.MappedElementKind import net.fabricmc.mappingio.MappingUtil import net.fabricmc.mappingio.MappingVisitor +import net.fabricmc.mappingio.adapter.EmptyElementFilter import net.fabricmc.mappingio.adapter.ForwardingMappingVisitor import net.fabricmc.mappingio.format.enigma.EnigmaFileReader import net.fabricmc.mappingio.format.enigma.EnigmaFileWriter @@ -42,16 +44,17 @@ import net.fabricmc.mappingio.tree.VisitOrder /** * Reads and writes the Javadoc comments stored in an annotater `mappings` directory. Comments are stored in Enigma - * format, with one file per top level class at `/.mapping`. The reading and writing - * matches mcsrc.dev so that files edited by either tool produce the same diffs. + * format, with one file per top level class at `/.mapping`. The files are written + * in the same way as annotater formats them, so that saving doesn't reformat unrelated parts of a file. + * + * Use [forDirectory] rather than the constructor outside of tests, so that every checkout of a directory sees the + * comments the others write. */ class AnnotaterJavadocStore(val mappingsDir: Path) { private val cache = ConcurrentHashMap() fun fileFor(outerClass: String): Path = mappingsDir.resolve("$outerClass.mapping") - fun hasFile(outerClass: String): Boolean = fileFor(outerClass).exists() - @Throws(IOException::class) fun get(target: AnnotaterJavadocTarget): String? { val clazz = treeFor(target.outerClass).getClass(target.owner) ?: return null @@ -62,6 +65,18 @@ class AnnotaterJavadocStore(val mappingsDir: Path) { } } + /** + * Like [get], but returns null if the `.mapping` file can't be read, for displaying Javadocs. + */ + fun getOrNull(target: AnnotaterJavadocTarget): String? { + return try { + if (fileFor(target.outerClass).exists()) get(target) else null + } catch (e: IOException) { + thisLogger().info("Failed to read annotater Javadoc of $target", e) + null + } + } + /** * Sets the comment for [target], or removes it when [comment] is null or empty, and writes the `.mapping` file * of its outer class. The file is deleted when it no longer contains any comments. @@ -103,10 +118,6 @@ class AnnotaterJavadocStore(val mappingsDir: Path) { cache.remove(outerClass) } - fun invalidateAll() { - cache.clear() - } - @Throws(IOException::class) private fun treeFor(outerClass: String): MemoryMappingTree { cache[outerClass]?.let { return it } @@ -116,8 +127,27 @@ class AnnotaterJavadocStore(val mappingsDir: Path) { } companion object { + private val stores = ConcurrentHashMap() + + /** + * The store of [mappingsDir], shared by all of its checkouts. The checkout objects are replaced whenever the + * checkouts change, such as when a contribution branch is created on the first edit, but an editor started + * before that keeps saving through the old one. + */ + fun forDirectory(mappingsDir: Path): AnnotaterJavadocStore = + stores.computeIfAbsent(mappingsDir.normalize(), ::AnnotaterJavadocStore) + + /** + * Forgets the cached comments of every store, for when the checkouts change, such as by switching branch. + */ + fun invalidateAll() { + for (store in stores.values) { + store.cache.clear() + } + } + @Throws(IOException::class) - fun readFile(text: String, path: String, expectedOuterClass: String? = null): MemoryMappingTree { + private fun readFile(text: String, path: String, expectedOuterClass: String? = null): MemoryMappingTree { val tree = MemoryMappingTree() try { EnigmaFileReader.read(StringReader(text), withoutMethodVariables(tree)) @@ -138,7 +168,7 @@ class AnnotaterJavadocStore(val mappingsDir: Path) { } @Throws(IOException::class) - fun writeTree(tree: MemoryMappingTree): String { + private fun writeTree(tree: MemoryMappingTree): String { val meaningful = newTree() for (clazz in tree.classes) { if (isMeaningful(clazz)) { @@ -150,8 +180,9 @@ class AnnotaterJavadocStore(val mappingsDir: Path) { } val output = StringWriter() + // The same as annotater's own formatting EnigmaFileWriter(output).use { writer -> - meaningful.accept(writer, VisitOrder.createByName().classesBySrcNameShortFirst()) + meaningful.accept(EmptyElementFilter(writer), VisitOrder.createByName()) } return output.toString() } diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocTarget.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocTarget.kt index 43fb33b4e..39ba4f6cf 100644 --- a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocTarget.kt +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocTarget.kt @@ -22,10 +22,17 @@ package com.demonwav.mcdev.platform.fabric.annotater.javadoc import com.demonwav.mcdev.util.descriptor import com.demonwav.mcdev.util.internalName +import com.intellij.psi.PsiAnonymousClass import com.intellij.psi.PsiClass +import com.intellij.psi.PsiCompiledElement import com.intellij.psi.PsiElement import com.intellij.psi.PsiField +import com.intellij.psi.PsiJavaFile +import com.intellij.psi.PsiMember import com.intellij.psi.PsiMethod +import com.intellij.psi.PsiTypeParameter +import com.intellij.psi.SyntaxTraverser +import com.intellij.psi.impl.compiled.ClsElementImpl /** * A class, field or method that can have a Javadoc comment in annotater. All names are internal (slash separated) @@ -57,34 +64,55 @@ sealed class AnnotaterJavadocTarget { ) : AnnotaterJavadocTarget() companion object { + /** + * The target documented by [element], which may be an element of a class file's decompiled text. Anonymous + * classes can't be documented, but their members can. + */ fun fromPsi(element: PsiElement): AnnotaterJavadocTarget? { - return when (element) { - is PsiClass -> { - if (element.qualifiedName == null) { - // Anonymous and local classes can't be documented - return null - } - ClassTarget(element.internalName ?: return null) - } - + // Decompiled text is generated from the compiled class, which is more reliable to get the target of + val compiled = element.getUserData(ClsElementImpl.COMPILED_ELEMENT) ?: element + if (compiled !is PsiMember || !isDocumentable(compiled)) { + return null + } + return when (compiled) { + is PsiClass -> ClassTarget(compiled.internalName ?: return null) is PsiField -> { - val owner = element.containingClass?.takeIf { it.qualifiedName != null } ?: return null - FieldTarget(owner.internalName ?: return null, element.name, element.descriptor ?: return null) + val owner = compiled.containingClass?.internalName ?: return null + FieldTarget(owner, compiled.name, compiled.descriptor ?: return null) } is PsiMethod -> { - val owner = element.containingClass?.takeIf { it.qualifiedName != null } ?: return null - MethodTarget( - owner.internalName ?: return null, - element.internalName, - element.descriptor ?: return null - ) + val owner = compiled.containingClass?.internalName ?: return null + MethodTarget(owner, compiled.internalName, compiled.descriptor ?: return null) } else -> null } } + /** + * The classes, fields and methods declared in the source of [file] in text order, including those in local + * and anonymous classes, but not members that don't appear in the source, such as the implicit methods of + * records. For a class file, these are the declarations in its decompiled text. + */ + fun declarations(file: PsiJavaFile): List { + val source = (if (file is PsiCompiledElement) file.mirror else file) as? PsiJavaFile ?: return emptyList() + return SyntaxTraverser.psiTraverser(source) + .filter(PsiMember::class.java) + .filter(::isDocumentable) + .toList() + } + + /** + * Whether [member] is a kind of declaration that can be documented: a class, field or method, but not a type + * parameter or anonymous class (including enum constant bodies), which are also [PsiClass]es. + */ + private fun isDocumentable(member: PsiMember): Boolean = when (member) { + is PsiTypeParameter, is PsiAnonymousClass -> false + is PsiClass, is PsiField, is PsiMethod -> true + else -> false + } + /** * Returns the top level class of a nested class name, treating `$` directly after a package separator or at * the start of a name as part of the class name, matching mcsrc. diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocUnqualifiedReferenceInspection.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocUnqualifiedReferenceInspection.kt index d43b7a8ac..db696d956 100644 --- a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocUnqualifiedReferenceInspection.kt +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocUnqualifiedReferenceInspection.kt @@ -53,7 +53,7 @@ class AnnotaterJavadocUnqualifiedReferenceInspection : LocalInspectionTool() { if (PsiTreeUtil.getParentOfType(reference, PsiDocComment::class.java) == null) { return } - val qualifiedName = qualifiedNameIfUnqualified(reference) ?: return + val qualifiedName = Util.qualifiedNameIfUnqualified(reference) ?: return holder.registerProblem( reference, MCDevBundle("fabric.annotater.inspection.unqualified.problem", qualifiedName), @@ -79,7 +79,7 @@ class AnnotaterJavadocUnqualifiedReferenceInspection : LocalInspectionTool() { } } - companion object { + private object Util { /** * Returns the fully qualified name of the class [reference] refers to, if it isn't already written as such. */ diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocVirtualFile.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocVirtualFile.kt index aace7bdf0..123adaa51 100644 --- a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocVirtualFile.kt +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocVirtualFile.kt @@ -22,8 +22,6 @@ package com.demonwav.mcdev.platform.fabric.annotater.javadoc import com.demonwav.mcdev.platform.fabric.annotater.AnnotaterCheckout import com.intellij.ide.highlighter.JavaFileType -import com.intellij.openapi.vfs.VirtualFile -import com.intellij.pom.java.LanguageLevel import com.intellij.psi.PsiFile import com.intellij.psi.util.PsiUtil import com.intellij.testFramework.LightVirtualFile @@ -47,7 +45,7 @@ class AnnotaterJavadocVirtualFile( get() = copy.outerClass init { - putUserData(PsiUtil.FILE_LANGUAGE_LEVEL_KEY, LanguageLevel.HIGHEST) + putUserData(PsiUtil.FILE_LANGUAGE_LEVEL_KEY, copy.languageLevel) } companion object { diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/EditAnnotaterJavadocAction.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/EditAnnotaterJavadocAction.kt index c9e2928b6..062303de3 100644 --- a/src/main/kotlin/platform/fabric/annotater/javadoc/EditAnnotaterJavadocAction.kt +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/EditAnnotaterJavadocAction.kt @@ -32,7 +32,7 @@ class EditAnnotaterJavadocAction : AnAction() { override fun update(e: AnActionEvent) { e.presentation.text = MCDevBundle("fabric.annotater.javadoc.edit") - e.presentation.icon = GeneralAssets.ANNOTATER + e.presentation.icon = GeneralAssets.TINY_POTATO val editor = e.getData(CommonDataKeys.EDITOR) val file = e.getData(CommonDataKeys.PSI_FILE) val project = e.project @@ -47,6 +47,6 @@ class EditAnnotaterJavadocAction : AnAction() { val project = e.project ?: return val editor = e.getData(CommonDataKeys.EDITOR) ?: return val file = e.getData(CommonDataKeys.PSI_FILE) ?: return - AnnotaterJavadocEditing.edit(project, editor, file) + AnnotaterJavadocEditing.open(project, editor, file) } } diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/EditAnnotaterJavadocIntention.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/EditAnnotaterJavadocIntention.kt index 2b5f32399..ccfab85b3 100644 --- a/src/main/kotlin/platform/fabric/annotater/javadoc/EditAnnotaterJavadocIntention.kt +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/EditAnnotaterJavadocIntention.kt @@ -45,7 +45,7 @@ class EditAnnotaterJavadocIntention : IntentionAction { if (editor == null || file == null) { return } - AnnotaterJavadocEditing.edit(project, editor, file) + AnnotaterJavadocEditing.open(project, editor, file) } override fun generatePreview(project: Project, editor: Editor, file: PsiFile): IntentionPreviewInfo = diff --git a/src/main/kotlin/util/McdevReflection.kt b/src/main/kotlin/util/McdevReflection.kt index 1dd20f251..6f8504ef2 100644 --- a/src/main/kotlin/util/McdevReflection.kt +++ b/src/main/kotlin/util/McdevReflection.kt @@ -22,10 +22,12 @@ package com.demonwav.mcdev.util import com.intellij.codeInsight.generation.ClassMember import com.intellij.codeInsight.generation.GenerateMembersHandlerBase +import com.intellij.codeInspection.LocalQuickFix import com.intellij.openapi.editor.Editor import com.intellij.openapi.project.Project import com.intellij.openapi.updateSettings.impl.PluginDownloader import com.intellij.psi.PsiClass +import java.lang.reflect.Constructor import java.lang.reflect.Field import java.lang.reflect.InvocationTargetException import java.lang.reflect.Method @@ -63,6 +65,14 @@ object McdevReflection { Array::class.java, ) + @JvmStatic + val CLASS_MARKDOWN_DOCUMENTATION_COMMENTS_MIGRATION_FIX: Class<*> = + Class.forName("com.siyeh.ig.migration.MarkdownDocumentationCommentsMigrationInspection\$MarkdownDocumentationCommentsMigrationFix") + + @JvmStatic + val CONSTRUCTOR_MARKDOWN_DOCUMENTATION_COMMENTS_MIGRATION_FIX: Constructor = + CLASS_MARKDOWN_DOCUMENTATION_COMMENTS_MIGRATION_FIX.getDeclaredConstructorEx() + @JvmStatic val FIELD_PLUGIN_DOWNLOADER_MY_FILE: Field = PluginDownloader::class.java.getDeclaredFieldEx("myFile", Path::class.java) @@ -79,6 +89,18 @@ object McdevReflection { return field } + /** + * Like [Class.getDeclaredConstructor], but makes the constructor accessible. + */ + private inline fun Class<*>.getDeclaredConstructorEx(vararg params: Class<*>): Constructor { + check(T::class.java.isAssignableFrom(this)) { + "Class ${this.name} is not a subclass of ${T::class.java.name}" + } + val constructor = this.asSubclass(T::class.java).getDeclaredConstructor(*params) + constructor.isAccessible = true + return constructor + } + /** * Like [Class.getDeclaredMethod], but makes the method accessible and checks that it has the expected [returnType]. */ @@ -91,6 +113,18 @@ object McdevReflection { return method } + /** + * Like [Constructor.newInstance], but rethrows the exception thrown by the invoked constructor rather than wrapping + * it in an [InvocationTargetException]. + */ + fun Constructor.newInstanceUnwrap(vararg args: Any?): T { + try { + return newInstance(*args) + } catch (e: InvocationTargetException) { + throw e.cause ?: e + } + } + /** * Like [Method.invoke], but rethrows the exception thrown by the invoked method rather than wrapping it in an * [InvocationTargetException]. diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 845ebf2da..2001daf4d 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -1722,7 +1722,7 @@ + topic="com.intellij.openapi.vfs.newvfs.BulkFileListenerBackgroundable"/> diff --git a/src/main/resources/assets/icons/general/Annotater.LICENSE.txt b/src/main/resources/assets/icons/general/tiny_potato.LICENSE.txt similarity index 66% rename from src/main/resources/assets/icons/general/Annotater.LICENSE.txt rename to src/main/resources/assets/icons/general/tiny_potato.LICENSE.txt index 0f87cc2d1..8765954e5 100644 --- a/src/main/resources/assets/icons/general/Annotater.LICENSE.txt +++ b/src/main/resources/assets/icons/general/tiny_potato.LICENSE.txt @@ -1,3 +1,3 @@ -Annotater.png, Annotater@2x.png, AnnotaterLarge.png and AnnotaterLarge@2x.png are downscaled from +tiny_potato.png, tiny_potato@2x.png, tiny_potato_large.png and tiny_potato_large@2x.png are downscaled from https://github.com/FabricMC/community/blob/main/media/tater-wall/png/potato.png by FabricMC, licensed under the Creative Commons Attribution 4.0 International license (https://creativecommons.org/licenses/by/4.0/). diff --git a/src/main/resources/assets/icons/general/Annotater.png b/src/main/resources/assets/icons/general/tiny_potato.png similarity index 100% rename from src/main/resources/assets/icons/general/Annotater.png rename to src/main/resources/assets/icons/general/tiny_potato.png diff --git a/src/main/resources/assets/icons/general/Annotater@2x.png b/src/main/resources/assets/icons/general/tiny_potato@2x.png similarity index 100% rename from src/main/resources/assets/icons/general/Annotater@2x.png rename to src/main/resources/assets/icons/general/tiny_potato@2x.png diff --git a/src/main/resources/assets/icons/general/AnnotaterLarge.png b/src/main/resources/assets/icons/general/tiny_potato_large.png similarity index 100% rename from src/main/resources/assets/icons/general/AnnotaterLarge.png rename to src/main/resources/assets/icons/general/tiny_potato_large.png diff --git a/src/main/resources/assets/icons/general/AnnotaterLarge@2x.png b/src/main/resources/assets/icons/general/tiny_potato_large@2x.png similarity index 100% rename from src/main/resources/assets/icons/general/AnnotaterLarge@2x.png rename to src/main/resources/assets/icons/general/tiny_potato_large@2x.png diff --git a/src/main/resources/intentionDescriptions/EditAnnotaterJavadocIntention/after.java.template b/src/main/resources/intentionDescriptions/EditAnnotaterJavadocIntention/after.java.template new file mode 100644 index 000000000..07048b40a --- /dev/null +++ b/src/main/resources/intentionDescriptions/EditAnnotaterJavadocIntention/after.java.template @@ -0,0 +1,6 @@ +/// Clamps a value between a minimum and a maximum. +/// +/// @return the clamped value +public static int clamp(int value, int min, int max) { + return Math.min(Math.max(value, min), max); +} diff --git a/src/main/resources/intentionDescriptions/EditAnnotaterJavadocIntention/before.java.template b/src/main/resources/intentionDescriptions/EditAnnotaterJavadocIntention/before.java.template new file mode 100644 index 000000000..4d9aed0f1 --- /dev/null +++ b/src/main/resources/intentionDescriptions/EditAnnotaterJavadocIntention/before.java.template @@ -0,0 +1,3 @@ +public static int clamp(int value, int min, int max) { + return Math.min(Math.max(value, min), max); +} diff --git a/src/main/resources/messages/MinecraftDevelopment.properties b/src/main/resources/messages/MinecraftDevelopment.properties index 37ce3d450..74bf38aa0 100644 --- a/src/main/resources/messages/MinecraftDevelopment.properties +++ b/src/main/resources/messages/MinecraftDevelopment.properties @@ -406,6 +406,9 @@ fabric.annotater.settings.display_name=Annotater fabric.annotater.settings.title=Annotater fabric.annotater.settings.enabled=Enable annotater Javadoc editing fabric.annotater.settings.enabled.comment=Edit Minecraft Javadocs for annotater with the Edit Annotater Javadoc action on Minecraft classes, fields and methods, or the annotater gutter icon on declarations that already have an annotater Javadoc. +fabric.annotater.settings.guidelines=Please follow annotater''s {0} when writing Javadocs and opening pull requests. +fabric.annotater.settings.guidelines.link=contribution guidelines +fabric.annotater.settings.guidelines.link.version=contribution guidelines for {0} fabric.annotater.settings.show_javadocs=Show annotater Javadocs instead of existing Minecraft Javadocs fabric.annotater.settings.show_javadocs.comment=Applies to quick documentation, decompiled classes and attached Minecraft sources. Rendered doc comments (reader mode) are turned off in Minecraft classes. fabric.annotater.settings.managed=Clone annotater automatically @@ -434,6 +437,7 @@ fabric.annotater.uncommitted_changes=The annotater checkout has uncommitted chan fabric.annotater.preparing=Preparing annotater checkout fabric.annotater.preparing.failed=Failed to prepare the annotater checkout fabric.annotater.create_pull_request=Create Pull Request... +fabric.annotater.update_pull_request=Update Pull Request... fabric.annotater.dropdown={0} \u25BE fabric.annotater.menu=Annotater fabric.annotater.contribution.tooltip=Switch to another contribution, for example to update a pull request after review @@ -443,6 +447,15 @@ fabric.annotater.contribution.switching=Switching annotater contribution fabric.annotater.contribution.failed=Failed to switch annotater contribution fabric.annotater.contribution.save_failed=Fix the errors in the open annotater editors before switching contribution. fabric.annotater.contribution.commit=The changes to "{0}" will be committed before switching. Commit message\: +fabric.annotater.contribution.delete=Delete Contribution +fabric.annotater.contribution.delete.title=Delete Contribution +fabric.annotater.contribution.delete.ok=Delete +fabric.annotater.contribution.delete.confirm=Delete the contribution "{0}"? It is only deleted locally. Any branch pushed to GitHub and any pull request for it are kept. +fabric.annotater.contribution.delete.confirm.current=This contribution is checked out, so its uncommitted changes will be lost. +fabric.annotater.contribution.deleting=Deleting annotater contribution +fabric.annotater.contribution.deleted=Annotater contribution deleted +fabric.annotater.contribution.deleted.content=Deleted the contribution "{0}". +fabric.annotater.contribution.delete.failed=Failed to delete annotater contribution fabric.annotater.status.disabled=Annotater is disabled in the Minecraft Development settings. fabric.annotater.status.unknown_version=The Minecraft version of this project is unknown. fabric.annotater.status.not_cloned=Annotater has not been cloned for Minecraft {0} yet. @@ -452,7 +465,6 @@ fabric.annotater.status.invalid=Invalid annotater directory\: {0} fabric.annotater.javadoc.edit=Edit Annotater Javadoc fabric.annotater.javadoc.open_failed=Failed to open annotater Javadoc -fabric.annotater.javadoc.read_only=Only the Javadoc comment can be edited. fabric.annotater.banner=Annotater Javadocs for Minecraft {0} from {1}. Use Edit Annotater Javadoc on a class, field or method to edit its Javadoc. Changes are saved automatically. fabric.annotater.javadoc.finish=Finish editing fabric.annotater.javadoc.save_failed=Failed to save annotater Javadoc @@ -474,16 +486,25 @@ fabric.annotater.pr.no_account=Log in to a GitHub.com account in the GitHub sett fabric.annotater.pr.failed=Failed to create annotater pull request fabric.annotater.pr.no_base_branch={0} has no {1} branch to open a pull request against. fabric.annotater.pr.creating=Creating annotater pull request +fabric.annotater.pr.updating=Updating annotater pull request fabric.annotater.pr.committing=Committing changes... fabric.annotater.pr.forking=Forking annotater... fabric.annotater.pr.pushing=Pushing changes... fabric.annotater.pr.opening=Opening pull request... fabric.annotater.pr.created=Annotater pull request created +fabric.annotater.pr.updated=Annotater pull request updated fabric.annotater.pr.open=Open in Browser fabric.annotater.pr.start_new=Start New Contribution fabric.annotater.pr.dialog.ok=Create Pull Request +fabric.annotater.pr.dialog.update=Update Pull Request fabric.annotater.pr.dialog.account=GitHub account\: fabric.annotater.pr.dialog.title=Title\: fabric.annotater.pr.dialog.description=Description\: fabric.annotater.pr.dialog.commit_message=Commit message\: fabric.annotater.pr.dialog.changes=Changed files\:
{0} +fabric.annotater.pr.dialog.check_build=Check the build first +fabric.annotater.pr.dialog.check_build.comment=Runs gradlew check in the annotater checkout, which can take a minute. +fabric.annotater.pr.check_build.progress=Checking the annotater build +fabric.annotater.pr.check_build.failed=Annotater Build Check Failed +fabric.annotater.pr.check_build.failed.message=The annotater build check failed. Fix the problems below, or submit anyway. +fabric.annotater.pr.check_build.submit_anyway=Submit Anyway diff --git a/src/test/kotlin/platform/fabric/annotater/AnnotaterJavadocEditorTest.kt b/src/test/kotlin/platform/fabric/annotater/AnnotaterJavadocEditorTest.kt index 865828f04..f8f721f2a 100644 --- a/src/test/kotlin/platform/fabric/annotater/AnnotaterJavadocEditorTest.kt +++ b/src/test/kotlin/platform/fabric/annotater/AnnotaterJavadocEditorTest.kt @@ -30,9 +30,9 @@ import com.demonwav.mcdev.platform.fabric.annotater.javadoc.AnnotaterJavadocTarg import com.demonwav.mcdev.platform.fabric.annotater.javadoc.AnnotaterJavadocUnqualifiedReferenceInspection import com.demonwav.mcdev.platform.fabric.annotater.javadoc.AnnotaterJavadocVirtualFile import com.intellij.codeInsight.intention.IntentionAction +import com.intellij.codeInsight.lookup.Lookup import com.intellij.ide.DataManager import com.intellij.ide.impl.HeadlessDataManager -import com.intellij.codeInsight.lookup.Lookup import com.intellij.lang.annotation.HighlightSeverity import com.intellij.openapi.actionSystem.PlatformCoreDataKeys import com.intellij.openapi.command.WriteCommandAction @@ -96,6 +96,25 @@ class AnnotaterJavadocEditorTest : BaseMinecraftTest() { } """.trimIndent(), ) + java( + "net/minecraft/util/Ticker.java", + """ + package net.minecraft.util; + + public class Ticker { + public void tick() { + class Step { + int count; + } + Runnable runnable = new Runnable() { + @Override + public void run() { + } + }; + } + } + """.trimIndent(), + ) java( "net/minecraft/world/level/Level.java", """ @@ -146,7 +165,25 @@ class AnnotaterJavadocEditorTest : BaseMinecraftTest() { copy.text, ) assertEquals("Mth.java", copy.fileName) - assertEquals(listOf(AnnotaterJavadocTarget.ClassTarget("net/minecraft/util/Mth"), pi, clamp), copy.targets) + assertEquals(listOf(AnnotaterJavadocTarget.ClassTarget("net/minecraft/util/Mth"), clamp, pi), copy.targets) + } + + @Test + @DisplayName("Local classes and members of local and anonymous classes can be documented") + fun localAndAnonymousClasses() { + val copy = AnnotaterJavadocSourceCopy.create(findMember("net.minecraft.util.Ticker")) { null }!! + assertEquals( + listOf( + AnnotaterJavadocTarget.ClassTarget("net/minecraft/util/Ticker"), + AnnotaterJavadocTarget.MethodTarget("net/minecraft/util/Ticker", "tick", "()V"), + AnnotaterJavadocTarget.ClassTarget("net/minecraft/util/Ticker\$1Step"), + AnnotaterJavadocTarget.FieldTarget("net/minecraft/util/Ticker\$1Step", "count", "I"), + AnnotaterJavadocTarget.MethodTarget("net/minecraft/util/Ticker\$1", "run", "()V"), + ), + // Type parameters and anonymous classes are also PsiClasses, but aren't declarations that can be documented + copy.targets, + ) + assertEquals("net/minecraft/util/Ticker", copy.outerClass) } @Test @@ -185,6 +222,7 @@ class AnnotaterJavadocEditorTest : BaseMinecraftTest() { copy.fileName, copy.text.replace("/// Clamps a value.", "/** Clamps a value. */"), copy.targets, + copy.languageLevel, ) val result = changed.extractJavadocs(changed.createPsiFile(project)!!) assertInstanceOf(AnnotaterJavadocSourceCopy.ExtractResult.Error::class.java, result) From 9d90433eccd264b57a7114bedcb1ff530f1bff2e Mon Sep 17 00:00:00 2001 From: joe Date: Wed, 11 Nov 2026 03:18:59 +0000 Subject: [PATCH 3/5] Remove excessive Javadocs --- .../fabric/annotater/AnnotaterCheckout.kt | 2 -- .../annotater/AnnotaterCheckoutService.kt | 23 ++--------------- .../fabric/annotater/AnnotaterConfig.kt | 3 --- .../fabric/annotater/AnnotaterConfigurable.kt | 9 ++----- .../platform/fabric/annotater/AnnotaterVcs.kt | 16 +++--------- .../fabric/annotater/git/AnnotaterGitVcs.kt | 25 +++---------------- .../annotater/github/AnnotaterBuildCheck.kt | 3 --- .../github/AnnotaterGitHubPullRequests.kt | 4 --- .../github/AnnotaterPullRequestDialog.kt | 5 +--- .../javadoc/AnnotaterJavadocContributions.kt | 21 ---------------- .../javadoc/AnnotaterJavadocEditing.kt | 12 ++------- ...otaterJavadocEditorNotificationProvider.kt | 3 --- .../AnnotaterJavadocInlayPassFactory.kt | 7 ------ .../javadoc/AnnotaterJavadocInlayRenderer.kt | 6 ----- .../javadoc/AnnotaterJavadocInlineEditor.kt | 24 ++++-------------- .../javadoc/AnnotaterJavadocRefresher.kt | 3 --- .../javadoc/AnnotaterJavadocRendering.kt | 10 -------- .../javadoc/AnnotaterJavadocSourceCopy.kt | 14 +++-------- .../javadoc/AnnotaterJavadocStore.kt | 12 +++------ .../javadoc/AnnotaterJavadocTarget.kt | 7 +++--- ...erJavadocUnqualifiedReferenceInspection.kt | 3 --- .../javadoc/AnnotaterJavadocVirtualFile.kt | 3 --- 22 files changed, 29 insertions(+), 186 deletions(-) diff --git a/src/main/kotlin/platform/fabric/annotater/AnnotaterCheckout.kt b/src/main/kotlin/platform/fabric/annotater/AnnotaterCheckout.kt index 6d9f3e4cc..0db07f545 100644 --- a/src/main/kotlin/platform/fabric/annotater/AnnotaterCheckout.kt +++ b/src/main/kotlin/platform/fabric/annotater/AnnotaterCheckout.kt @@ -24,8 +24,6 @@ import com.demonwav.mcdev.platform.fabric.annotater.javadoc.AnnotaterJavadocStor import java.nio.file.Path /** - * An annotater checkout on disk for one Minecraft version. - * * @property managed whether the plugin created this checkout and may run git operations in it */ class AnnotaterCheckout(val root: Path, val config: AnnotaterConfig, val managed: Boolean) { diff --git a/src/main/kotlin/platform/fabric/annotater/AnnotaterCheckoutService.kt b/src/main/kotlin/platform/fabric/annotater/AnnotaterCheckoutService.kt index b86172f4b..b298c8af0 100644 --- a/src/main/kotlin/platform/fabric/annotater/AnnotaterCheckoutService.kt +++ b/src/main/kotlin/platform/fabric/annotater/AnnotaterCheckoutService.kt @@ -39,9 +39,6 @@ import java.util.concurrent.ConcurrentHashMap import kotlin.io.path.Path import kotlin.io.path.exists -/** - * Finds the annotater checkout to use for Minecraft classes. - */ @Service(Service.Level.APP) class AnnotaterCheckoutService { private val checkouts = ConcurrentHashMap() @@ -68,7 +65,7 @@ class AnnotaterCheckoutService { } /** - * Finds the checkout for [version] according to the user's settings, ignoring whether the feature is enabled. + * Ignores whether annotater is enabled. */ private fun statusForVersion(version: String): Status { val settings = AnnotaterSettings.instance @@ -111,10 +108,6 @@ class AnnotaterCheckoutService { return statusForVersion(version) } - /** - * The checkout to use for [element], or null if annotater is disabled, [element] isn't part of Minecraft, or no - * checkout exists for its Minecraft version. - */ fun checkoutFor(element: PsiElement): AnnotaterCheckout? { if (!AnnotaterSettings.instance.enabled) { return null @@ -123,10 +116,6 @@ class AnnotaterCheckoutService { return checkoutFor(element.project, file) } - /** - * The checkout to use for [file] in [project], or null if annotater is disabled, [file] isn't part of Minecraft, - * or no checkout exists for its Minecraft version. - */ fun checkoutFor(project: Project, file: VirtualFile): AnnotaterCheckout? { if (!AnnotaterSettings.instance.enabled || !isMinecraftFile(project, file)) { return null @@ -175,10 +164,6 @@ class AnnotaterCheckoutService { val instance: AnnotaterCheckoutService get() = ApplicationManager.getApplication().getService(AnnotaterCheckoutService::class.java) - /** - * Whether any module of [project] is a Fabric mod. Annotater is a Fabric project, so it's offered more - * prominently there. - */ fun isFabricProject(project: Project): Boolean { return ModuleManager.getInstance(project).modules.any { MinecraftFacet.getInstance(it, FabricModuleType) != null @@ -186,14 +171,10 @@ class AnnotaterCheckoutService { } /** - * The file containing [element]. Elements of a class file's decompiled text are in the class file. + * Elements of a class file's decompiled text are in the class file. */ fun virtualFileOf(element: PsiElement): VirtualFile? = element.containingFile?.originalFile?.virtualFile - /** - * Whether [file] is a class or source file from a Minecraft jar, as opposed to project sources or another - * library. - */ fun isMinecraftFile(project: Project, file: VirtualFile): Boolean { val fileIndex = ProjectFileIndex.getInstance(project) if (!fileIndex.isInLibrary(file)) { diff --git a/src/main/kotlin/platform/fabric/annotater/AnnotaterConfig.kt b/src/main/kotlin/platform/fabric/annotater/AnnotaterConfig.kt index 4d2ef27f2..9715fa33a 100644 --- a/src/main/kotlin/platform/fabric/annotater/AnnotaterConfig.kt +++ b/src/main/kotlin/platform/fabric/annotater/AnnotaterConfig.kt @@ -28,9 +28,6 @@ import java.nio.file.Path import kotlin.io.path.exists import kotlin.io.path.readText -/** - * The contents of an annotater checkout's `config.json`. - */ data class AnnotaterConfig( val version: String, val mappings: String, diff --git a/src/main/kotlin/platform/fabric/annotater/AnnotaterConfigurable.kt b/src/main/kotlin/platform/fabric/annotater/AnnotaterConfigurable.kt index 870b42764..411971bb1 100644 --- a/src/main/kotlin/platform/fabric/annotater/AnnotaterConfigurable.kt +++ b/src/main/kotlin/platform/fabric/annotater/AnnotaterConfigurable.kt @@ -145,8 +145,7 @@ class AnnotaterConfigurable : Configurable { }.also { panel = it } /** - * Checks that [directory] is an annotater checkout for a Minecraft version used by an open project, returning - * the problem and whether it's an error rather than a warning. + * Returns the problem and whether it's an error rather than a warning. */ private fun validateCustomDirectory(directory: String): Pair? { if (directory.isBlank()) { @@ -170,8 +169,7 @@ class AnnotaterConfigurable : Configurable { } /** - * A reminder to follow annotater's contribution guidelines, which can differ between version branches, linking to - * those of the versions used by open projects, or of the default branch if there are none. + * The guidelines can differ between version branches, so link to those of each version in use. */ private fun contributionGuidelinesNote(): String { fun link(branch: String, text: String) = @@ -237,9 +235,6 @@ class AnnotaterConfigurable : Configurable { override fun reset() = panel.reset() - /** - * The Minecraft versions of all modules in open projects, as configured in their Minecraft module settings. - */ private fun knownMinecraftVersions(): List { return ProjectManager.getInstance().openProjects.flatMap { project -> ModuleManager.getInstance(project).modules.mapNotNull { diff --git a/src/main/kotlin/platform/fabric/annotater/AnnotaterVcs.kt b/src/main/kotlin/platform/fabric/annotater/AnnotaterVcs.kt index 2832f5708..1bec07aba 100644 --- a/src/main/kotlin/platform/fabric/annotater/AnnotaterVcs.kt +++ b/src/main/kotlin/platform/fabric/annotater/AnnotaterVcs.kt @@ -25,7 +25,7 @@ import com.intellij.openapi.progress.ProgressIndicator import com.intellij.openapi.project.Project /** - * Git operations on managed annotater checkouts. Only available when the Git plugin is installed. + * Only available when the Git plugin is installed. */ interface AnnotaterVcs { /** @@ -52,12 +52,12 @@ interface AnnotaterVcs { fun currentContribution(checkout: AnnotaterCheckout): AnnotaterContribution? /** - * The contribution branches in the managed [checkout], newest first. Must not be called on the EDT. + * Newest first. Must not be called on the EDT. */ fun contributions(project: Project?, checkout: AnnotaterCheckout): List /** - * The mapping files in [checkout] that have changed since the last commit. Must not be called on the EDT. + * Must not be called on the EDT. */ fun uncommittedMappingFiles(project: Project?, checkout: AnnotaterCheckout): List @@ -92,8 +92,6 @@ interface AnnotaterVcs { } /** - * A local branch in a managed checkout holding one contribution, which becomes one pull request. - * * @property title the title of the contribution's pull request, or its latest commit message if it doesn't have one * yet, or null if nothing has been committed to it */ @@ -102,9 +100,6 @@ data class AnnotaterContribution(val branch: String, val title: String?) { get() = title ?: branch companion object { - /** - * A pull request title or commit message describing changes to the mapping [files]. - */ fun defaultTitle(files: List): String { val classes = files.map { it.substringAfterLast('/').removeSuffix(".mapping") }.distinct() return when { @@ -117,7 +112,7 @@ data class AnnotaterContribution(val branch: String, val title: String?) { } /** - * Creating pull requests to annotater. Only available when the GitHub plugin is installed. + * Only available when the GitHub plugin is installed. */ interface AnnotaterPullRequests { enum class Action { CREATE, UPDATE } @@ -128,9 +123,6 @@ interface AnnotaterPullRequests { */ fun availableAction(checkout: AnnotaterCheckout): Action? - /** - * Creates a pull request with the changes in [checkout], or pushes them to its existing pull request. - */ fun submit(project: Project, checkout: AnnotaterCheckout) companion object { diff --git a/src/main/kotlin/platform/fabric/annotater/git/AnnotaterGitVcs.kt b/src/main/kotlin/platform/fabric/annotater/git/AnnotaterGitVcs.kt index ea3743575..f95662076 100644 --- a/src/main/kotlin/platform/fabric/annotater/git/AnnotaterGitVcs.kt +++ b/src/main/kotlin/platform/fabric/annotater/git/AnnotaterGitVcs.kt @@ -70,7 +70,7 @@ class AnnotaterGitVcs : AnnotaterVcs { private val titledBranches = ConcurrentHashMap.newKeySet() /** - * The [PushStatus] of each managed checkout by root, so that the editor banner can show it without running git. + * Cached so that the editor banner can show it without running git. */ private val pushStatuses = ConcurrentHashMap() @@ -79,9 +79,6 @@ class AnnotaterGitVcs : AnnotaterVcs { */ private val pushStatusCheckouts = ConcurrentHashMap() - /** - * The checkouts whose [pushStatuses] are waiting to be refreshed. - */ private val pendingPushStatuses = ConcurrentHashMap.newKeySet() private val pushStatusExecutor = @@ -224,7 +221,7 @@ class AnnotaterGitVcs : AnnotaterVcs { } /** - * Reads the checked out branch straight from the worktree's `HEAD` file, to avoid running git. + * Reads the worktree's `HEAD` file directly, to avoid running git. */ private fun readHeadBranch(root: Path): String? { return try { @@ -278,9 +275,6 @@ class AnnotaterGitVcs : AnnotaterVcs { return title } - /** - * Updates the title of the checked out contribution shown in editor banners. - */ private fun refreshCurrentTitle(project: Project, checkout: AnnotaterCheckout) { val branch = currentBranch(project, checkout.root) ?: return loadTitles(project, checkout) @@ -290,9 +284,6 @@ class AnnotaterGitVcs : AnnotaterVcs { } } - /** - * The title of the pull request already opened for the contribution [branch], if any. - */ fun pullRequestTitle(project: Project, checkout: AnnotaterCheckout, branch: String): String? { loadTitles(project, checkout) return titles[branch] @@ -315,7 +306,7 @@ class AnnotaterGitVcs : AnnotaterVcs { } /** - * Remembers the title of the pull request for the contribution [branch], as its git branch description. + * Stored as the branch's git branch description. */ fun setContributionTitle(project: Project, checkout: AnnotaterCheckout, branch: String, title: String) { run(project, checkout.root, GitCommand.CONFIG, "branch.$branch.description", title) @@ -372,16 +363,9 @@ class AnnotaterGitVcs : AnnotaterVcs { return PushStatus(pullRequestUrl, hasUnpushedChanges) } - /** - * The URL of the pull request opened for the contribution [branch], if any. - */ fun pullRequestUrl(project: Project, checkout: AnnotaterCheckout, branch: String): String? = getBranchConfig(project, checkout, branch, PULL_REQUEST_KEY) - /** - * Remembers that the contribution [branch] has been pushed to the pull request at [url], so that the editor banner - * offers to update it when there are new changes. - */ fun setPullRequest(project: Project, checkout: AnnotaterCheckout, branch: String, url: String) { run(project, checkout.root, GitCommand.CONFIG, "branch.$branch.$PULL_REQUEST_KEY", url) run( @@ -436,9 +420,6 @@ class AnnotaterGitVcs : AnnotaterVcs { } } - /** - * The number of commits on the current branch that aren't in the upstream version branch. - */ fun commitsAheadOfUpstream(project: Project, checkout: AnnotaterCheckout): Int { val output = run( project, diff --git a/src/main/kotlin/platform/fabric/annotater/github/AnnotaterBuildCheck.kt b/src/main/kotlin/platform/fabric/annotater/github/AnnotaterBuildCheck.kt index 8480235fa..7d8a166e2 100644 --- a/src/main/kotlin/platform/fabric/annotater/github/AnnotaterBuildCheck.kt +++ b/src/main/kotlin/platform/fabric/annotater/github/AnnotaterBuildCheck.kt @@ -90,9 +90,6 @@ object AnnotaterBuildCheck { return commandLine } - /** - * Shows the output of the failed checks, offering to cancel submitting, which is the default, or submit anyway. - */ private class FailedDialog(project: Project, private val log: String) : DialogWrapper(project) { init { title = MCDevBundle("fabric.annotater.pr.check_build.failed") diff --git a/src/main/kotlin/platform/fabric/annotater/github/AnnotaterGitHubPullRequests.kt b/src/main/kotlin/platform/fabric/annotater/github/AnnotaterGitHubPullRequests.kt index fa5d1ce21..a2d0a3e2c 100644 --- a/src/main/kotlin/platform/fabric/annotater/github/AnnotaterGitHubPullRequests.kt +++ b/src/main/kotlin/platform/fabric/annotater/github/AnnotaterGitHubPullRequests.kt @@ -47,10 +47,6 @@ import org.jetbrains.plugins.github.authentication.GHAccountsUtil import org.jetbrains.plugins.github.authentication.accounts.GithubAccount import org.jetbrains.plugins.github.util.GHCompatibilityUtil -/** - * Commits annotater changes, forks annotater on GitHub, pushes the contribution branch to the fork and opens a pull - * request, using accounts from the GitHub plugin. - */ class AnnotaterGitHubPullRequests : AnnotaterPullRequests { override fun availableAction(checkout: AnnotaterCheckout): AnnotaterPullRequests.Action? { val status = AnnotaterGitVcs.instance?.pushStatus(checkout)?.takeIf { it.hasUnpushedChanges } ?: return null diff --git a/src/main/kotlin/platform/fabric/annotater/github/AnnotaterPullRequestDialog.kt b/src/main/kotlin/platform/fabric/annotater/github/AnnotaterPullRequestDialog.kt index fa950bea5..027f3d75d 100644 --- a/src/main/kotlin/platform/fabric/annotater/github/AnnotaterPullRequestDialog.kt +++ b/src/main/kotlin/platform/fabric/annotater/github/AnnotaterPullRequestDialog.kt @@ -41,10 +41,7 @@ class AnnotaterPullRequestDialog( private val changes: List, private val uncommittedChanges: List, existingTitle: String?, - /** - * Whether the changes are pushed to an existing pull request, so there's no title or description to enter. - */ - private val update: Boolean, + private val update: Boolean, // existing PR or update? ) : DialogWrapper(project) { private val accountModel = CollectionComboBoxModel(accounts, defaultAccount ?: accounts.first()) diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocContributions.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocContributions.kt index f18e078e2..0d6f7bb9d 100644 --- a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocContributions.kt +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocContributions.kt @@ -41,21 +41,10 @@ import com.intellij.openapi.ui.popup.JBPopupFactory import java.util.concurrent.CompletableFuture import javax.swing.JComponent -/** - * Switching a managed annotater checkout between contributions, so that a pull request can be updated after review, - * and deleting contributions. - */ object AnnotaterJavadocContributions { - /** - * The label for the contribution [checkout] is on, for the editor banner. - */ fun currentLabel(vcs: AnnotaterVcs, checkout: AnnotaterCheckout): String = vcs.currentContribution(checkout)?.displayName ?: MCDevBundle("fabric.annotater.contribution.new") - /** - * Shows a popup under [component] for switching between and deleting contributions if [checkout] is managed, and - * configuring annotater. - */ fun showPopup(project: Project, checkout: AnnotaterCheckout, component: JComponent) { val vcs = AnnotaterVcs.instance?.takeIf { checkout.managed } if (vcs == null) { @@ -167,9 +156,6 @@ object AnnotaterJavadocContributions { } } - /** - * Asks for a message to commit the uncommitted changes with, if there are any, then switches to [branch]. - */ private fun askForCommitMessageThenSwitch( project: Project, checkout: AnnotaterCheckout, @@ -207,10 +193,6 @@ object AnnotaterJavadocContributions { }.queue() } - /** - * Commits the uncommitted changes with [commitMessage] if it isn't null, then checks out [branch], restarting the - * editing of the Javadoc being edited. - */ private fun commitAndCheckOut( project: Project, checkout: AnnotaterCheckout, @@ -238,9 +220,6 @@ object AnnotaterJavadocContributions { }.queue() } - /** - * Deletes [contribution] after asking for confirmation. Only the local branch is deleted. - */ private fun deleteContribution( project: Project, checkout: AnnotaterCheckout, diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocEditing.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocEditing.kt index 5dfd9bcc3..77ffebf6d 100644 --- a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocEditing.kt +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocEditing.kt @@ -48,8 +48,7 @@ import java.io.IOException object AnnotaterJavadocEditing { /** - * Whether to offer editing annotater Javadocs in [project]. In Fabric projects it's offered even before annotater - * is set up, to make it discoverable. + * In Fabric projects it's offered even before annotater is set up, to make it discoverable. */ fun isOffered(project: Project): Boolean { return AnnotaterSettings.instance.enabled || AnnotaterCheckoutService.isFabricProject(project) @@ -71,10 +70,6 @@ object AnnotaterJavadocEditing { return candidate.takeIf { isDocumentable(it) } } - /** - * Converts an element of a decompiled class's text to the compiled element it was generated from, if there is - * one, and filters out elements that can't have annotater Javadocs. - */ fun normalize(element: PsiElement): PsiMember? { val member = (element.getUserData(ClsElementImpl.COMPILED_ELEMENT) ?: element) as? PsiMember ?: return null return member.takeIf { AnnotaterJavadocTarget.fromPsi(it) != null } @@ -85,15 +80,12 @@ object AnnotaterJavadocEditing { return AnnotaterCheckoutService.isMinecraftFile(member.project, file) } - /** - * Starts editing the annotater Javadoc of the member found by [findTarget]. - */ fun open(project: Project, editor: Editor, file: PsiFile) { open(project, findTarget(editor, file) ?: return) } /** - * Starts editing the annotater Javadoc of [member], in an editor embedded in the Minecraft class it's displayed in. + * Edits in an editor embedded in the Minecraft class [member] is displayed in. */ fun open(project: Project, member: PsiMember) { val checkout = when (val status = AnnotaterCheckoutService.instance.status(member)) { diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocEditorNotificationProvider.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocEditorNotificationProvider.kt index 727524c5e..54585f2e2 100644 --- a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocEditorNotificationProvider.kt +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocEditorNotificationProvider.kt @@ -36,9 +36,6 @@ import java.util.concurrent.CompletableFuture import java.util.function.Function import javax.swing.JComponent -/** - * Shows a banner in Minecraft classes that annotater Javadocs are displayed in, i.e. that have an annotater checkout. - */ class AnnotaterJavadocEditorNotificationProvider : EditorNotificationProvider { override fun collectNotificationData( project: Project, diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlayPassFactory.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlayPassFactory.kt index 631191de4..8cc2d5e24 100644 --- a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlayPassFactory.kt +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlayPassFactory.kt @@ -54,10 +54,6 @@ import com.intellij.psi.SmartPsiElementPointer import javax.swing.Icon /** - * Displays annotater Javadocs in Minecraft classes, both decompiled and attached sources. Existing doc comments are - * folded away, and annotater Javadocs are shown as block inlays above their declarations. Declarations with an - * annotater Javadoc also get a gutter icon to edit it. - * * The IDE's rendered doc comments (reader mode) would show the existing Javadocs again, so they're turned off in these * editors. Javadocs can't be added to the decompiled text itself, as the IDE requires decompiled classes' doc comments * to match their compiled stubs. @@ -86,9 +82,6 @@ class AnnotaterJavadocInlayPassFactory : TextEditorHighlightingPassFactoryRegist TextEditorHighlightingPass(file.project, editor.document, false) { private val documentStamp = editor.document.modificationStamp - /** - * Whether annotater Javadocs replace the existing ones, which is never the case without a checkout. - */ private var showJavadocs = false private var items = emptyList() private var gutterIcons = emptyList() diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlayRenderer.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlayRenderer.kt index f2ea6f13b..7162f5f5a 100644 --- a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlayRenderer.kt +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlayRenderer.kt @@ -74,9 +74,6 @@ class AnnotaterJavadocInlayRenderer( override fun getContextMenuGroup(inlay: Inlay<*>): ActionGroup = DefaultActionGroup(AnnotaterJavadocInlayPassFactory.EditAction(pointer)) - /** - * The x coordinate of the declaration, where the comment starts. - */ private fun startX(inlay: Inlay<*>): Int { // The size is first calculated while the inlay is being created, before it has a valid offset. The offset // differs from initialOffset after the document changes, until the highlighting pass replaces the inlay @@ -96,9 +93,6 @@ class AnnotaterJavadocInlayRenderer( private var lines: List>? = null private var linesScheme: EditorColorsScheme? = null - /** - * The doc comment, split into lines of tokens highlighted as they would be in a Java file. - */ private fun lines(): List> { val scheme = editor.colorsScheme lines?.takeIf { linesScheme === scheme }?.let { return it } diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlineEditor.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlineEditor.kt index 85443b88b..34d6de60c 100644 --- a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlineEditor.kt +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocInlineEditor.kt @@ -102,13 +102,12 @@ class AnnotaterJavadocInlineEditor private constructor( val file: AnnotaterJavadocVirtualFile, private val document: Document, /** - * The edited member in the Minecraft class it's displayed in, so that editing can be restarted when the checkout - * switches branch. + * Kept so that editing can be restarted when the checkout switches branch. */ val source: SmartPsiElementPointer?, ) : Disposable { /** - * The pending automatic save, which is delayed until the user stops typing. + * Delayed save until the user stops typing. */ private var saveJob: Job? = null @@ -117,16 +116,10 @@ class AnnotaterJavadocInlineEditor private constructor( @Volatile private var dirty = false - /** - * The editor of the Minecraft class that the Javadoc is edited in. - */ @Volatile var hostEditor: Editor? = null private set - /** - * The editor embedded in [hostEditor] that edits the Javadoc. - */ var editor: EditorEx? = null private set @@ -190,9 +183,6 @@ class AnnotaterJavadocInlineEditor private constructor( guards += guard } - /** - * Shows the embedded editor above [offset] in [host], and focuses it. - */ fun show(host: EditorEx, offset: Int) { val range = commentRange() ?: return val editor = EditorFactory.getInstance().createEditor(document, project, file, false, EditorKind.MAIN_EDITOR) @@ -486,9 +476,6 @@ class AnnotaterJavadocInlineEditor private constructor( private val saveExecutor = AppExecutorUtil.createBoundedApplicationPoolExecutor("Annotater Javadoc saving", 1) - /** - * The annotater Javadoc being edited in [project], if any. - */ fun current(project: Project): AnnotaterJavadocInlineEditor? = project.getUserData(CURRENT_KEY) /** @@ -503,7 +490,7 @@ class AnnotaterJavadocInlineEditor private constructor( } /** - * Whether [range] of [document] is entirely within one guarded block, so the user didn't write it. + * Whether [range] of [document] is entirely within one guarded block, i.e. the user didn't write it. */ fun isGuarded(document: Document, range: TextRange): Boolean { val guard = document.getRangeGuard(range.startOffset, range.endOffset) ?: return false @@ -511,9 +498,8 @@ class AnnotaterJavadocInlineEditor private constructor( } /** - * Starts editing the Javadoc of [file]'s target, finishing the previous editing in the project - * first. Returns null if the previous editing couldn't be finished, or the target's doc comment can't be found - * in [file]. + * Finishes the previous editing in the project first. Returns null if that couldn't be finished, or the + * target's doc comment can't be found in [file]. */ fun start( project: Project, diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocRefresher.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocRefresher.kt index b4a98a4fc..87b3090d5 100644 --- a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocRefresher.kt +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocRefresher.kt @@ -30,9 +30,6 @@ import com.intellij.openapi.vfs.newvfs.events.VFileEvent import com.intellij.ui.EditorNotifications import java.nio.file.Path -/** - * Refreshes the places annotater Javadocs are displayed when they change, whether through the plugin or on disk. - */ class AnnotaterJavadocRefresher : AnnotaterListener, BulkFileListenerBackgroundable { override fun checkoutsChanged() { refresh(updateNotifications = true) diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocRendering.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocRendering.kt index 2f72d7eec..af52e9e32 100644 --- a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocRendering.kt +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocRendering.kt @@ -34,10 +34,6 @@ import com.intellij.psi.util.PsiTreeUtil * its source file that has the annotater Javadoc as its doc comment. */ object AnnotaterJavadocRendering { - /** - * Finds the checkout whose Javadocs should be displayed instead of the existing Javadoc of [member], or null if - * the existing Javadoc should be shown. - */ fun checkoutForDisplay(member: PsiMember): AnnotaterCheckout? { if (!AnnotaterSettings.instance.showAnnotaterJavadocs) { return null @@ -55,9 +51,6 @@ object AnnotaterJavadocRendering { ?: member.textRange.startOffset } - /** - * Finds the declaration of [member] in a copy of its source file with [javadoc] as its doc comment. - */ private fun copiedDeclaration(member: PsiMember, javadoc: String?): PsiJavaDocumentedElement? { val target = AnnotaterJavadocTarget.fromPsi(member) ?: return null val copy = AnnotaterJavadocSourceCopy.create(member) { if (it == target) javadoc else null } ?: return null @@ -65,9 +58,6 @@ object AnnotaterJavadocRendering { return copy.memberOf(file, target) as? PsiJavaDocumentedElement } - /** - * Generates the Quick Documentation of [member] using [javadoc] as its description. - */ fun generateDoc(member: PsiMember, javadoc: String?): String? { val declaration = copiedDeclaration(member, javadoc) ?: return null return JavaDocumentationProvider.generateDocStatic(declaration, null) diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocSourceCopy.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocSourceCopy.kt index 8e45c4c45..323903fba 100644 --- a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocSourceCopy.kt +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocSourceCopy.kt @@ -43,17 +43,13 @@ import com.intellij.testFramework.LightVirtualFile * existing Javadocs replaced by the annotater Javadocs as Markdown doc comments. This lets the IDE's own Java support * be used to edit and render annotater Javadocs in their real context. * - * The member at index `i` of [AnnotaterJavadocTarget.declarations] documents `targets[i]`, or nothing if that is null, e.g. for members of other - * top level classes in the same file. + * The member at index `i` of [AnnotaterJavadocTarget.declarations] documents `targets[i]`, or nothing if that is null, + * e.g. for members of other top level classes in the same file. */ class AnnotaterJavadocSourceCopy( val fileName: String, val text: String, val targets: List, - /** - * The language level to parse the copy at: that of the source file, but at least the first that supports Markdown - * doc comments. - */ val languageLevel: LanguageLevel, ) { /** @@ -127,8 +123,7 @@ class AnnotaterJavadocSourceCopy( companion object { /** - * Extracts the Markdown text of a `///` doc comment, removing the `///` prefixes and the common indentation. - * Returns null when the comment is blank. + * Removes the `///` prefixes and the common indentation. Returns null when the comment is blank. */ private fun extractMarkdown(comment: PsiDocComment): String? { val lines = comment.text.lines().map { line -> @@ -142,9 +137,6 @@ class AnnotaterJavadocSourceCopy( return text.ifEmpty { null } } - /** - * Formats Markdown as a `///` doc comment with the given indentation, ending with a newline. - */ private fun formatMarkdown(markdown: String, indent: String): String { return markdown.lines().joinToString("") { line -> if (line.isEmpty()) "$indent///\n" else "$indent/// $line\n" diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocStore.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocStore.kt index 9a78973e9..7b9b86df5 100644 --- a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocStore.kt +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocStore.kt @@ -41,16 +41,15 @@ import net.fabricmc.mappingio.format.enigma.EnigmaFileWriter import net.fabricmc.mappingio.tree.MappingTree import net.fabricmc.mappingio.tree.MemoryMappingTree import net.fabricmc.mappingio.tree.VisitOrder +import org.jetbrains.annotations.VisibleForTesting /** - * Reads and writes the Javadoc comments stored in an annotater `mappings` directory. Comments are stored in Enigma - * format, with one file per top level class at `/.mapping`. The files are written - * in the same way as annotater formats them, so that saving doesn't reformat unrelated parts of a file. + * Reads and writes the Javadoc comments stored in an annotater `mappings` directory. * * Use [forDirectory] rather than the constructor outside of tests, so that every checkout of a directory sees the * comments the others write. */ -class AnnotaterJavadocStore(val mappingsDir: Path) { +class AnnotaterJavadocStore @VisibleForTesting constructor(val mappingsDir: Path) { private val cache = ConcurrentHashMap() fun fileFor(outerClass: String): Path = mappingsDir.resolve("$outerClass.mapping") @@ -87,7 +86,7 @@ class AnnotaterJavadocStore(val mappingsDir: Path) { } /** - * Like [set], but for several targets at once. All targets must share the same outer class. + * All targets must share the same outer class. */ @Throws(IOException::class) @Synchronized @@ -137,9 +136,6 @@ class AnnotaterJavadocStore(val mappingsDir: Path) { fun forDirectory(mappingsDir: Path): AnnotaterJavadocStore = stores.computeIfAbsent(mappingsDir.normalize(), ::AnnotaterJavadocStore) - /** - * Forgets the cached comments of every store, for when the checkouts change, such as by switching branch. - */ fun invalidateAll() { for (store in stores.values) { store.cache.clear() diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocTarget.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocTarget.kt index 39ba4f6cf..1fd220c6c 100644 --- a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocTarget.kt +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocTarget.kt @@ -36,13 +36,13 @@ import com.intellij.psi.impl.compiled.ClsElementImpl /** * A class, field or method that can have a Javadoc comment in annotater. All names are internal (slash separated) - * official names, and members are identified by their name and JVM descriptor. + * names, and members are identified by their name and JVM descriptor. */ sealed class AnnotaterJavadocTarget { abstract val owner: String /** - * The internal name of the top level class whose `.mapping` file stores this target. + * The top level class whose `.mapping` file stores this target. */ val outerClass: String get() = outerClassName(owner) @@ -65,8 +65,7 @@ sealed class AnnotaterJavadocTarget { companion object { /** - * The target documented by [element], which may be an element of a class file's decompiled text. Anonymous - * classes can't be documented, but their members can. + * The target documented by [element], which may be an element of a class file's decompiled text. */ fun fromPsi(element: PsiElement): AnnotaterJavadocTarget? { // Decompiled text is generated from the compiled class, which is more reliable to get the target of diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocUnqualifiedReferenceInspection.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocUnqualifiedReferenceInspection.kt index db696d956..2c5fb89d9 100644 --- a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocUnqualifiedReferenceInspection.kt +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocUnqualifiedReferenceInspection.kt @@ -80,9 +80,6 @@ class AnnotaterJavadocUnqualifiedReferenceInspection : LocalInspectionTool() { } private object Util { - /** - * Returns the fully qualified name of the class [reference] refers to, if it isn't already written as such. - */ fun qualifiedNameIfUnqualified(reference: PsiJavaCodeReferenceElement): String? { val resolved = reference.resolve() as? PsiClass ?: return null val qualifiedName = resolved.qualifiedName ?: return null diff --git a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocVirtualFile.kt b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocVirtualFile.kt index 123adaa51..c165c9f10 100644 --- a/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocVirtualFile.kt +++ b/src/main/kotlin/platform/fabric/annotater/javadoc/AnnotaterJavadocVirtualFile.kt @@ -35,9 +35,6 @@ class AnnotaterJavadocVirtualFile( val copy: AnnotaterJavadocSourceCopy, val target: AnnotaterJavadocTarget, ) : LightVirtualFile(copy.fileName, JavaFileType.INSTANCE, copy.text) { - /** - * The reason the last save failed, shown in the editor banner. - */ @Volatile var error: String? = null From 375b870d87466de905390a44aa785b7d1db818d3 Mon Sep 17 00:00:00 2001 From: joe Date: Wed, 11 Nov 2026 03:32:27 +0000 Subject: [PATCH 4/5] Reduce yap in messages --- .../fabric/annotater/AnnotaterConfigurable.kt | 1 - .../messages/MinecraftDevelopment.properties | 17 ++++++++--------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/main/kotlin/platform/fabric/annotater/AnnotaterConfigurable.kt b/src/main/kotlin/platform/fabric/annotater/AnnotaterConfigurable.kt index 411971bb1..5d2c36bd6 100644 --- a/src/main/kotlin/platform/fabric/annotater/AnnotaterConfigurable.kt +++ b/src/main/kotlin/platform/fabric/annotater/AnnotaterConfigurable.kt @@ -81,7 +81,6 @@ class AnnotaterConfigurable : Configurable { row { checkBox(MCDevBundle("fabric.annotater.settings.show_javadocs")) .bindSelected(settings::showAnnotaterJavadocs) - .comment(MCDevBundle("fabric.annotater.settings.show_javadocs.comment")) } lateinit var managed: Cell lateinit var custom: Cell diff --git a/src/main/resources/messages/MinecraftDevelopment.properties b/src/main/resources/messages/MinecraftDevelopment.properties index 74bf38aa0..3d7afbeae 100644 --- a/src/main/resources/messages/MinecraftDevelopment.properties +++ b/src/main/resources/messages/MinecraftDevelopment.properties @@ -404,13 +404,12 @@ update.outdated_ide.content=Minecraft Development has dropped support for {0}. T fabric.annotater.settings.display_name=Annotater fabric.annotater.settings.title=Annotater -fabric.annotater.settings.enabled=Enable annotater Javadoc editing -fabric.annotater.settings.enabled.comment=Edit Minecraft Javadocs for annotater with the Edit Annotater Javadoc action on Minecraft classes, fields and methods, or the annotater gutter icon on declarations that already have an annotater Javadoc. +fabric.annotater.settings.enabled=Enable annotater +fabric.annotater.settings.enabled.comment=Contribute to annotater, Fabric's Minecraft documentation, from within the IDE. fabric.annotater.settings.guidelines=Please follow annotater''s {0} when writing Javadocs and opening pull requests. fabric.annotater.settings.guidelines.link=contribution guidelines fabric.annotater.settings.guidelines.link.version=contribution guidelines for {0} -fabric.annotater.settings.show_javadocs=Show annotater Javadocs instead of existing Minecraft Javadocs -fabric.annotater.settings.show_javadocs.comment=Applies to quick documentation, decompiled classes and attached Minecraft sources. Rendered doc comments (reader mode) are turned off in Minecraft classes. +fabric.annotater.settings.show_javadocs=Show annotater Javadocs in code fabric.annotater.settings.managed=Clone annotater automatically fabric.annotater.settings.managed_root=Clone into\: fabric.annotater.settings.clone_now=Clone or Update Annotater @@ -440,7 +439,7 @@ fabric.annotater.create_pull_request=Create Pull Request... fabric.annotater.update_pull_request=Update Pull Request... fabric.annotater.dropdown={0} \u25BE fabric.annotater.menu=Annotater -fabric.annotater.contribution.tooltip=Switch to another contribution, for example to update a pull request after review +fabric.annotater.contribution.tooltip=Switch to another contribution fabric.annotater.contribution.switch=Switch Contribution fabric.annotater.contribution.new=New contribution fabric.annotater.contribution.switching=Switching annotater contribution @@ -456,7 +455,7 @@ fabric.annotater.contribution.deleting=Deleting annotater contribution fabric.annotater.contribution.deleted=Annotater contribution deleted fabric.annotater.contribution.deleted.content=Deleted the contribution "{0}". fabric.annotater.contribution.delete.failed=Failed to delete annotater contribution -fabric.annotater.status.disabled=Annotater is disabled in the Minecraft Development settings. +fabric.annotater.status.disabled=Annotater is disabled in the MinecraftDev settings. fabric.annotater.status.unknown_version=The Minecraft version of this project is unknown. fabric.annotater.status.not_cloned=Annotater has not been cloned for Minecraft {0} yet. fabric.annotater.status.not_cloned_no_git=Annotater has not been cloned for Minecraft {0}. Install the Git plugin, or select an existing annotater directory. @@ -465,7 +464,7 @@ fabric.annotater.status.invalid=Invalid annotater directory\: {0} fabric.annotater.javadoc.edit=Edit Annotater Javadoc fabric.annotater.javadoc.open_failed=Failed to open annotater Javadoc -fabric.annotater.banner=Annotater Javadocs for Minecraft {0} from {1}. Use Edit Annotater Javadoc on a class, field or method to edit its Javadoc. Changes are saved automatically. +fabric.annotater.banner=Annotater Javadocs for Minecraft {0} from {1}. Changes are saved automatically. fabric.annotater.javadoc.finish=Finish editing fabric.annotater.javadoc.save_failed=Failed to save annotater Javadoc fabric.annotater.javadoc.error.not_markdown=Annotater only supports Markdown Javadoc comments (///). Changes were not saved. @@ -503,8 +502,8 @@ fabric.annotater.pr.dialog.description=Description\: fabric.annotater.pr.dialog.commit_message=Commit message\: fabric.annotater.pr.dialog.changes=Changed files\:
{0} fabric.annotater.pr.dialog.check_build=Check the build first -fabric.annotater.pr.dialog.check_build.comment=Runs gradlew check in the annotater checkout, which can take a minute. +fabric.annotater.pr.dialog.check_build.comment=Runs gradlew check in the annotater checkout. May take a minute or two. fabric.annotater.pr.check_build.progress=Checking the annotater build fabric.annotater.pr.check_build.failed=Annotater Build Check Failed -fabric.annotater.pr.check_build.failed.message=The annotater build check failed. Fix the problems below, or submit anyway. +fabric.annotater.pr.check_build.failed.message=The annotater build check failed with the following error log. fabric.annotater.pr.check_build.submit_anyway=Submit Anyway From 0a9fe90dbb5d5d67e31bd6784ab44b0e49b7592a Mon Sep 17 00:00:00 2001 From: joe Date: Wed, 11 Nov 2026 03:42:36 +0000 Subject: [PATCH 5/5] Improve annotater config menu --- .../kotlin/platform/fabric/annotater/AnnotaterConfigurable.kt | 1 + src/main/resources/messages/MinecraftDevelopment.properties | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/platform/fabric/annotater/AnnotaterConfigurable.kt b/src/main/kotlin/platform/fabric/annotater/AnnotaterConfigurable.kt index 5d2c36bd6..aef9fcd27 100644 --- a/src/main/kotlin/platform/fabric/annotater/AnnotaterConfigurable.kt +++ b/src/main/kotlin/platform/fabric/annotater/AnnotaterConfigurable.kt @@ -82,6 +82,7 @@ class AnnotaterConfigurable : Configurable { checkBox(MCDevBundle("fabric.annotater.settings.show_javadocs")) .bindSelected(settings::showAnnotaterJavadocs) } + separator() lateinit var managed: Cell lateinit var custom: Cell buttonsGroup { diff --git a/src/main/resources/messages/MinecraftDevelopment.properties b/src/main/resources/messages/MinecraftDevelopment.properties index 3d7afbeae..5082eda53 100644 --- a/src/main/resources/messages/MinecraftDevelopment.properties +++ b/src/main/resources/messages/MinecraftDevelopment.properties @@ -417,7 +417,7 @@ fabric.annotater.settings.clone_now.version=Minecraft version to clone annotater fabric.annotater.settings.clone_now.no_versions=No Minecraft versions could be found in the open projects. Refresh the Gradle project, or set the Minecraft version in the Minecraft module settings. fabric.annotater.settings.no_git=The Git plugin is required to clone annotater. fabric.annotater.settings.custom=Use an existing annotater directory\: -fabric.annotater.settings.custom.comment=The plugin never runs git in this directory. Commit, push and open pull requests yourself. +fabric.annotater.settings.custom.comment=Commit, push and open pull requests yourself. MinecraftDev will not run git in this directory. fabric.annotater.settings.custom.version_mismatch=This annotater directory is for Minecraft {0}, but the open projects use Minecraft {1}. fabric.annotater.unavailable=Annotater unavailable