async toolchain resolution and other tweaks

This commit is contained in:
FalsePattern 2025-04-10 01:44:43 +02:00
parent c7e33ea8de
commit 3ceb61f2dd
Signed by: falsepattern
GPG key ID: E930CDEC50C50E23
19 changed files with 230 additions and 125 deletions

View file

@ -22,7 +22,6 @@
package com.falsepattern.zigbrains
import com.falsepattern.zigbrains.direnv.DirenvCmd
import com.falsepattern.zigbrains.project.toolchain.local.LocalZigToolchain
import com.falsepattern.zigbrains.project.toolchain.base.suggestZigToolchain
import com.intellij.ide.BrowserUtil

View file

@ -1,35 +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.direnv
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import kotlinx.coroutines.sync.Mutex
@Service(Service.Level.PROJECT)
class DirenvProjectService {
val mutex = Mutex()
}
val Project.direnvService get() = service<DirenvProjectService>()

View file

@ -29,23 +29,29 @@ import com.intellij.ide.impl.isTrusted
import com.intellij.notification.Notification
import com.intellij.notification.NotificationType
import com.intellij.notification.Notifications
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.openapi.project.guessProjectDir
import com.intellij.platform.util.progress.withProgressText
import com.intellij.util.io.awaitExit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import java.nio.file.Path
object DirenvCmd {
suspend fun importDirenv(project: Project): Env {
if (!direnvInstalled() || !project.isTrusted())
return emptyEnv
val workDir = project.guessProjectDir()?.toNioPath() ?: return emptyEnv
@Service(Service.Level.PROJECT)
class DirenvService(val project: Project) {
val mutex = Mutex()
val runOutput = run(project, workDir, "export", "json")
suspend fun import(): Env {
if (!isInstalled || !project.isTrusted() || project.isDefault)
return Env.empty
val workDir = project.guessProjectDir()?.toNioPath() ?: return Env.empty
val runOutput = run(workDir, "export", "json")
if (runOutput.error) {
if (runOutput.output.contains("is blocked")) {
Notifications.Bus.notify(Notification(
@ -54,7 +60,7 @@ object DirenvCmd {
ZigBrainsBundle.message("notification.content.direnv-blocked"),
NotificationType.ERROR
))
return emptyEnv
return Env.empty
} else {
Notifications.Bus.notify(Notification(
GROUP_DISPLAY_ID,
@ -62,22 +68,22 @@ object DirenvCmd {
ZigBrainsBundle.message("notification.content.direnv-error", runOutput.output),
NotificationType.ERROR
))
return emptyEnv
return Env.empty
}
}
return if (runOutput.output.isBlank()) {
emptyEnv
Env.empty
} else {
Env(Json.decodeFromString<Map<String, String>>(runOutput.output))
}
}
private suspend fun run(project: Project, workDir: Path, vararg args: String): DirenvOutput {
private suspend fun run(workDir: Path, vararg args: String): DirenvOutput {
val cli = GeneralCommandLine("direnv", *args).withWorkingDirectory(workDir)
val (process, exitCode) = withProgressText("Running ${cli.commandLineString}") {
withContext(Dispatchers.IO) {
project.direnvService.mutex.withLock {
mutex.withLock {
val process = cli.createProcess()
val exitCode = process.awaitExit()
process to exitCode
@ -94,17 +100,13 @@ object DirenvCmd {
return DirenvOutput(stdOut, false)
}
private const val GROUP_DISPLAY_ID = "zigbrains-direnv"
private val _direnvInstalled by lazy {
val isInstalled: Boolean by lazy {
// Using the builtin stuff here instead of Env because it should only scan for direnv on the process path
PathEnvironmentVariableUtil.findExecutableInPathOnAnyOS("direnv") != null
}
fun direnvInstalled() = _direnvInstalled
}
suspend fun Project?.getDirenv(): Env {
if (this == null)
return emptyEnv
return DirenvCmd.importDirenv(this)
companion object {
private const val GROUP_DISPLAY_ID = "zigbrains-direnv"
fun getInstance(project: Project): DirenvService = project.service()
}
}

View file

@ -34,6 +34,7 @@ import kotlin.io.path.isDirectory
import kotlin.io.path.isExecutable
import kotlin.io.path.isRegularFile
@JvmRecord
data class Env(val env: Map<String, String>) {
private val path get() = getVariable("PATH")?.split(File.pathSeparatorChar)
@ -55,6 +56,8 @@ data class Env(val env: Map<String, String>) {
emit(exePath)
}
}
}
val emptyEnv = Env(emptyMap())
companion object {
val empty = Env(emptyMap())
}
}

View file

@ -22,7 +22,6 @@
package com.falsepattern.zigbrains.project.execution.base
import com.falsepattern.zigbrains.direnv.DirenvCmd
import com.intellij.execution.ExecutionException
import com.intellij.execution.Executor
import com.intellij.execution.configurations.ConfigurationFactory

View file

@ -109,6 +109,18 @@ class ZigToolchainListService: SerializablePersistentStateComponent<ZigToolchain
}
}
override fun <T: ZigToolchain> withUniqueName(toolchain: T): T {
val baseName = toolchain.name ?: ""
var index = 0
var currentName = baseName
while (toolchains.any { (_, existing) -> existing.name == currentName }) {
index++
currentName = "$baseName ($index)"
}
@Suppress("UNCHECKED_CAST")
return toolchain.withName(currentName) as T
}
private fun notifyChanged() {
synchronized(changeListeners) {
var i = 0
@ -151,4 +163,5 @@ sealed interface IZigToolchainListService {
fun removeToolchain(uuid: UUID)
fun addChangeListener(listener: ToolchainListChangeListener)
fun removeChangeListener(listener: ToolchainListChangeListener)
fun <T: ZigToolchain> withUniqueName(toolchain: T): T
}

View file

@ -43,6 +43,11 @@ interface ZigToolchain {
fun pathToExecutable(toolName: String, project: Project? = null): Path
/**
* Returned object must be the same class.
*/
fun withName(newName: String?): ZigToolchain
data class Ref(
@JvmField
@Attribute

View file

@ -23,11 +23,14 @@
package com.falsepattern.zigbrains.project.toolchain.base
import com.falsepattern.zigbrains.project.toolchain.ZigToolchainListService
import com.falsepattern.zigbrains.shared.zigCoroutineScope
import com.intellij.openapi.extensions.ExtensionPointName
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.UserDataHolder
import com.intellij.ui.SimpleColoredComponent
import com.intellij.util.text.SemVer
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import java.util.UUID
private val EXTENSION_POINT_NAME = ExtensionPointName.create<ZigToolchainProvider>("com.falsepattern.zigbrains.toolchainProvider")
@ -41,7 +44,7 @@ internal interface ZigToolchainProvider {
fun serialize(toolchain: ZigToolchain): Map<String, String>
fun matchesSuggestion(toolchain: ZigToolchain, suggestion: ZigToolchain): Boolean
fun createConfigurable(uuid: UUID, toolchain: ZigToolchain): ZigToolchainConfigurable<*>
fun suggestToolchains(): List<Pair<SemVer?, ZigToolchain>>
fun suggestToolchains(): List<Deferred<ZigToolchain>>
fun render(toolchain: ZigToolchain, component: SimpleColoredComponent, isSuggestion: Boolean, isSelected: Boolean)
}
@ -66,13 +69,22 @@ fun ZigToolchain.createNamedConfigurable(uuid: UUID): ZigToolchainConfigurable<*
return provider.createConfigurable(uuid, this)
}
fun suggestZigToolchains(): List<ZigToolchain> {
fun suggestZigToolchains(): List<Deferred<ZigToolchain>> {
val existing = ZigToolchainListService.getInstance().toolchains.map { (_, tc) -> tc }.toList()
return EXTENSION_POINT_NAME.extensionList.flatMap { ext ->
val compatibleExisting = existing.filter { ext.isCompatible(it) }
val suggestions = ext.suggestToolchains()
suggestions.filter { suggestion -> compatibleExisting.none { existing -> ext.matchesSuggestion(existing, suggestion.second) } }
}.sortedByDescending { it.first }.map { it.second }
suggestions.map { suggestion ->
zigCoroutineScope.async {
val sugg = suggestion.await()
if (compatibleExisting.none { existing -> ext.matchesSuggestion(existing, sugg) }) {
sugg
} else {
throw IllegalArgumentException()
}
}
}
}
}
fun ZigToolchain.render(component: SimpleColoredComponent, isSuggestion: Boolean, isSelected: Boolean) {

View file

@ -74,7 +74,7 @@ object Downloader {
) {
version.downloadAndUnpack(downloadPath)
}
return LocalZigToolchain.tryFromPath(downloadPath)?.second
return LocalZigToolchain.tryFromPath(downloadPath)?.let { LocalSelector.browseFromDisk(component, it) }
}
@RequiresEdt

View file

@ -28,12 +28,19 @@ import com.falsepattern.zigbrains.project.toolchain.ZigToolchainListService
import com.falsepattern.zigbrains.project.toolchain.base.ZigToolchain
import com.falsepattern.zigbrains.project.toolchain.local.LocalZigToolchain
import com.falsepattern.zigbrains.shared.coroutine.asContextElement
import com.falsepattern.zigbrains.shared.coroutine.launchWithEDT
import com.falsepattern.zigbrains.shared.coroutine.runInterruptibleEDT
import com.falsepattern.zigbrains.shared.coroutine.withEDTContext
import com.falsepattern.zigbrains.shared.zigCoroutineScope
import com.intellij.icons.AllIcons
import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.fileChooser.FileChooser
import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory
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.DocumentAdapter
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBTextField
@ -42,17 +49,19 @@ import com.intellij.ui.dsl.builder.AlignX
import com.intellij.ui.dsl.builder.panel
import com.intellij.util.concurrency.annotations.RequiresEdt
import java.awt.Component
import java.util.concurrent.atomic.AtomicBoolean
import javax.swing.event.DocumentEvent
import kotlin.io.path.pathString
object LocalSelector {
suspend fun browseFromDisk(component: Component): ZigToolchain? {
return runInterruptibleEDT(component.asContextElement()) {
doBrowseFromDisk()
suspend fun browseFromDisk(component: Component, preSelected: LocalZigToolchain? = null): ZigToolchain? {
return withEDTContext(component.asContextElement()) {
doBrowseFromDisk(component, preSelected)
}
}
@RequiresEdt
private fun doBrowseFromDisk(): ZigToolchain? {
private suspend fun doBrowseFromDisk(component: Component, preSelected: LocalZigToolchain?): ZigToolchain? {
val dialog = DialogBuilder()
val name = JBTextField().also { it.columns = 25 }
val path = textFieldWithBrowseButton(
@ -62,26 +71,31 @@ object LocalSelector {
)
Disposer.register(dialog, path)
lateinit var errorMessageBox: JBLabel
fun verify(path: String) {
val tc = LocalZigToolchain.tryFromPathString(path)?.second
fun verify(tc: LocalZigToolchain?) {
var tc = tc
if (tc == null) {
errorMessageBox.icon = AllIcons.General.Error
errorMessageBox.text = ZigBrainsBundle.message("settings.toolchain.local-selector.state.invalid")
dialog.setOkActionEnabled(false)
} else if (ZigToolchainListService
} else {
val existingToolchain = ZigToolchainListService
.getInstance()
.toolchains
.mapNotNull { it.second as? LocalZigToolchain }
.any { it.location == tc.location }
) {
errorMessageBox.icon = AllIcons.General.Warning
errorMessageBox.text = tc.name?.let { ZigBrainsBundle.message("settings.toolchain.local-selector.state.already-exists-named", it) }
?: ZigBrainsBundle.message("settings.toolchain.local-selector.state.already-exists-unnamed")
dialog.setOkActionEnabled(true)
} else {
errorMessageBox.icon = AllIcons.General.Information
errorMessageBox.text = ZigBrainsBundle.message("settings.toolchain.local-selector.state.ok")
dialog.setOkActionEnabled(true)
.firstOrNull { it.location == tc.location }
if (existingToolchain != null) {
errorMessageBox.icon = AllIcons.General.Warning
errorMessageBox.text = existingToolchain.name?.let { ZigBrainsBundle.message("settings.toolchain.local-selector.state.already-exists-named", it) }
?: ZigBrainsBundle.message("settings.toolchain.local-selector.state.already-exists-unnamed")
dialog.setOkActionEnabled(true)
} else {
errorMessageBox.icon = AllIcons.General.Information
errorMessageBox.text = ZigBrainsBundle.message("settings.toolchain.local-selector.state.ok")
dialog.setOkActionEnabled(true)
}
}
if (tc != null) {
tc = ZigToolchainListService.getInstance().withUniqueName(tc)
}
val prevNameDefault = name.emptyText.text.trim() == name.text.trim() || name.text.isBlank()
name.emptyText.text = tc?.name ?: ""
@ -89,9 +103,20 @@ object LocalSelector {
name.text = name.emptyText.text
}
}
suspend fun verify(path: String) {
val tc = runCatching { withModalProgress(ModalTaskOwner.component(component), "Resolving toolchain", TaskCancellation.cancellable()) {
LocalZigToolchain.tryFromPathString(path)
} }.getOrNull()
verify(tc)
}
val active = AtomicBoolean(false)
path.addDocumentListener(object: DocumentAdapter() {
override fun textChanged(e: DocumentEvent) {
verify(path.text)
if (!active.get())
return
zigCoroutineScope.launchWithEDT(ModalityState.current()) {
verify(path.text)
}
}
})
val center = panel {
@ -110,19 +135,29 @@ object LocalSelector {
dialog.setTitle(ZigBrainsBundle.message("settings.toolchain.local-selector.title"))
dialog.addCancelAction()
dialog.addOkAction().also { it.setText(ZigBrainsBundle.message("settings.toolchain.local-selector.ok-action")) }
val chosenFile = FileChooser.chooseFile(
FileChooserDescriptorFactory.createSingleFolderDescriptor()
.withTitle(ZigBrainsBundle.message("settings.toolchain.local-selector.chooser.title")),
null,
null
)
if (chosenFile != null) {
verify(chosenFile.path)
path.text = chosenFile.path
if (preSelected == null) {
val chosenFile = FileChooser.chooseFile(
FileChooserDescriptorFactory.createSingleFolderDescriptor()
.withTitle(ZigBrainsBundle.message("settings.toolchain.local-selector.chooser.title")),
null,
null
)
if (chosenFile != null) {
verify(chosenFile.path)
path.text = chosenFile.path
}
} else {
verify(preSelected)
path.text = preSelected.location.pathString
}
active.set(true)
if (!dialog.showAndGet()) {
active.set(false)
return null
}
return LocalZigToolchain.tryFromPathString(path.text)?.second?.also { it.copy(name = name.text.ifBlank { null } ?: it.name) }
active.set(false)
return runCatching { withModalProgress(ModalTaskOwner.component(component), "Resolving toolchain", TaskCancellation.cancellable()) {
LocalZigToolchain.tryFromPathString(path.text)?.let { it.withName(name.text.ifBlank { null } ?: it.name) }
} }.getOrNull()
}
}

View file

@ -22,7 +22,6 @@
package com.falsepattern.zigbrains.project.toolchain.local
import com.falsepattern.zigbrains.direnv.DirenvCmd
import com.falsepattern.zigbrains.project.toolchain.base.ZigToolchain
import com.intellij.execution.ExecutionException
import com.intellij.execution.configurations.GeneralCommandLine
@ -32,8 +31,9 @@ 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 com.intellij.util.text.SemVer
import kotlinx.coroutines.delay
import java.nio.file.Path
import kotlin.random.Random
@JvmRecord
data class LocalZigToolchain(val location: Path, val std: Path? = null, override val name: String? = null): ZigToolchain {
@ -54,6 +54,10 @@ data class LocalZigToolchain(val location: Path, val std: Path? = null, override
return location.resolve(exeName)
}
override fun withName(newName: String?): LocalZigToolchain {
return this.copy(name = newName)
}
companion object {
val DIRENV_KEY = KeyWithDefaultValue.create<Boolean>("ZIG_LOCAL_DIRENV")
@ -67,27 +71,23 @@ data class LocalZigToolchain(val location: Path, val std: Path? = null, override
}
}
fun tryFromPathString(pathStr: String?): Pair<SemVer?, LocalZigToolchain>? {
return pathStr?.ifBlank { null }?.toNioPathOrNull()?.let(::tryFromPath)
suspend fun tryFromPathString(pathStr: String?): LocalZigToolchain? {
return pathStr?.ifBlank { null }?.toNioPathOrNull()?.let { tryFromPath(it) }
}
fun tryFromPath(path: Path): Pair<SemVer?, LocalZigToolchain>? {
suspend fun tryFromPath(path: Path): LocalZigToolchain? {
var tc = LocalZigToolchain(path)
if (!tc.zig.fileValid()) {
return null
}
val versionStr = tc.zig
.getEnvBlocking(null)
.getEnv(null)
.getOrNull()
?.version
val version: SemVer?
if (versionStr != null) {
version = SemVer.parseFromText(versionStr)
tc = tc.copy(name = "Zig $versionStr")
} else {
version = null
}
return version to tc
return tc
}
}
}

View file

@ -22,13 +22,12 @@
package com.falsepattern.zigbrains.project.toolchain.local
import com.falsepattern.zigbrains.direnv.DirenvCmd
import com.falsepattern.zigbrains.direnv.emptyEnv
import com.falsepattern.zigbrains.direnv.Env
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.shared.zigCoroutineScope
import com.intellij.openapi.project.Project
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
@ -37,7 +36,8 @@ import com.intellij.ui.SimpleColoredComponent
import com.intellij.ui.SimpleTextAttributes
import com.intellij.util.EnvironmentUtil
import com.intellij.util.system.OS
import com.intellij.util.text.SemVer
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import java.io.File
import java.nio.file.Files
import java.nio.file.Path
@ -53,7 +53,7 @@ class LocalZigToolchainProvider: ZigToolchainProvider {
// } else {
// emptyEnv
// }
val env = emptyEnv
val env = Env.empty
val zigExePath = env.findExecutableOnPATH("zig") ?: return null
return LocalZigToolchain(zigExePath.parent)
}
@ -98,7 +98,7 @@ class LocalZigToolchainProvider: ZigToolchainProvider {
return LocalZigToolchainConfigurable(uuid, toolchain)
}
override fun suggestToolchains(): List<Pair<SemVer?, ZigToolchain>> {
override fun suggestToolchains(): List<Deferred<ZigToolchain>> {
val res = HashSet<String>()
EnvironmentUtil.getValue("PATH")?.split(File.pathSeparatorChar)?.let { res.addAll(it.toList()) }
val wellKnown = getWellKnown()
@ -113,7 +113,7 @@ class LocalZigToolchainProvider: ZigToolchainProvider {
}
}
}
return res.mapNotNull { LocalZigToolchain.tryFromPathString(it) }
return res.map { zigCoroutineScope.async { LocalZigToolchain.tryFromPathString(it) ?: throw IllegalArgumentException() } }
}
override fun render(toolchain: ZigToolchain, component: SimpleColoredComponent, isSuggestion: Boolean, isSelected: Boolean) {

View file

@ -45,8 +45,6 @@ class ZigCompilerTool(toolchain: ZigToolchain) : ZigTool(toolchain) {
Result.failure(IllegalStateException("could not deserialize zig env", e))
}
}
fun getEnvBlocking(project: Project?) = runBlocking { getEnv(project) }
}
private val envJson = Json {

View file

@ -32,7 +32,7 @@ import java.util.UUID
internal object ZigToolchainComboBoxHandler {
@RequiresBackgroundThread
suspend fun onItemSelected(context: Component, elem: TCListElem.Pseudo): UUID? = when(elem) {
is TCListElem.Toolchain.Suggested -> elem.toolchain
is TCListElem.Toolchain.Suggested -> ZigToolchainListService.getInstance().withUniqueName(elem.toolchain)
is TCListElem.Download -> Downloader.downloadToolchain(context)
is TCListElem.FromDisk -> LocalSelector.browseFromDisk(context)
}?.let { ZigToolchainListService.getInstance().registerNewToolchain(it) }

View file

@ -184,10 +184,10 @@ class ZigToolchainEditor(private val isForDefaultProject: Boolean = false): SubC
private fun getModelList(): List<TCListElemIn> {
val modelList = ArrayList<TCListElemIn>()
modelList.add(TCListElem.None)
modelList.addAll(ZigToolchainListService.getInstance().toolchains.map { it.asActual() })
modelList.addAll(ZigToolchainListService.getInstance().toolchains.map { it.asActual() }.sortedBy { it.toolchain.name })
modelList.add(Separator("", true))
modelList.addAll(TCListElem.fetchGroup)
modelList.add(Separator(ZigBrainsBundle.message("settings.toolchain.model.detected.separator"), true))
modelList.addAll(suggestZigToolchains().map { it.asSuggested() })
modelList.addAll(suggestZigToolchains().map { it.asPending() })
return modelList
}

View file

@ -34,6 +34,7 @@ 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.observable.util.whenListChanged
import com.intellij.openapi.project.DumbAwareAction
import com.intellij.openapi.ui.MasterDetailsComponent
import com.intellij.util.IconUtil
@ -65,10 +66,13 @@ class ZigToolchainListEditor : MasterDetailsComponent(), ToolchainListChangeList
val modelList = ArrayList<TCListElemIn>()
modelList.addAll(TCListElem.fetchGroup)
modelList.add(Separator(ZigBrainsBundle.message("settings.toolchain.model.detected.separator"), true))
modelList.addAll(suggestZigToolchains().map { it.asSuggested() })
val model = TCModel.Companion(modelList)
modelList.addAll(suggestZigToolchains().map { it.asPending() })
val model = TCModel(modelList)
val context = TCContext(null, model)
val popup = TCComboBoxPopup(context, null, ::onItemSelected)
model.whenListChanged {
popup.syncWithModelChange()
}
popup.showInBestPositionFor(e.dataContext)
}
}

View file

@ -23,6 +23,10 @@
package com.falsepattern.zigbrains.project.toolchain.ui
import com.falsepattern.zigbrains.project.toolchain.base.ZigToolchain
import com.falsepattern.zigbrains.shared.zigCoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import java.util.UUID
@ -41,6 +45,7 @@ internal sealed interface TCListElem : TCListElemIn {
object None: TCListElem
object Download : TCListElem, Pseudo
object FromDisk : TCListElem, Pseudo
data class Pending(val elem: Deferred<TCListElem>): TCListElem
companion object {
val fetchGroup get() = listOf(Download, FromDisk)
@ -53,3 +58,5 @@ internal data class Separator(val text: String, val line: Boolean) : TCListElemI
internal fun Pair<UUID, ZigToolchain>.asActual() = TCListElem.Toolchain.Actual(first, second)
internal fun ZigToolchain.asSuggested() = TCListElem.Toolchain.Suggested(this)
internal fun Deferred<ZigToolchain>.asPending() = TCListElem.Pending(zigCoroutineScope.async { this@asPending.await().asSuggested() })

View file

@ -26,26 +26,42 @@ import ai.grazie.utils.attributes.value
import com.falsepattern.zigbrains.Icons
import com.falsepattern.zigbrains.ZigBrainsBundle
import com.falsepattern.zigbrains.project.toolchain.base.render
import com.falsepattern.zigbrains.shared.zigCoroutineScope
import com.intellij.icons.AllIcons
import com.intellij.openapi.application.EDT
import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.application.asContextElement
import com.intellij.openapi.application.impl.ModalityStateEx
import com.intellij.openapi.application.runInEdt
import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.ComboBox
import com.intellij.ui.AnimatedIcon
import com.intellij.ui.CellRendererPanel
import com.intellij.ui.ClientProperty
import com.intellij.ui.CollectionComboBoxModel
import com.intellij.ui.ColoredListCellRenderer
import com.intellij.ui.ComponentUtil
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.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.EmptyIcon
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import fleet.util.async.awaitResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.awt.BorderLayout
import java.awt.Component
import java.util.IdentityHashMap
import java.util.UUID
import java.util.concurrent.locks.ReentrantLock
import javax.accessibility.AccessibleContext
import javax.swing.CellRendererPane
import javax.swing.JComponent
import javax.swing.JList
import javax.swing.border.Border
@ -57,7 +73,7 @@ internal class TCComboBoxPopup(
internal class TCComboBox(model: TCModel): ComboBox<TCListElem>(model) {
init {
setRenderer(TCCellRenderer({model}))
setRenderer(TCCellRenderer { model })
}
var selectedToolchain: UUID?
@ -86,15 +102,17 @@ internal class TCComboBox(model: TCModel): ComboBox<TCListElem>(model) {
}
}
internal class TCModel private constructor(elements: List<TCListElem>, private var separators: Map<TCListElem, Separator>) : CollectionComboBoxModel<TCListElem>(elements) {
internal class TCModel private constructor(elements: List<TCListElem>, private var separators: MutableMap<TCListElem, Separator>) : CollectionComboBoxModel<TCListElem>(elements) {
private var counter: Int = 0
companion object {
operator fun invoke(input: List<TCListElemIn>): TCModel {
val (elements, separators) = convert(input)
val model = TCModel(elements, separators)
model.launchPendingResolve()
return model
}
private fun convert(input: List<TCListElemIn>): Pair<List<TCListElem>, Map<TCListElem, Separator>> {
private fun convert(input: List<TCListElemIn>): Pair<List<TCListElem>, MutableMap<TCListElem, Separator>> {
val separators = IdentityHashMap<TCListElem, Separator>()
var lastSeparator: Separator? = null
val elements = ArrayList<TCListElem>()
@ -117,10 +135,52 @@ internal class TCModel private constructor(elements: List<TCListElem>, private v
fun separatorAbove(elem: TCListElem) = separators[elem]
private fun launchPendingResolve() {
runInEdt(ModalityState.any()) {
val counter = this.counter
val size = this.size
for (i in 0..<size) {
val elem = getElementAt(i)
?: continue
if (elem !is TCListElem.Pending)
continue
zigCoroutineScope.launch(Dispatchers.EDT + ModalityState.any().asContextElement()) {
val newElem = elem.elem.awaitResult().getOrNull()
swap(elem, newElem, counter)
}
}
}
}
@RequiresEdt
private fun swap(old: TCListElem, new: TCListElem?, oldCounter: Int) {
val newCounter = this@TCModel.counter
if (oldCounter != newCounter) {
return
}
if (new == null) {
val index = this@TCModel.getElementIndex(old)
this@TCModel.remove(index)
val sep = separators.remove(old)
if (sep != null && this@TCModel.size > index) {
this@TCModel.getElementAt(index)?.let { separators[it] = sep }
}
return
}
val currentIndex = this@TCModel.getElementIndex(old)
separators.remove(old)?.let {
separators.put(new, it)
}
this@TCModel.setElementAt(new, currentIndex)
}
@RequiresEdt
fun updateContents(input: List<TCListElemIn>) {
counter++
val (elements, separators) = convert(input)
this.separators = separators
replaceAll(elements)
launchPendingResolve()
}
}
@ -216,7 +276,10 @@ internal class TCCellRenderer(val getModel: () -> TCModel) : ColoredListCellRend
icon = AllIcons.General.OpenDisk
append(ZigBrainsBundle.message("settings.toolchain.model.from-disk.text"))
}
is TCListElem.Pending -> {
icon = AllIcons.Empty
append("Loading\u2026", SimpleTextAttributes.GRAYED_ATTRIBUTES)
}
is TCListElem.None, null -> {
icon = AllIcons.General.BalloonError
append(ZigBrainsBundle.message("settings.toolchain.model.none.text"), SimpleTextAttributes.ERROR_ATTRIBUTES)

View file

@ -22,7 +22,7 @@
package com.falsepattern.zigbrains.shared.ipc
import com.falsepattern.zigbrains.direnv.emptyEnv
import com.falsepattern.zigbrains.direnv.Env
import com.intellij.execution.configurations.GeneralCommandLine
import com.intellij.openapi.util.SystemInfo
import com.intellij.openapi.util.io.FileUtil
@ -56,7 +56,7 @@ object IPCUtil {
if (SystemInfo.isWindows) {
return null
}
val mkfifo = emptyEnv
val mkfifo = Env.empty
.findAllExecutablesOnPATH("mkfifo")
.map { it.pathString }
.map(::MKFifo)
@ -67,7 +67,7 @@ object IPCUtil {
true
} ?: return null
val selectedBash = emptyEnv
val selectedBash = Env.empty
.findAllExecutablesOnPATH("bash")
.map { it.pathString }
.filter {