work work work

This commit is contained in:
FalsePattern 2025-04-06 02:25:22 +02:00
parent e737058cb5
commit 2c500d40a5
Signed by: falsepattern
GPG key ID: E930CDEC50C50E23
8 changed files with 412 additions and 81 deletions

View file

@ -32,6 +32,7 @@ import com.intellij.openapi.util.KeyWithDefaultValue
import com.intellij.openapi.util.SystemInfo
import com.intellij.openapi.util.io.toNioPathOrNull
import com.intellij.openapi.vfs.toNioPathOrNull
import kotlinx.coroutines.runBlocking
import java.nio.file.Path
import java.util.UUID
import kotlin.io.path.pathString
@ -71,10 +72,16 @@ data class LocalZigToolchain(val location: Path, val std: Path? = null, val name
}
fun tryFromPath(path: Path): LocalZigToolchain? {
val tc = LocalZigToolchain(path)
var tc = LocalZigToolchain(path)
if (!tc.zig.fileValid()) {
return null
}
tc.zig
.getEnvBlocking(null)
.getOrNull()
?.version
?.let { "Zig $it" }
?.let { tc = tc.copy(name = it) }
return tc
}
}

View file

@ -30,8 +30,13 @@ import com.intellij.openapi.projectRoots.Sdk
import com.intellij.openapi.roots.ui.configuration.SdkPopupBuilder
import com.intellij.openapi.ui.NamedConfigurable
import com.intellij.openapi.util.UserDataHolder
import com.intellij.openapi.util.io.FileUtil
import com.intellij.openapi.util.io.toNioPathOrNull
import com.intellij.openapi.util.text.StringUtil
import com.intellij.ui.SimpleColoredComponent
import com.intellij.ui.SimpleTextAttributes
import com.intellij.util.EnvironmentUtil
import com.intellij.util.IconUtil
import com.intellij.util.system.OS
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
@ -98,4 +103,24 @@ class LocalZigToolchainProvider: ZigToolchainProvider {
EnvironmentUtil.getValue("PATH")?.split(File.pathSeparatorChar)?.let { res.addAll(it.toList()) }
return res.mapNotNull { LocalZigToolchain.tryFromPathString(it) }
}
override fun render(toolchain: AbstractZigToolchain, component: SimpleColoredComponent) {
toolchain as LocalZigToolchain
component.append(presentDetectedPath(toolchain.location.pathString))
if (toolchain.name != null) {
component.append(" ")
component.append(toolchain.name, SimpleTextAttributes.GRAYED_ATTRIBUTES)
}
}
}
private fun presentDetectedPath(home: String, maxLength: Int = 50, suffixLength: Int = 30): String {
//for macOS, let's try removing Bundle internals
var home = home
home = StringUtil.trimEnd(home, "/Contents/Home") //NON-NLS
home = StringUtil.trimEnd(home, "/Contents/MacOS") //NON-NLS
home = FileUtil.getLocationRelativeToUserHome(home, false)
home = StringUtil.shortenTextWithEllipsis(home, maxLength, suffixLength)
return home
}

View file

@ -22,111 +22,94 @@
package com.falsepattern.zigbrains.project.toolchain
import com.falsepattern.zigbrains.Icons
import com.falsepattern.zigbrains.ZigBrainsBundle
import com.intellij.ide.projectView.TreeStructureProvider
import com.intellij.ide.util.treeView.AbstractTreeStructure
import com.intellij.ide.util.treeView.AbstractTreeStructureBase
import com.falsepattern.zigbrains.shared.coroutine.withEDTContext
import com.falsepattern.zigbrains.shared.zigCoroutineScope
import com.intellij.icons.AllIcons
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.Presentation
import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.application.PathManager
import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory
import com.intellij.openapi.progress.coroutineToIndicator
import com.intellij.openapi.project.DumbAwareAction
import com.intellij.openapi.project.Project
import com.intellij.openapi.project.ProjectManager
import com.intellij.openapi.roots.ui.SdkAppearanceService
import com.intellij.openapi.roots.ui.configuration.SdkListPresenter
import com.intellij.openapi.roots.ui.configuration.SdkPopupFactory
import com.intellij.openapi.ui.ComboBox
import com.intellij.openapi.ui.DialogBuilder
import com.intellij.openapi.ui.MasterDetailsComponent
import com.intellij.openapi.ui.popup.JBPopupFactory
import com.intellij.openapi.ui.popup.util.BaseTreePopupStep
import com.intellij.openapi.util.NlsContexts
import com.intellij.ui.CollectionListModel
import com.intellij.ui.ColoredListCellRenderer
import com.intellij.ui.SimpleTextAttributes
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBPanel
import com.intellij.ui.components.panels.HorizontalLayout
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.io.toNioPathOrNull
import com.intellij.platform.ide.progress.ModalTaskOwner
import com.intellij.platform.ide.progress.TaskCancellation
import com.intellij.platform.ide.progress.withModalProgress
import com.intellij.platform.util.progress.withProgressText
import com.intellij.ui.*
import com.intellij.ui.components.JBList
import com.intellij.ui.components.panels.OpaquePanel
import com.intellij.ui.components.textFieldWithBrowseButton
import com.intellij.ui.dsl.builder.Align
import com.intellij.ui.dsl.builder.AlignX
import com.intellij.ui.dsl.builder.Cell
import com.intellij.ui.dsl.builder.panel
import com.intellij.ui.dsl.gridLayout.UnscaledGaps
import com.intellij.ui.popup.list.ComboBoxPopup
import com.intellij.ui.treeStructure.SimpleNode
import com.intellij.ui.treeStructure.SimpleTreeStructure
import com.intellij.util.Consumer
import com.intellij.util.IconUtil
import com.intellij.util.download.DownloadableFileService
import com.intellij.util.system.CpuArch
import com.intellij.util.text.SemVer
import com.intellij.util.ui.EmptyIcon
import com.intellij.util.ui.UIUtil.FontColor
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.decodeFromStream
import java.awt.BorderLayout
import java.awt.Component
import java.awt.LayoutManager
import java.nio.file.Path
import java.util.*
import java.util.function.Consumer
import javax.swing.AbstractListModel
import javax.swing.BoxLayout
import javax.swing.DefaultListModel
import javax.accessibility.AccessibleContext
import javax.swing.DefaultComboBoxModel
import javax.swing.JComponent
import javax.swing.JList
import javax.swing.JPanel
import javax.swing.ListCellRenderer
import javax.swing.ListModel
import javax.swing.SwingConstants
import javax.swing.border.Border
import javax.swing.event.DocumentEvent
import javax.swing.tree.DefaultTreeModel
import kotlin.io.path.pathString
class ZigToolchainListEditor(): MasterDetailsComponent() {
class ZigToolchainListEditor() : MasterDetailsComponent() {
private var isTreeInitialized = false
private var myComponent: JComponent? = null
override fun createComponent(): JComponent {
if (!isTreeInitialized) {
initTree()
isTreeInitialized = true
}
return super.createComponent()
val comp = super.createComponent()
myComponent = comp
return comp
}
class ToolchainContext(private val project: Project?, private val model: ListModel<Any>): ComboBoxPopup.Context<Any> {
override fun getProject(): Project? {
return project
}
override fun getModel(): ListModel<Any> {
return model
}
override fun getRenderer(): ListCellRenderer<in Any> {
return object: ColoredListCellRenderer<Any>() {
override fun customizeCellRenderer(
list: JList<out Any?>,
value: Any?,
index: Int,
selected: Boolean,
hasFocus: Boolean
) {
icon = EMPTY_ICON
if (value is LocalZigToolchain) {
icon = IconUtil.addIcon
append(SdkListPresenter.presentDetectedSdkPath(value.location.pathString))
if (value.name != null) {
append(" ")
append(value.name, SimpleTextAttributes.GRAYED_ATTRIBUTES)
}
}
}
}
}
}
class ToolchainPopup(context: ToolchainContext,
selected: Any?,
onItemSelected: Consumer<Any>
): ComboBoxPopup<Any>(context, selected, onItemSelected) {
}
override fun createActions(fromPopup: Boolean): List<AnAction?>? {
val add = object : DumbAwareAction({"lmaoo"}, Presentation.NULL_STRING, IconUtil.addIcon) {
override fun createActions(fromPopup: Boolean): List<AnAction> {
val add = object : DumbAwareAction({ "lmaoo" }, Presentation.NULL_STRING, IconUtil.addIcon) {
override fun actionPerformed(e: AnActionEvent) {
val toolchains = suggestZigToolchains(zigToolchainList.toolchains.map { it.second }.toList())
val final = ArrayList<Any>()
final.addAll(toolchains)
val popup = ToolchainPopup(ToolchainContext(null, CollectionListModel(final)), null, {})
val final = ArrayList<TCListElemIn>()
final.add(TCListElem.Download)
final.add(TCListElem.FromDisk)
final.add(Separator("Detected toolchains", true))
final.addAll(toolchains.map { TCListElem.Toolchain(it) })
val model = TCModel(final)
val context = TCContext(null, model)
val popup = TCPopup(context, null, ::onItemSelected)
popup.showInBestPositionFor(e.dataContext)
}
}
@ -140,6 +123,61 @@ class ZigToolchainListEditor(): MasterDetailsComponent() {
super.onItemDeleted(item)
}
private fun onItemSelected(elem: TCListElem) {
when (elem) {
is TCListElem.Toolchain -> {
val uuid = UUID.randomUUID()
zigToolchainList.setToolchain(uuid, elem.toolchain)
addToolchain(uuid, elem.toolchain)
(myTree.model as DefaultTreeModel).reload()
}
is TCListElem.Download -> {
zigCoroutineScope.async {
withEDTContext(ModalityState.stateForComponent(myComponent!!)) {
val info = withModalProgress(myComponent?.let { ModalTaskOwner.component(it) } ?: ModalTaskOwner.guess(), "Fetching zig version information", TaskCancellation.cancellable()) {
ZigVersionInfo.download()
}
val dialog = DialogBuilder()
val theList = ComboBox<String>(DefaultComboBoxModel(info.map { it.first }.toTypedArray()))
val outputPath = textFieldWithBrowseButton(
null,
FileChooserDescriptorFactory.createSingleFolderDescriptor().withTitle(ZigBrainsBundle.message("dialog.title.zig-toolchain"))
).also {
Disposer.register(dialog, it)
}
var archiveSizeCell: Cell<*>? = null
fun detect(item: String) {
outputPath.text = System.getProperty("user.home") + "/.zig/" + item
val data = info.firstOrNull { it.first == item } ?: return
val size = data.second.dist.size
val sizeMb = size / (1024f * 1024f)
archiveSizeCell?.comment?.text = "Archive size: %.2fMB".format(sizeMb)
}
theList.addItemListener {
detect(it.item as String)
}
val center = panel {
row("Version:") {
cell(theList).resizableColumn().align(AlignX.FILL)
}
row("Location:") {
cell(outputPath).resizableColumn().align(AlignX.FILL).apply { archiveSizeCell = comment("") }
}
}
detect(info[0].first)
dialog.centerPanel(center)
dialog.setTitle("Version Selector")
dialog.addCancelAction()
dialog.showAndGet()
}
}
}
is TCListElem.FromDisk -> {}
}
}
override fun reset() {
reloadTree()
super.reset()
@ -149,20 +187,160 @@ class ZigToolchainListEditor(): MasterDetailsComponent() {
override fun getDisplayName() = ZigBrainsBundle.message("settings.toolchains.title")
private fun addLocalToolchain(uuid: UUID, toolchain: LocalZigToolchain) {
val node = MyNode(LocalZigToolchainConfigurable(uuid, toolchain, ProjectManager.getInstance().defaultProject))
private fun addToolchain(uuid: UUID, toolchain: AbstractZigToolchain) {
val node = MyNode(toolchain.createNamedConfigurable(uuid, ProjectManager.getInstance().defaultProject))
addNode(node, myRoot)
}
private fun reloadTree() {
myRoot.removeAllChildren()
zigToolchainList.toolchains.forEach { (uuid, toolchain) ->
if (toolchain is LocalZigToolchain) {
addLocalToolchain(uuid, toolchain)
}
addToolchain(uuid, toolchain)
}
(myTree.model as DefaultTreeModel).reload()
}
override fun disposeUIResources() {
super.disposeUIResources()
myComponent = null
}
}
private sealed interface TCListElemIn
private sealed interface TCListElem : TCListElemIn {
@JvmRecord
data class Toolchain(val toolchain: AbstractZigToolchain) : TCListElem
object Download : TCListElem
object FromDisk : TCListElem
}
@JvmRecord
private data class Separator(val text: String, val separatorBar: Boolean) : TCListElemIn
private class TCPopup(
context: TCContext,
selected: TCListElem?,
onItemSelected: Consumer<TCListElem>,
) : ComboBoxPopup<TCListElem>(context, selected, onItemSelected)
private class TCModel private constructor(elements: List<TCListElem>, private val separators: Map<TCListElem, Separator>) : CollectionListModel<TCListElem>(elements) {
companion object {
operator fun invoke(input: List<TCListElemIn>): TCModel {
val separators = IdentityHashMap<TCListElem, Separator>()
var lastSeparator: Separator? = null
val elements = ArrayList<TCListElem>()
input.forEach {
when (it) {
is TCListElem -> {
if (lastSeparator != null) {
separators[it] = lastSeparator
lastSeparator = null
}
elements.add(it)
}
is Separator -> lastSeparator = it
}
}
val model = TCModel(elements, separators)
return model
}
}
fun separatorAbove(elem: TCListElem) = separators[elem]
}
private class TCContext(private val project: Project?, private val model: TCModel) : ComboBoxPopup.Context<TCListElem> {
override fun getProject(): Project? {
return project
}
override fun getModel(): TCModel {
return model
}
override fun getRenderer(): ListCellRenderer<in TCListElem> {
return TCCellRenderer(::getModel)
}
}
private class TCCellRenderer(val getModel: () -> TCModel) : ColoredListCellRenderer<TCListElem>() {
override fun getListCellRendererComponent(
list: JList<out TCListElem?>?,
value: TCListElem?,
index: Int,
selected: Boolean,
hasFocus: Boolean
): Component? {
val component = super.getListCellRendererComponent(list, value, index, selected, hasFocus) as SimpleColoredComponent
val panel = object : CellRendererPanel(BorderLayout()) {
val myContext = component.accessibleContext
override fun getAccessibleContext(): AccessibleContext? {
return myContext
}
override fun setBorder(border: Border?) {
component.border = border
}
}
panel.add(component, BorderLayout.CENTER)
component.isOpaque = true
list?.let { background = if (selected) it.selectionBackground else it.background }
val model = getModel()
val separator = value?.let { model.separatorAbove(it) }
if (separator != null) {
val separatorText = separator.text
val vGap = if (UIUtil.isUnderNativeMacLookAndFeel()) 1 else 3
val separatorComponent = GroupHeaderSeparator(JBUI.insets(vGap, 10, vGap, 0))
separatorComponent.isHideLine = !separator.separatorBar
if (separatorText.isNotBlank()) {
separatorComponent.caption = separatorText
}
val wrapper = OpaquePanel(BorderLayout())
wrapper.add(separatorComponent, BorderLayout.CENTER)
list?.let { wrapper.background = it.background }
panel.add(wrapper, BorderLayout.NORTH)
}
return panel
}
override fun customizeCellRenderer(
list: JList<out TCListElem?>,
value: TCListElem?,
index: Int,
selected: Boolean,
hasFocus: Boolean
) {
icon = EMPTY_ICON
when (value) {
is TCListElem.Toolchain -> {
icon = Icons.Zig
val toolchain = value.toolchain
toolchain.render(this)
}
is TCListElem.Download -> {
icon = AllIcons.Actions.Download
append("Download Zig\u2026")
}
is TCListElem.FromDisk -> {
icon = AllIcons.General.OpenDisk
append("Add Zig from disk\u2026")
}
null -> {}
}
}
}
private val EMPTY_ICON = EmptyIcon.create(1, 16)

View file

@ -63,7 +63,6 @@ class ZigToolchainListService: SerializablePersistentStateComponent<ZigToolchain
data class State(
@JvmField
@MapAnnotation(surroundKeyWithTag = false, surroundValueWithTag = false)
val toolchains: Map<String, AbstractZigToolchain.Ref> = emptyMap(),
)
}

View file

@ -26,6 +26,7 @@ import com.intellij.openapi.extensions.ExtensionPointName
import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.NamedConfigurable
import com.intellij.openapi.util.UserDataHolder
import com.intellij.ui.SimpleColoredComponent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.filter
@ -46,6 +47,7 @@ sealed interface ZigToolchainProvider {
fun matchesSuggestion(toolchain: AbstractZigToolchain, suggestion: AbstractZigToolchain): Boolean
fun createConfigurable(uuid: UUID, toolchain: AbstractZigToolchain, project: Project): NamedConfigurable<UUID>
fun suggestToolchains(): List<AbstractZigToolchain>
fun render(toolchain: AbstractZigToolchain, component: SimpleColoredComponent)
}
fun AbstractZigToolchain.Ref.resolve(): AbstractZigToolchain? {
@ -76,3 +78,8 @@ fun suggestZigToolchains(existing: List<AbstractZigToolchain>): List<AbstractZig
suggestions.filter { suggestion -> compatibleExisting.none { existing -> ext.matchesSuggestion(existing, suggestion) } }
}
}
fun AbstractZigToolchain.render(component: SimpleColoredComponent) {
val provider = EXTENSION_POINT_NAME.extensionList.find { it.isCompatible(this) } ?: throw IllegalStateException()
return provider.render(this, component)
}

View file

@ -0,0 +1,106 @@
/*
* This file is part of ZigBrains.
*
* Copyright (C) 2023-2025 FalsePattern
* All Rights Reserved
*
* The above copyright notice and this permission notice shall be included
* in all copies or substantial portions of the Software.
*
* ZigBrains 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, only version 3 of the License.
*
* ZigBrains 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 Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with ZigBrains. If not, see <https://www.gnu.org/licenses/>.
*/
package com.falsepattern.zigbrains.project.toolchain
import com.intellij.openapi.application.PathManager
import com.intellij.openapi.progress.coroutineToIndicator
import com.intellij.openapi.util.io.toNioPathOrNull
import com.intellij.platform.util.progress.withProgressText
import com.intellij.util.asSafely
import com.intellij.util.download.DownloadableFileService
import com.intellij.util.system.CpuArch
import com.intellij.util.system.OS
import com.intellij.util.text.SemVer
import com.jetbrains.rd.util.firstOrNull
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.decodeFromStream
import java.nio.file.Path
@JvmRecord
data class ZigVersionInfo(val date: String, val docs: String, val notes: String, val src: Tarball?, val dist: Tarball) {
companion object {
@OptIn(ExperimentalSerializationApi::class)
suspend fun download(): List<Pair<String, ZigVersionInfo>> {
return withProgressText("Fetching zig version information") {
withContext(Dispatchers.IO) {
val service = DownloadableFileService.getInstance()
val desc = service.createFileDescription("https://ziglang.org/download/index.json", "index.json")
val downloader = service.createDownloader(listOf(desc), "Zig version information downloading")
val downloadDirectory = tempPluginDir.toFile()
val downloadResults = coroutineToIndicator {
downloader.download(downloadDirectory)
}
var info: JsonObject? = null
for (result in downloadResults) {
if (result.second.defaultFileName == "index.json") {
info = result.first.inputStream().use { Json.decodeFromStream<JsonObject>(it) }
}
}
info?.mapNotNull getVersions@{ (version, data) ->
data as? JsonObject ?: return@getVersions null
val date = data["date"]?.asSafely<JsonPrimitive>()?.content ?: ""
val docs = data["docs"]?.asSafely<JsonPrimitive>()?.content ?: ""
val notes = data["notes"]?.asSafely<JsonPrimitive>()?.content ?: ""
val src = data["src"]?.asSafely<JsonObject>()?.let { Json.decodeFromJsonElement<Tarball>(it) }
val dist = data.firstNotNullOfOrNull findCompatible@{ (dist, tb) ->
if (!dist.contains('-'))
return@findCompatible null
val (arch, os) = dist.split('-', limit = 2)
val theArch = when(arch) {
"x86_64" -> CpuArch.X86_64
"i386" -> CpuArch.X86
"armv7a" -> CpuArch.ARM32
"aarch64" -> CpuArch.ARM64
else -> return@findCompatible null
}
val theOS = when(os) {
"linux" -> OS.Linux
"windows" -> OS.Windows
"macos" -> OS.macOS
"freebsd" -> OS.FreeBSD
else -> return@findCompatible null
}
if (theArch == CpuArch.CURRENT && theOS == OS.CURRENT) {
Json.decodeFromJsonElement<Tarball>(tb)
} else null
} ?: return@getVersions null
Pair(version, ZigVersionInfo(date, docs, notes, src, dist))
} ?.toList() ?: emptyList()
}
}
}
}
}
@JvmRecord
@Serializable
data class Tarball(val tarball: String, val shasum: String, val size: Int)
private val tempPluginDir get(): Path = PathManager.getTempPath().toNioPathOrNull()!!.resolve("zigbrains")

View file

@ -25,6 +25,7 @@ package com.falsepattern.zigbrains.project.toolchain.tools
import com.falsepattern.zigbrains.project.toolchain.AbstractZigToolchain
import com.falsepattern.zigbrains.project.toolchain.ZigToolchainEnvironmentSerializable
import com.intellij.openapi.project.Project
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import java.nio.file.Path
@ -45,6 +46,8 @@ class ZigCompilerTool(toolchain: AbstractZigToolchain) : ZigTool(toolchain) {
Result.failure(IllegalStateException("could not deserialize zig env", e))
}
}
fun getEnvBlocking(project: Project?) = runBlocking { getEnv(project) }
}
private val envJson = Json {

View file

@ -49,6 +49,12 @@ suspend inline fun <T> withCurrentEDTModalityContext(noinline block: suspend Cor
}
}
fun <T, R> T.letBlocking(targetAction: suspend CoroutineScope.(T) -> R): R {
return runBlocking {
targetAction(this@letBlocking)
}
}
suspend inline fun <T> runInterruptibleEDT(state: ModalityState, noinline targetAction: () -> T): T {
return runInterruptible(Dispatchers.EDT + state.asContextElement(), block = targetAction)
}