project-level toolchain selector

This commit is contained in:
FalsePattern 2025-04-06 17:19:26 +02:00
parent 9541bb9752
commit 8bb4e8bef1
Signed by: falsepattern
GPG key ID: E930CDEC50C50E23
20 changed files with 1016 additions and 538 deletions

View file

@ -1,332 +0,0 @@
/*
* 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.falsepattern.zigbrains.Icons
import com.falsepattern.zigbrains.ZigBrainsBundle
import com.falsepattern.zigbrains.project.toolchain.base.ZigToolchain
import com.falsepattern.zigbrains.project.toolchain.base.createNamedConfigurable
import com.falsepattern.zigbrains.project.toolchain.base.render
import com.falsepattern.zigbrains.project.toolchain.base.suggestZigToolchains
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.fileChooser.FileChooserDescriptorFactory
import com.intellij.openapi.project.DumbAwareAction
import com.intellij.openapi.project.Project
import com.intellij.openapi.project.ProjectManager
import com.intellij.openapi.ui.ComboBox
import com.intellij.openapi.ui.DialogBuilder
import com.intellij.openapi.ui.MasterDetailsComponent
import com.intellij.openapi.util.Disposer
import com.intellij.platform.ide.progress.ModalTaskOwner
import com.intellij.platform.ide.progress.TaskCancellation
import com.intellij.platform.ide.progress.withModalProgress
import com.intellij.ui.*
import com.intellij.ui.components.panels.OpaquePanel
import com.intellij.ui.components.textFieldWithBrowseButton
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.popup.list.ComboBoxPopup
import com.intellij.util.Consumer
import com.intellij.util.IconUtil
import com.intellij.util.ui.EmptyIcon
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import kotlinx.coroutines.async
import java.awt.BorderLayout
import java.awt.Component
import java.util.*
import javax.accessibility.AccessibleContext
import javax.swing.DefaultComboBoxModel
import javax.swing.JComponent
import javax.swing.JList
import javax.swing.ListCellRenderer
import javax.swing.border.Border
import javax.swing.tree.DefaultTreeModel
class ZigToolchainListEditor() : MasterDetailsComponent() {
private var isTreeInitialized = false
private var myComponent: JComponent? = null
override fun createComponent(): JComponent {
if (!isTreeInitialized) {
initTree()
isTreeInitialized = true
}
val comp = super.createComponent()
myComponent = comp
return comp
}
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<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)
}
}
return listOf(add, MyDeleteAction())
}
override fun onItemDeleted(item: Any?) {
if (item is UUID) {
zigToolchainList.removeToolchain(item)
}
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()
}
override fun getEmptySelectionString() = ZigBrainsBundle.message("settings.toolchains.empty")
override fun getDisplayName() = ZigBrainsBundle.message("settings.toolchains.title")
private fun addToolchain(uuid: UUID, toolchain: ZigToolchain) {
val node = MyNode(toolchain.createNamedConfigurable(uuid, ProjectManager.getInstance().defaultProject))
addNode(node, myRoot)
}
private fun reloadTree() {
myRoot.removeAllChildren()
zigToolchainList.toolchains.forEach { (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: ZigToolchain) : 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

@ -67,6 +67,9 @@ class ZigToolchainListService: SerializablePersistentStateComponent<ZigToolchain
@JvmField @JvmField
val toolchains: Map<String, ZigToolchain.Ref> = emptyMap(), val toolchains: Map<String, ZigToolchain.Ref> = emptyMap(),
) )
}
val zigToolchainList get() = service<ZigToolchainListService>() companion object {
@JvmStatic
fun getInstance(): ZigToolchainListService = service()
}
}

View file

@ -0,0 +1,63 @@
/*
* 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.falsepattern.zigbrains.project.toolchain.base.ZigToolchain
import com.intellij.openapi.components.SerializablePersistentStateComponent
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.State
import com.intellij.openapi.components.Storage
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.util.xmlb.annotations.Attribute
import java.util.UUID
@Service(Service.Level.PROJECT)
@State(
name = "ZigToolchain",
storages = [Storage("zigbrains.xml")]
)
class ZigToolchainService: SerializablePersistentStateComponent<ZigToolchainService.State>(State()) {
var toolchainUUID: UUID?
get() = state.toolchain.ifBlank { null }?.let { UUID.fromString(it) }
set(value) {
updateState {
it.copy(toolchain = value?.toString() ?: "")
}
}
val toolchain: ZigToolchain?
get() = toolchainUUID?.let { ZigToolchainListService.getInstance().getToolchain(it) }
@JvmRecord
data class State(
@JvmField
@Attribute
val toolchain: String = ""
)
companion object {
@JvmStatic
fun getInstance(project: Project): ZigToolchainService = project.service()
}
}

View file

@ -1,106 +0,0 @@
/*
* 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,13 +25,16 @@ package com.falsepattern.zigbrains.project.toolchain.base
import com.falsepattern.zigbrains.project.toolchain.tools.ZigCompilerTool import com.falsepattern.zigbrains.project.toolchain.tools.ZigCompilerTool
import com.intellij.execution.configurations.GeneralCommandLine import com.intellij.execution.configurations.GeneralCommandLine
import com.intellij.openapi.project.Project import com.intellij.openapi.project.Project
import com.intellij.openapi.util.UserDataHolderBase
import com.intellij.util.xmlb.annotations.Attribute import com.intellij.util.xmlb.annotations.Attribute
import com.intellij.util.xmlb.annotations.MapAnnotation import com.intellij.util.xmlb.annotations.MapAnnotation
import java.nio.file.Path import java.nio.file.Path
abstract class ZigToolchain { abstract class ZigToolchain: UserDataHolderBase() {
val zig: ZigCompilerTool by lazy { ZigCompilerTool(this) } val zig: ZigCompilerTool by lazy { ZigCompilerTool(this) }
abstract val name: String?
abstract fun workingDirectory(project: Project? = null): Path? abstract fun workingDirectory(project: Project? = null): Path?
abstract suspend fun patchCommandLine(commandLine: GeneralCommandLine, project: Project? = null): GeneralCommandLine abstract suspend fun patchCommandLine(commandLine: GeneralCommandLine, project: Project? = null): GeneralCommandLine

View file

@ -0,0 +1,86 @@
/*
* 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.base
import com.falsepattern.zigbrains.project.toolchain.ZigToolchainListService
import com.intellij.openapi.ui.NamedConfigurable
import com.intellij.openapi.util.NlsContexts
import com.intellij.ui.dsl.builder.panel
import java.util.UUID
import javax.swing.JComponent
abstract class ZigToolchainConfigurable<T: ZigToolchain>(
val uuid: UUID,
tc: T
): NamedConfigurable<UUID>() {
var toolchain: T = tc
set(value) {
ZigToolchainListService.getInstance().setToolchain(uuid, value)
field = value
}
private var myView: ZigToolchainPanel<T>? = null
abstract fun createPanel(): ZigToolchainPanel<T>
override fun createOptionsPanel(): JComponent? {
var view = myView
if (view == null) {
view = createPanel()
view.reset(toolchain)
myView = view
}
return panel {
view.attach(this)
}
}
override fun getEditableObject(): UUID? {
return uuid
}
override fun getBannerSlogan(): @NlsContexts.DetailedDescription String? {
return displayName
}
override fun getDisplayName(): @NlsContexts.ConfigurableName String? {
return toolchain.name
}
override fun isModified(): Boolean {
return myView?.isModified(toolchain) == true
}
override fun apply() {
myView?.apply(toolchain)?.let { toolchain = it }
}
override fun reset() {
myView?.reset(toolchain)
}
override fun disposeUIResources() {
myView?.dispose()
myView = null
super.disposeUIResources()
}
}

View file

@ -0,0 +1,47 @@
/*
* 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.base
import com.intellij.openapi.Disposable
import com.intellij.ui.components.JBTextField
import com.intellij.ui.dsl.builder.AlignX
import com.intellij.ui.dsl.builder.Panel
abstract class ZigToolchainPanel<T: ZigToolchain>: Disposable {
private val nameField = JBTextField()
protected var nameFieldValue: String?
get() = nameField.text.ifBlank { null }
set(value) {nameField.text = value ?: ""}
open fun attach(p: Panel): Unit = with(p) {
row("Name") {
cell(nameField).resizableColumn().align(AlignX.FILL)
}
separator()
}
abstract fun isModified(toolchain: T): Boolean
abstract fun apply(toolchain: T): T?
abstract fun reset(toolchain: T)
}

View file

@ -22,6 +22,7 @@
package com.falsepattern.zigbrains.project.toolchain.base package com.falsepattern.zigbrains.project.toolchain.base
import com.falsepattern.zigbrains.project.toolchain.ZigToolchainListService
import com.intellij.openapi.extensions.ExtensionPointName import com.intellij.openapi.extensions.ExtensionPointName
import com.intellij.openapi.project.Project import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.NamedConfigurable import com.intellij.openapi.ui.NamedConfigurable
@ -39,7 +40,7 @@ internal interface ZigToolchainProvider {
fun deserialize(data: Map<String, String>): ZigToolchain? fun deserialize(data: Map<String, String>): ZigToolchain?
fun serialize(toolchain: ZigToolchain): Map<String, String> fun serialize(toolchain: ZigToolchain): Map<String, String>
fun matchesSuggestion(toolchain: ZigToolchain, suggestion: ZigToolchain): Boolean fun matchesSuggestion(toolchain: ZigToolchain, suggestion: ZigToolchain): Boolean
fun createConfigurable(uuid: UUID, toolchain: ZigToolchain, project: Project): NamedConfigurable<UUID> fun createConfigurable(uuid: UUID, toolchain: ZigToolchain): ZigToolchainConfigurable<*>
fun suggestToolchains(): List<ZigToolchain> fun suggestToolchains(): List<ZigToolchain>
fun render(toolchain: ZigToolchain, component: SimpleColoredComponent) fun render(toolchain: ZigToolchain, component: SimpleColoredComponent)
} }
@ -60,12 +61,13 @@ suspend fun Project?.suggestZigToolchain(extraData: UserDataHolder): ZigToolchai
return EXTENSION_POINT_NAME.extensionList.firstNotNullOfOrNull { it.suggestToolchain(this, extraData) } return EXTENSION_POINT_NAME.extensionList.firstNotNullOfOrNull { it.suggestToolchain(this, extraData) }
} }
fun ZigToolchain.createNamedConfigurable(uuid: UUID, project: Project): NamedConfigurable<UUID> { fun ZigToolchain.createNamedConfigurable(uuid: UUID): ZigToolchainConfigurable<*> {
val provider = EXTENSION_POINT_NAME.extensionList.find { it.isCompatible(this) } ?: throw IllegalStateException() val provider = EXTENSION_POINT_NAME.extensionList.find { it.isCompatible(this) } ?: throw IllegalStateException()
return provider.createConfigurable(uuid, this, project) return provider.createConfigurable(uuid, this)
} }
fun suggestZigToolchains(existing: List<ZigToolchain>): List<ZigToolchain> { fun suggestZigToolchains(): List<ZigToolchain> {
val existing = ZigToolchainListService.getInstance().toolchains.map { (uuid, tc) -> tc }.toList()
return EXTENSION_POINT_NAME.extensionList.flatMap { ext -> return EXTENSION_POINT_NAME.extensionList.flatMap { ext ->
val compatibleExisting = existing.filter { ext.isCompatible(it) } val compatibleExisting = existing.filter { ext.isCompatible(it) }
val suggestions = ext.suggestToolchains() val suggestions = ext.suggestToolchains()

View file

@ -0,0 +1,115 @@
/*
* 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.downloader
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
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 {
suspend fun download(): List<Pair<String, ZigVersionInfo>> {
return withProgressText("Fetching zig version information") {
withContext(Dispatchers.IO) {
doDownload()
}
}
}
@OptIn(ExperimentalSerializationApi::class)
private suspend fun doDownload(): List<Pair<String, ZigVersionInfo>> {
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) }
}
}
return info?.mapNotNull { (version, data) -> parseVersion(data)?.let { Pair(version, it) } }?.toList() ?: emptyList()
}
private fun parseVersion(data: JsonElement): ZigVersionInfo? {
data as? JsonObject ?: return 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 { (dist, tb) -> getTarballIfCompatible(dist, tb) } ?: return null
return ZigVersionInfo(date, docs, notes, src, dist)
}
private fun getTarballIfCompatible(dist: String, tb: JsonElement): Tarball? {
if (!dist.contains('-'))
return 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 null
}
val theOS = when (os) {
"linux" -> OS.Linux
"windows" -> OS.Windows
"macos" -> OS.macOS
"freebsd" -> OS.FreeBSD
else -> return null
}
if (theArch != CpuArch.CURRENT || theOS != OS.CURRENT) {
return null
}
return Json.decodeFromJsonElement<Tarball>(tb)
}
}
}
@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

@ -35,7 +35,7 @@ import com.intellij.openapi.util.io.toNioPathOrNull
import com.intellij.openapi.vfs.toNioPathOrNull import com.intellij.openapi.vfs.toNioPathOrNull
import java.nio.file.Path import java.nio.file.Path
data class LocalZigToolchain(val location: Path, val std: Path? = null, val name: String? = null): ZigToolchain() { data class LocalZigToolchain(val location: Path, val std: Path? = null, override val name: String? = null): ZigToolchain() {
override fun workingDirectory(project: Project?): Path? { override fun workingDirectory(project: Project?): Path? {
return project?.guessProjectDir()?.toNioPathOrNull() return project?.guessProjectDir()?.toNioPathOrNull()
} }

View file

@ -22,75 +22,16 @@
package com.falsepattern.zigbrains.project.toolchain.local package com.falsepattern.zigbrains.project.toolchain.local
import com.falsepattern.zigbrains.project.toolchain.zigToolchainList import com.falsepattern.zigbrains.project.toolchain.base.ZigToolchainConfigurable
import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.NamedConfigurable
import com.intellij.openapi.util.NlsContexts
import com.intellij.ui.dsl.builder.panel
import kotlinx.coroutines.runBlocking
import java.util.UUID import java.util.UUID
import javax.swing.JComponent
class LocalZigToolchainConfigurable( class LocalZigToolchainConfigurable(
val uuid: UUID, uuid: UUID,
toolchain: LocalZigToolchain, toolchain: LocalZigToolchain
private val project: Project ): ZigToolchainConfigurable<LocalZigToolchain>(uuid, toolchain) {
): NamedConfigurable<UUID>() { override fun createPanel() = LocalZigToolchainPanel()
var toolchain: LocalZigToolchain = toolchain
set(value) {
zigToolchainList.setToolchain(uuid, value)
field = value
}
private var myView: LocalZigToolchainPanel? = null
override fun setDisplayName(name: String?) { override fun setDisplayName(name: String?) {
toolchain = toolchain.copy(name = name) toolchain = toolchain.copy(name = name)
} }
override fun getEditableObject(): UUID {
return uuid
}
override fun getBannerSlogan(): @NlsContexts.DetailedDescription String? {
return displayName
}
override fun createOptionsPanel(): JComponent? {
var view = myView
if (view == null) {
view = LocalZigToolchainPanel()
view.reset(this)
myView = view
}
return panel {
view.attach(this)
}
}
override fun getDisplayName(): @NlsContexts.ConfigurableName String? {
var theName = toolchain.name
if (theName == null) {
val version = toolchain.zig.let { runBlocking { it.getEnv(project) } }.getOrNull()?.version
if (version != null) {
theName = "Zig $version"
toolchain = toolchain.copy(name = theName)
}
}
return theName
}
override fun isModified(): Boolean {
return myView?.isModified(this) == true
}
override fun apply() {
myView?.apply(this)
}
override fun reset() {
myView?.reset(this)
}
override fun disposeUIResources() {
super.disposeUIResources()
}
} }

View file

@ -23,6 +23,7 @@
package com.falsepattern.zigbrains.project.toolchain.local package com.falsepattern.zigbrains.project.toolchain.local
import com.falsepattern.zigbrains.ZigBrainsBundle import com.falsepattern.zigbrains.ZigBrainsBundle
import com.falsepattern.zigbrains.project.toolchain.base.ZigToolchainPanel
import com.falsepattern.zigbrains.shared.coroutine.withEDTContext import com.falsepattern.zigbrains.shared.coroutine.withEDTContext
import com.falsepattern.zigbrains.shared.zigCoroutineScope import com.falsepattern.zigbrains.shared.zigCoroutineScope
import com.intellij.openapi.Disposable import com.intellij.openapi.Disposable
@ -45,8 +46,7 @@ import kotlinx.coroutines.launch
import javax.swing.event.DocumentEvent import javax.swing.event.DocumentEvent
import kotlin.io.path.pathString import kotlin.io.path.pathString
class LocalZigToolchainPanel() : Disposable { class LocalZigToolchainPanel() : ZigToolchainPanel<LocalZigToolchain>() {
private val nameField = JBTextField()
private val pathToToolchain = textFieldWithBrowseButton( private val pathToToolchain = textFieldWithBrowseButton(
null, null,
FileChooserDescriptorFactory.createSingleFolderDescriptor().withTitle(ZigBrainsBundle.message("dialog.title.zig-toolchain")) FileChooserDescriptorFactory.createSingleFolderDescriptor().withTitle(ZigBrainsBundle.message("dialog.title.zig-toolchain"))
@ -75,11 +75,8 @@ class LocalZigToolchainPanel() : Disposable {
).also { Disposer.register(this, it) } ).also { Disposer.register(this, it) }
private var debounce: Job? = null private var debounce: Job? = null
fun attach(p: Panel): Unit = with(p) { override fun attach(p: Panel): Unit = with(p) {
row("Name") { super.attach(p)
cell(nameField).resizableColumn().align(AlignX.FILL)
}
separator()
row(ZigBrainsBundle.message("settings.project.label.toolchain")) { row(ZigBrainsBundle.message("settings.project.label.toolchain")) {
cell(pathToToolchain).resizableColumn().align(AlignX.FILL) cell(pathToToolchain).resizableColumn().align(AlignX.FILL)
} }
@ -92,27 +89,23 @@ class LocalZigToolchainPanel() : Disposable {
} }
} }
fun isModified(cfg: LocalZigToolchainConfigurable): Boolean { override fun isModified(toolchain: LocalZigToolchain): Boolean {
val name = nameField.text.ifBlank { null } ?: return false val name = nameFieldValue ?: return false
val tc = cfg.toolchain
val location = this.pathToToolchain.text.ifBlank { null }?.toNioPathOrNull() ?: return false val location = this.pathToToolchain.text.ifBlank { null }?.toNioPathOrNull() ?: return false
val std = if (stdFieldOverride.isSelected) pathToStd.text.ifBlank { null }?.toNioPathOrNull() else null val std = if (stdFieldOverride.isSelected) pathToStd.text.ifBlank { null }?.toNioPathOrNull() else null
return name != cfg.displayName || tc.location != location || tc.std != std return name != toolchain.name || toolchain.location != location || toolchain.std != std
} }
fun apply(cfg: LocalZigToolchainConfigurable): Boolean { override fun apply(toolchain: LocalZigToolchain): LocalZigToolchain? {
val tc = cfg.toolchain val location = this.pathToToolchain.text.ifBlank { null }?.toNioPathOrNull() ?: return null
val location = this.pathToToolchain.text.ifBlank { null }?.toNioPathOrNull() ?: return false
val std = if (stdFieldOverride.isSelected) pathToStd.text.ifBlank { null }?.toNioPathOrNull() else null val std = if (stdFieldOverride.isSelected) pathToStd.text.ifBlank { null }?.toNioPathOrNull() else null
cfg.toolchain = tc.copy(location = location, std = std, name = nameField.text ?: "") return toolchain.copy(location = location, std = std, name = nameFieldValue ?: "")
return true
} }
fun reset(cfg: LocalZigToolchainConfigurable) { override fun reset(toolchain: LocalZigToolchain) {
nameField.text = cfg.displayName ?: "" nameFieldValue = toolchain.name
val tc = cfg.toolchain this.pathToToolchain.text = toolchain.location.pathString
this.pathToToolchain.text = tc.location.pathString val std = toolchain.std
val std = tc.std
if (std != null) { if (std != null) {
stdFieldOverride.isSelected = true stdFieldOverride.isSelected = true
pathToStd.text = std.pathString pathToStd.text = std.pathString

View file

@ -26,6 +26,7 @@ import com.falsepattern.zigbrains.direnv.DirenvCmd
import com.falsepattern.zigbrains.direnv.emptyEnv import com.falsepattern.zigbrains.direnv.emptyEnv
import com.falsepattern.zigbrains.project.settings.zigProjectSettings import com.falsepattern.zigbrains.project.settings.zigProjectSettings
import com.falsepattern.zigbrains.project.toolchain.base.ZigToolchain import com.falsepattern.zigbrains.project.toolchain.base.ZigToolchain
import com.falsepattern.zigbrains.project.toolchain.base.ZigToolchainConfigurable
import com.falsepattern.zigbrains.project.toolchain.base.ZigToolchainProvider import com.falsepattern.zigbrains.project.toolchain.base.ZigToolchainProvider
import com.intellij.openapi.project.Project import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.NamedConfigurable import com.intellij.openapi.ui.NamedConfigurable
@ -85,11 +86,10 @@ class LocalZigToolchainProvider: ZigToolchainProvider {
override fun createConfigurable( override fun createConfigurable(
uuid: UUID, uuid: UUID,
toolchain: ZigToolchain, toolchain: ZigToolchain
project: Project ): ZigToolchainConfigurable<*> {
): NamedConfigurable<UUID> {
toolchain as LocalZigToolchain toolchain as LocalZigToolchain
return LocalZigToolchainConfigurable(uuid, toolchain, project) return LocalZigToolchainConfigurable(uuid, toolchain)
} }
override fun suggestToolchains(): List<ZigToolchain> { override fun suggestToolchains(): List<ZigToolchain> {

View file

@ -0,0 +1,87 @@
/*
* 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.ui
import com.falsepattern.zigbrains.ZigBrainsBundle
import com.falsepattern.zigbrains.project.toolchain.downloader.ZigVersionInfo
import com.falsepattern.zigbrains.shared.coroutine.withEDTContext
import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory
import com.intellij.openapi.ui.ComboBox
import com.intellij.openapi.ui.DialogBuilder
import com.intellij.openapi.util.Disposer
import com.intellij.platform.ide.progress.ModalTaskOwner
import com.intellij.platform.ide.progress.TaskCancellation
import com.intellij.platform.ide.progress.withModalProgress
import com.intellij.ui.components.textFieldWithBrowseButton
import com.intellij.ui.dsl.builder.AlignX
import com.intellij.ui.dsl.builder.Cell
import com.intellij.ui.dsl.builder.panel
import java.awt.Component
import javax.swing.DefaultComboBoxModel
object Downloader {
suspend fun openDownloadDialog(component: Component) {
val info = withModalProgress(
component.let { ModalTaskOwner.component(it) },
"Fetching zig version information",
TaskCancellation.Companion.cancellable()) {
ZigVersionInfo.Companion.download()
}
withEDTContext(ModalityState.stateForComponent(component)) {
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()
Disposer.dispose(dialog)
}
}
}

View file

@ -0,0 +1,134 @@
/*
* 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.ui
import com.falsepattern.zigbrains.project.toolchain.ZigToolchainListService
import com.falsepattern.zigbrains.project.toolchain.ZigToolchainService
import com.falsepattern.zigbrains.project.toolchain.base.suggestZigToolchains
import com.intellij.openapi.Disposable
import com.intellij.openapi.options.Configurable
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.NlsContexts
import com.intellij.ui.dsl.builder.AlignX
import com.intellij.ui.dsl.builder.Panel
import com.intellij.ui.dsl.builder.panel
import java.awt.event.ItemEvent
import javax.swing.JComponent
import kotlin.collections.addAll
class ZigToolchainEditor(private val project: Project): Configurable {
private var myUi: UI? = null
override fun getDisplayName(): @NlsContexts.ConfigurableName String? {
return "Zig"
}
override fun createComponent(): JComponent? {
if (myUi != null) {
disposeUIResources()
}
val ui = UI()
myUi = ui
return panel {
ui.attach(this)
}
}
override fun isModified(): Boolean {
return myUi?.isModified() == true
}
override fun apply() {
myUi?.apply()
}
override fun reset() {
myUi?.reset()
}
override fun disposeUIResources() {
myUi?.let { Disposer.dispose(it) }
myUi = null
super.disposeUIResources()
}
inner class UI(): Disposable {
private val toolchainBox: TCComboBox
private var oldSelectionIndex: Int = 0
init {
val modelList = ArrayList<TCListElemIn>()
modelList.add(TCListElem.None)
modelList.addAll(ZigToolchainListService.Companion.getInstance().toolchains.map { it.asActual() })
modelList.add(Separator("", true))
modelList.addAll(TCListElem.fetchGroup)
modelList.add(Separator("Detected toolchains", true))
modelList.addAll(suggestZigToolchains().map { it.asSuggested() })
toolchainBox = TCComboBox(TCModel(modelList))
toolchainBox.addItemListener(::itemStateChanged)
reset()
}
private fun itemStateChanged(event: ItemEvent) {
if (event.stateChange != ItemEvent.SELECTED) {
return
}
val item = event.item
if (item !is TCListElem) {
toolchainBox.selectedIndex = oldSelectionIndex
return
}
when(item) {
is TCListElem.None, is TCListElem.Toolchain.Actual -> {
oldSelectionIndex = toolchainBox.selectedIndex
}
else -> {
toolchainBox.selectedIndex = oldSelectionIndex
}
}
}
fun attach(p: Panel): Unit = with(p) {
row("Toolchain") {
cell(toolchainBox).resizableColumn().align(AlignX.FILL)
}
}
fun isModified(): Boolean {
return ZigToolchainService.Companion.getInstance(project).toolchainUUID != toolchainBox.selectedToolchain
}
fun apply() {
ZigToolchainService.Companion.getInstance(project).toolchainUUID = toolchainBox.selectedToolchain
}
fun reset() {
toolchainBox.selectedToolchain = ZigToolchainService.Companion.getInstance(project).toolchainUUID
oldSelectionIndex = toolchainBox.selectedIndex
}
override fun dispose() {
}
}
}

View file

@ -0,0 +1,123 @@
/*
* 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.ui
import com.falsepattern.zigbrains.ZigBrainsBundle
import com.falsepattern.zigbrains.project.toolchain.ZigToolchainListService
import com.falsepattern.zigbrains.project.toolchain.base.ZigToolchain
import com.falsepattern.zigbrains.project.toolchain.base.createNamedConfigurable
import com.falsepattern.zigbrains.project.toolchain.base.suggestZigToolchains
import com.falsepattern.zigbrains.shared.zigCoroutineScope
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.Presentation
import com.intellij.openapi.project.DumbAwareAction
import com.intellij.openapi.ui.MasterDetailsComponent
import com.intellij.util.IconUtil
import kotlinx.coroutines.async
import java.util.UUID
import javax.swing.JComponent
import javax.swing.tree.DefaultTreeModel
class ZigToolchainListEditor() : MasterDetailsComponent() {
private var isTreeInitialized = false
private var myComponent: JComponent? = null
override fun createComponent(): JComponent {
if (!isTreeInitialized) {
initTree()
isTreeInitialized = true
}
val comp = super.createComponent()
myComponent = comp
return comp
}
override fun createActions(fromPopup: Boolean): List<AnAction> {
val add = object : DumbAwareAction({ "lmaoo" }, Presentation.NULL_STRING, IconUtil.addIcon) {
override fun actionPerformed(e: AnActionEvent) {
val modelList = ArrayList<TCListElemIn>()
modelList.addAll(TCListElem.fetchGroup)
modelList.add(Separator("Detected toolchains", true))
modelList.addAll(suggestZigToolchains().map { it.asSuggested() })
val model = TCModel.Companion(modelList)
val context = TCContext(null, model)
val popup = TCComboBoxPopup(context, null, ::onItemSelected)
popup.showInBestPositionFor(e.dataContext)
}
}
return listOf(add, MyDeleteAction())
}
override fun onItemDeleted(item: Any?) {
if (item is UUID) {
ZigToolchainListService.getInstance().removeToolchain(item)
}
super.onItemDeleted(item)
}
private fun onItemSelected(elem: TCListElem) {
when (elem) {
is TCListElem.Toolchain -> {
val uuid = UUID.randomUUID()
ZigToolchainListService.getInstance().setToolchain(uuid, elem.toolchain)
addToolchain(uuid, elem.toolchain)
(myTree.model as DefaultTreeModel).reload()
}
is TCListElem.Download -> {
zigCoroutineScope.async {
Downloader.openDownloadDialog(myComponent!!)
}
}
is TCListElem.FromDisk -> {}
is TCListElem.None -> {}
}
}
override fun reset() {
reloadTree()
super.reset()
}
override fun getEmptySelectionString() = ZigBrainsBundle.message("settings.toolchains.empty")
override fun getDisplayName() = ZigBrainsBundle.message("settings.toolchains.title")
private fun addToolchain(uuid: UUID, toolchain: ZigToolchain) {
val node = MyNode(toolchain.createNamedConfigurable(uuid))
addNode(node, myRoot)
}
private fun reloadTree() {
myRoot.removeAllChildren()
ZigToolchainListService.getInstance().toolchains.forEach { (uuid, toolchain) ->
addToolchain(uuid, toolchain)
}
(myTree.model as DefaultTreeModel).reload()
}
override fun disposeUIResources() {
super.disposeUIResources()
myComponent = null
}
}

View file

@ -0,0 +1,55 @@
/*
* 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.ui
import com.falsepattern.zigbrains.project.toolchain.base.ZigToolchain
import java.util.UUID
internal sealed interface TCListElemIn
internal sealed interface TCListElem : TCListElemIn {
sealed interface Toolchain : TCListElem {
val toolchain: ZigToolchain
@JvmRecord
data class Suggested(override val toolchain: ZigToolchain): Toolchain
@JvmRecord
data class Actual(val uuid: UUID, override val toolchain: ZigToolchain): Toolchain
}
object None: TCListElem
object Download : TCListElem
object FromDisk : TCListElem
companion object {
val fetchGroup get() = listOf(Download, FromDisk)
}
}
@JvmRecord
internal data class Separator(val text: String, val line: Boolean) : TCListElemIn
internal fun Pair<UUID, ZigToolchain>.asActual() = TCListElem.Toolchain.Actual(first, second)
internal fun ZigToolchain.asSuggested() = TCListElem.Toolchain.Suggested(this)

View file

@ -0,0 +1,215 @@
/*
* 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.ui
import ai.grazie.utils.attributes.value
import com.falsepattern.zigbrains.Icons
import com.falsepattern.zigbrains.project.toolchain.base.render
import com.intellij.icons.AllIcons
import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.ComboBox
import com.intellij.ui.CellRendererPanel
import com.intellij.ui.CollectionComboBoxModel
import com.intellij.ui.ColoredListCellRenderer
import com.intellij.ui.GroupHeaderSeparator
import com.intellij.ui.SimpleColoredComponent
import com.intellij.ui.SimpleTextAttributes
import com.intellij.ui.components.panels.OpaquePanel
import com.intellij.ui.popup.list.ComboBoxPopup
import com.intellij.util.Consumer
import com.intellij.util.ui.EmptyIcon
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import java.awt.BorderLayout
import java.awt.Component
import java.util.IdentityHashMap
import java.util.UUID
import javax.accessibility.AccessibleContext
import javax.swing.JList
import javax.swing.border.Border
internal class TCComboBoxPopup(
context: TCContext,
selected: TCListElem?,
onItemSelected: Consumer<TCListElem>,
) : ComboBoxPopup<TCListElem>(context, selected, onItemSelected)
internal class TCComboBox(model: TCModel): ComboBox<TCListElem>(model) {
init {
setRenderer(TCCellRenderer({model}))
}
var selectedToolchain: UUID?
set(value) {
if (value == null) {
selectedItem = TCListElem.None
return
}
for (i in 0..<model.size) {
val element = model.getElementAt(i)
if (element is TCListElem.Toolchain.Actual) {
if (element.uuid == value) {
selectedIndex = i
return
}
}
}
selectedItem = TCListElem.None
}
get() {
val item = selectedItem
return when(item) {
is TCListElem.Toolchain.Actual -> item.uuid
else -> null
}
}
}
internal class TCModel private constructor(elements: List<TCListElem>, private val separators: Map<TCListElem, Separator>) : CollectionComboBoxModel<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]
}
internal 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(): TCCellRenderer {
return TCCellRenderer(::getModel)
}
}
internal 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()
if (index == -1) {
component.isOpaque = false
panel.isOpaque = false
return panel
}
val separator = value?.let { model.separatorAbove(it) }
if (separator != null) {
val vGap = if (UIUtil.isUnderNativeMacLookAndFeel()) 1 else 3
val separatorComponent = GroupHeaderSeparator(JBUI.insets(vGap, 10, vGap, 0))
separatorComponent.isHideLine = !separator.line
separatorComponent.caption = separator.text.ifBlank { null }
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 = when(value) {
is TCListElem.Toolchain.Suggested -> AllIcons.General.Information
is TCListElem.Toolchain.Actual -> 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")
}
is TCListElem.None, null -> {
icon = AllIcons.General.BalloonError
append("<No Toolchain>", SimpleTextAttributes.ERROR_ATTRIBUTES)
}
}
}
}
private val EMPTY_ICON = EmptyIcon.create(1, 16)

View file

@ -0,0 +1,49 @@
/*
* 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.ui.popup
import com.falsepattern.zigbrains.Icons
import com.falsepattern.zigbrains.project.toolchain.base.render
import com.falsepattern.zigbrains.project.toolchain.ui.Separator
import com.falsepattern.zigbrains.project.toolchain.ui.TCListElem
import com.falsepattern.zigbrains.project.toolchain.ui.TCListElemIn
import com.intellij.icons.AllIcons
import com.intellij.openapi.project.Project
import com.intellij.ui.CellRendererPanel
import com.intellij.ui.CollectionListModel
import com.intellij.ui.ColoredListCellRenderer
import com.intellij.ui.GroupHeaderSeparator
import com.intellij.ui.SimpleColoredComponent
import com.intellij.ui.components.panels.OpaquePanel
import com.intellij.ui.popup.list.ComboBoxPopup
import com.intellij.util.Consumer
import com.intellij.util.ui.EmptyIcon
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import java.awt.BorderLayout
import java.awt.Component
import java.util.IdentityHashMap
import javax.accessibility.AccessibleContext
import javax.swing.JList
import javax.swing.border.Border

View file

@ -140,13 +140,13 @@
/> />
<projectConfigurable <projectConfigurable
parentId="language" parentId="language"
instance="com.falsepattern.zigbrains.project.settings.ZigConfigurable" instance="com.falsepattern.zigbrains.project.toolchain.ui.ZigToolchainEditor"
id="ZigConfigurable" id="ZigConfigurable"
displayName="Zig" displayName="Zig"
/> />
<applicationConfigurable <applicationConfigurable
parentId="ZigConfigurable" parentId="ZigConfigurable"
instance="com.falsepattern.zigbrains.project.toolchain.ZigToolchainListEditor" instance="com.falsepattern.zigbrains.project.toolchain.ui.ZigToolchainListEditor"
id="ZigToolchainConfigurable" id="ZigToolchainConfigurable"
displayName="Toolchain" displayName="Toolchain"
/> />