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

View file

@ -34,6 +34,7 @@ import kotlin.io.path.isDirectory
import kotlin.io.path.isExecutable import kotlin.io.path.isExecutable
import kotlin.io.path.isRegularFile import kotlin.io.path.isRegularFile
@JvmRecord
data class Env(val env: Map<String, String>) { data class Env(val env: Map<String, String>) {
private val path get() = getVariable("PATH")?.split(File.pathSeparatorChar) private val path get() = getVariable("PATH")?.split(File.pathSeparatorChar)
@ -55,6 +56,8 @@ data class Env(val env: Map<String, String>) {
emit(exePath) 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 package com.falsepattern.zigbrains.project.execution.base
import com.falsepattern.zigbrains.direnv.DirenvCmd
import com.intellij.execution.ExecutionException import com.intellij.execution.ExecutionException
import com.intellij.execution.Executor import com.intellij.execution.Executor
import com.intellij.execution.configurations.ConfigurationFactory 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() { private fun notifyChanged() {
synchronized(changeListeners) { synchronized(changeListeners) {
var i = 0 var i = 0
@ -151,4 +163,5 @@ sealed interface IZigToolchainListService {
fun removeToolchain(uuid: UUID) fun removeToolchain(uuid: UUID)
fun addChangeListener(listener: ToolchainListChangeListener) fun addChangeListener(listener: ToolchainListChangeListener)
fun removeChangeListener(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 fun pathToExecutable(toolName: String, project: Project? = null): Path
/**
* Returned object must be the same class.
*/
fun withName(newName: String?): ZigToolchain
data class Ref( data class Ref(
@JvmField @JvmField
@Attribute @Attribute

View file

@ -23,11 +23,14 @@
package com.falsepattern.zigbrains.project.toolchain.base package com.falsepattern.zigbrains.project.toolchain.base
import com.falsepattern.zigbrains.project.toolchain.ZigToolchainListService import com.falsepattern.zigbrains.project.toolchain.ZigToolchainListService
import com.falsepattern.zigbrains.shared.zigCoroutineScope
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.util.UserDataHolder import com.intellij.openapi.util.UserDataHolder
import com.intellij.ui.SimpleColoredComponent import com.intellij.ui.SimpleColoredComponent
import com.intellij.util.text.SemVer import com.intellij.util.text.SemVer
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import java.util.UUID import java.util.UUID
private val EXTENSION_POINT_NAME = ExtensionPointName.create<ZigToolchainProvider>("com.falsepattern.zigbrains.toolchainProvider") 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 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): ZigToolchainConfigurable<*> 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) 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) return provider.createConfigurable(uuid, this)
} }
fun suggestZigToolchains(): List<ZigToolchain> { fun suggestZigToolchains(): List<Deferred<ZigToolchain>> {
val existing = ZigToolchainListService.getInstance().toolchains.map { (_, tc) -> tc }.toList() val existing = ZigToolchainListService.getInstance().toolchains.map { (_, 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()
suggestions.filter { suggestion -> compatibleExisting.none { existing -> ext.matchesSuggestion(existing, suggestion.second) } } suggestions.map { suggestion ->
}.sortedByDescending { it.first }.map { it.second } 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) { fun ZigToolchain.render(component: SimpleColoredComponent, isSuggestion: Boolean, isSelected: Boolean) {

View file

@ -74,7 +74,7 @@ object Downloader {
) { ) {
version.downloadAndUnpack(downloadPath) version.downloadAndUnpack(downloadPath)
} }
return LocalZigToolchain.tryFromPath(downloadPath)?.second return LocalZigToolchain.tryFromPath(downloadPath)?.let { LocalSelector.browseFromDisk(component, it) }
} }
@RequiresEdt @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.base.ZigToolchain
import com.falsepattern.zigbrains.project.toolchain.local.LocalZigToolchain import com.falsepattern.zigbrains.project.toolchain.local.LocalZigToolchain
import com.falsepattern.zigbrains.shared.coroutine.asContextElement 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.runInterruptibleEDT
import com.falsepattern.zigbrains.shared.coroutine.withEDTContext
import com.falsepattern.zigbrains.shared.zigCoroutineScope
import com.intellij.icons.AllIcons import com.intellij.icons.AllIcons
import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.fileChooser.FileChooser import com.intellij.openapi.fileChooser.FileChooser
import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory
import com.intellij.openapi.ui.DialogBuilder import com.intellij.openapi.ui.DialogBuilder
import com.intellij.openapi.util.Disposer 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.DocumentAdapter
import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBTextField 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.ui.dsl.builder.panel
import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.concurrency.annotations.RequiresEdt
import java.awt.Component import java.awt.Component
import java.util.concurrent.atomic.AtomicBoolean
import javax.swing.event.DocumentEvent import javax.swing.event.DocumentEvent
import kotlin.io.path.pathString
object LocalSelector { object LocalSelector {
suspend fun browseFromDisk(component: Component): ZigToolchain? { suspend fun browseFromDisk(component: Component, preSelected: LocalZigToolchain? = null): ZigToolchain? {
return runInterruptibleEDT(component.asContextElement()) { return withEDTContext(component.asContextElement()) {
doBrowseFromDisk() doBrowseFromDisk(component, preSelected)
} }
} }
@RequiresEdt @RequiresEdt
private fun doBrowseFromDisk(): ZigToolchain? { private suspend fun doBrowseFromDisk(component: Component, preSelected: LocalZigToolchain?): ZigToolchain? {
val dialog = DialogBuilder() val dialog = DialogBuilder()
val name = JBTextField().also { it.columns = 25 } val name = JBTextField().also { it.columns = 25 }
val path = textFieldWithBrowseButton( val path = textFieldWithBrowseButton(
@ -62,26 +71,31 @@ object LocalSelector {
) )
Disposer.register(dialog, path) Disposer.register(dialog, path)
lateinit var errorMessageBox: JBLabel lateinit var errorMessageBox: JBLabel
fun verify(path: String) { fun verify(tc: LocalZigToolchain?) {
val tc = LocalZigToolchain.tryFromPathString(path)?.second var tc = tc
if (tc == null) { if (tc == null) {
errorMessageBox.icon = AllIcons.General.Error errorMessageBox.icon = AllIcons.General.Error
errorMessageBox.text = ZigBrainsBundle.message("settings.toolchain.local-selector.state.invalid") errorMessageBox.text = ZigBrainsBundle.message("settings.toolchain.local-selector.state.invalid")
dialog.setOkActionEnabled(false) dialog.setOkActionEnabled(false)
} else if (ZigToolchainListService } else {
val existingToolchain = ZigToolchainListService
.getInstance() .getInstance()
.toolchains .toolchains
.mapNotNull { it.second as? LocalZigToolchain } .mapNotNull { it.second as? LocalZigToolchain }
.any { it.location == tc.location } .firstOrNull { it.location == tc.location }
) { if (existingToolchain != null) {
errorMessageBox.icon = AllIcons.General.Warning errorMessageBox.icon = AllIcons.General.Warning
errorMessageBox.text = tc.name?.let { ZigBrainsBundle.message("settings.toolchain.local-selector.state.already-exists-named", it) } 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") ?: ZigBrainsBundle.message("settings.toolchain.local-selector.state.already-exists-unnamed")
dialog.setOkActionEnabled(true) dialog.setOkActionEnabled(true)
} else { } else {
errorMessageBox.icon = AllIcons.General.Information errorMessageBox.icon = AllIcons.General.Information
errorMessageBox.text = ZigBrainsBundle.message("settings.toolchain.local-selector.state.ok") errorMessageBox.text = ZigBrainsBundle.message("settings.toolchain.local-selector.state.ok")
dialog.setOkActionEnabled(true) dialog.setOkActionEnabled(true)
}
}
if (tc != null) {
tc = ZigToolchainListService.getInstance().withUniqueName(tc)
} }
val prevNameDefault = name.emptyText.text.trim() == name.text.trim() || name.text.isBlank() val prevNameDefault = name.emptyText.text.trim() == name.text.trim() || name.text.isBlank()
name.emptyText.text = tc?.name ?: "" name.emptyText.text = tc?.name ?: ""
@ -89,9 +103,20 @@ object LocalSelector {
name.text = name.emptyText.text 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() { path.addDocumentListener(object: DocumentAdapter() {
override fun textChanged(e: DocumentEvent) { override fun textChanged(e: DocumentEvent) {
verify(path.text) if (!active.get())
return
zigCoroutineScope.launchWithEDT(ModalityState.current()) {
verify(path.text)
}
} }
}) })
val center = panel { val center = panel {
@ -110,19 +135,29 @@ object LocalSelector {
dialog.setTitle(ZigBrainsBundle.message("settings.toolchain.local-selector.title")) dialog.setTitle(ZigBrainsBundle.message("settings.toolchain.local-selector.title"))
dialog.addCancelAction() dialog.addCancelAction()
dialog.addOkAction().also { it.setText(ZigBrainsBundle.message("settings.toolchain.local-selector.ok-action")) } dialog.addOkAction().also { it.setText(ZigBrainsBundle.message("settings.toolchain.local-selector.ok-action")) }
val chosenFile = FileChooser.chooseFile( if (preSelected == null) {
FileChooserDescriptorFactory.createSingleFolderDescriptor() val chosenFile = FileChooser.chooseFile(
.withTitle(ZigBrainsBundle.message("settings.toolchain.local-selector.chooser.title")), FileChooserDescriptorFactory.createSingleFolderDescriptor()
null, .withTitle(ZigBrainsBundle.message("settings.toolchain.local-selector.chooser.title")),
null null,
) null
if (chosenFile != null) { )
verify(chosenFile.path) if (chosenFile != null) {
path.text = chosenFile.path verify(chosenFile.path)
path.text = chosenFile.path
}
} else {
verify(preSelected)
path.text = preSelected.location.pathString
} }
active.set(true)
if (!dialog.showAndGet()) { if (!dialog.showAndGet()) {
active.set(false)
return null 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 package com.falsepattern.zigbrains.project.toolchain.local
import com.falsepattern.zigbrains.direnv.DirenvCmd
import com.falsepattern.zigbrains.project.toolchain.base.ZigToolchain import com.falsepattern.zigbrains.project.toolchain.base.ZigToolchain
import com.intellij.execution.ExecutionException import com.intellij.execution.ExecutionException
import com.intellij.execution.configurations.GeneralCommandLine 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.SystemInfo
import com.intellij.openapi.util.io.toNioPathOrNull import com.intellij.openapi.util.io.toNioPathOrNull
import com.intellij.openapi.vfs.toNioPathOrNull import com.intellij.openapi.vfs.toNioPathOrNull
import com.intellij.util.text.SemVer import kotlinx.coroutines.delay
import java.nio.file.Path import java.nio.file.Path
import kotlin.random.Random
@JvmRecord @JvmRecord
data class LocalZigToolchain(val location: Path, val std: Path? = null, override val name: String? = null): ZigToolchain { 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) return location.resolve(exeName)
} }
override fun withName(newName: String?): LocalZigToolchain {
return this.copy(name = newName)
}
companion object { companion object {
val DIRENV_KEY = KeyWithDefaultValue.create<Boolean>("ZIG_LOCAL_DIRENV") 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>? { suspend fun tryFromPathString(pathStr: String?): LocalZigToolchain? {
return pathStr?.ifBlank { null }?.toNioPathOrNull()?.let(::tryFromPath) 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) var tc = LocalZigToolchain(path)
if (!tc.zig.fileValid()) { if (!tc.zig.fileValid()) {
return null return null
} }
val versionStr = tc.zig val versionStr = tc.zig
.getEnvBlocking(null) .getEnv(null)
.getOrNull() .getOrNull()
?.version ?.version
val version: SemVer?
if (versionStr != null) { if (versionStr != null) {
version = SemVer.parseFromText(versionStr)
tc = tc.copy(name = "Zig $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 package com.falsepattern.zigbrains.project.toolchain.local
import com.falsepattern.zigbrains.direnv.DirenvCmd import com.falsepattern.zigbrains.direnv.Env
import com.falsepattern.zigbrains.direnv.emptyEnv
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.ZigToolchainConfigurable
import com.falsepattern.zigbrains.project.toolchain.base.ZigToolchainProvider import com.falsepattern.zigbrains.project.toolchain.base.ZigToolchainProvider
import com.falsepattern.zigbrains.shared.zigCoroutineScope
import com.intellij.openapi.project.Project import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.NamedConfigurable
import com.intellij.openapi.util.UserDataHolder import com.intellij.openapi.util.UserDataHolder
import com.intellij.openapi.util.io.FileUtil import com.intellij.openapi.util.io.FileUtil
import com.intellij.openapi.util.io.toNioPathOrNull import com.intellij.openapi.util.io.toNioPathOrNull
@ -37,7 +36,8 @@ import com.intellij.ui.SimpleColoredComponent
import com.intellij.ui.SimpleTextAttributes import com.intellij.ui.SimpleTextAttributes
import com.intellij.util.EnvironmentUtil import com.intellij.util.EnvironmentUtil
import com.intellij.util.system.OS 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.io.File
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
@ -53,7 +53,7 @@ class LocalZigToolchainProvider: ZigToolchainProvider {
// } else { // } else {
// emptyEnv // emptyEnv
// } // }
val env = emptyEnv val env = Env.empty
val zigExePath = env.findExecutableOnPATH("zig") ?: return null val zigExePath = env.findExecutableOnPATH("zig") ?: return null
return LocalZigToolchain(zigExePath.parent) return LocalZigToolchain(zigExePath.parent)
} }
@ -98,7 +98,7 @@ class LocalZigToolchainProvider: ZigToolchainProvider {
return LocalZigToolchainConfigurable(uuid, toolchain) return LocalZigToolchainConfigurable(uuid, toolchain)
} }
override fun suggestToolchains(): List<Pair<SemVer?, ZigToolchain>> { override fun suggestToolchains(): List<Deferred<ZigToolchain>> {
val res = HashSet<String>() val res = HashSet<String>()
EnvironmentUtil.getValue("PATH")?.split(File.pathSeparatorChar)?.let { res.addAll(it.toList()) } EnvironmentUtil.getValue("PATH")?.split(File.pathSeparatorChar)?.let { res.addAll(it.toList()) }
val wellKnown = getWellKnown() 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) { 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)) Result.failure(IllegalStateException("could not deserialize zig env", e))
} }
} }
fun getEnvBlocking(project: Project?) = runBlocking { getEnv(project) }
} }
private val envJson = Json { private val envJson = Json {

View file

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

View file

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

View file

@ -23,6 +23,10 @@
package com.falsepattern.zigbrains.project.toolchain.ui package com.falsepattern.zigbrains.project.toolchain.ui
import com.falsepattern.zigbrains.project.toolchain.base.ZigToolchain 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 import java.util.UUID
@ -41,6 +45,7 @@ internal sealed interface TCListElem : TCListElemIn {
object None: TCListElem object None: TCListElem
object Download : TCListElem, Pseudo object Download : TCListElem, Pseudo
object FromDisk : TCListElem, Pseudo object FromDisk : TCListElem, Pseudo
data class Pending(val elem: Deferred<TCListElem>): TCListElem
companion object { companion object {
val fetchGroup get() = listOf(Download, FromDisk) val fetchGroup get() = listOf(Download, FromDisk)
@ -52,4 +57,6 @@ internal data class Separator(val text: String, val line: Boolean) : TCListElemI
internal fun Pair<UUID, ZigToolchain>.asActual() = TCListElem.Toolchain.Actual(first, second) internal fun Pair<UUID, ZigToolchain>.asActual() = TCListElem.Toolchain.Actual(first, second)
internal fun ZigToolchain.asSuggested() = TCListElem.Toolchain.Suggested(this) 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.Icons
import com.falsepattern.zigbrains.ZigBrainsBundle import com.falsepattern.zigbrains.ZigBrainsBundle
import com.falsepattern.zigbrains.project.toolchain.base.render import com.falsepattern.zigbrains.project.toolchain.base.render
import com.falsepattern.zigbrains.shared.zigCoroutineScope
import com.intellij.icons.AllIcons 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.project.Project
import com.intellij.openapi.ui.ComboBox import com.intellij.openapi.ui.ComboBox
import com.intellij.ui.AnimatedIcon
import com.intellij.ui.CellRendererPanel import com.intellij.ui.CellRendererPanel
import com.intellij.ui.ClientProperty
import com.intellij.ui.CollectionComboBoxModel import com.intellij.ui.CollectionComboBoxModel
import com.intellij.ui.ColoredListCellRenderer import com.intellij.ui.ColoredListCellRenderer
import com.intellij.ui.ComponentUtil
import com.intellij.ui.GroupHeaderSeparator import com.intellij.ui.GroupHeaderSeparator
import com.intellij.ui.SimpleColoredComponent import com.intellij.ui.SimpleColoredComponent
import com.intellij.ui.SimpleTextAttributes import com.intellij.ui.SimpleTextAttributes
import com.intellij.ui.components.panels.OpaquePanel import com.intellij.ui.components.panels.OpaquePanel
import com.intellij.ui.popup.list.ComboBoxPopup import com.intellij.ui.popup.list.ComboBoxPopup
import com.intellij.util.Consumer import com.intellij.util.Consumer
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.EmptyIcon import com.intellij.util.ui.EmptyIcon
import com.intellij.util.ui.JBUI import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil 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.BorderLayout
import java.awt.Component import java.awt.Component
import java.util.IdentityHashMap import java.util.IdentityHashMap
import java.util.UUID import java.util.UUID
import java.util.concurrent.locks.ReentrantLock
import javax.accessibility.AccessibleContext import javax.accessibility.AccessibleContext
import javax.swing.CellRendererPane
import javax.swing.JComponent
import javax.swing.JList import javax.swing.JList
import javax.swing.border.Border import javax.swing.border.Border
@ -57,7 +73,7 @@ internal class TCComboBoxPopup(
internal class TCComboBox(model: TCModel): ComboBox<TCListElem>(model) { internal class TCComboBox(model: TCModel): ComboBox<TCListElem>(model) {
init { init {
setRenderer(TCCellRenderer({model})) setRenderer(TCCellRenderer { model })
} }
var selectedToolchain: UUID? 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 { companion object {
operator fun invoke(input: List<TCListElemIn>): TCModel { operator fun invoke(input: List<TCListElemIn>): TCModel {
val (elements, separators) = convert(input) val (elements, separators) = convert(input)
val model = TCModel(elements, separators) val model = TCModel(elements, separators)
model.launchPendingResolve()
return model 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>() val separators = IdentityHashMap<TCListElem, Separator>()
var lastSeparator: Separator? = null var lastSeparator: Separator? = null
val elements = ArrayList<TCListElem>() val elements = ArrayList<TCListElem>()
@ -117,10 +135,52 @@ internal class TCModel private constructor(elements: List<TCListElem>, private v
fun separatorAbove(elem: TCListElem) = separators[elem] 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>) { fun updateContents(input: List<TCListElemIn>) {
counter++
val (elements, separators) = convert(input) val (elements, separators) = convert(input)
this.separators = separators this.separators = separators
replaceAll(elements) replaceAll(elements)
launchPendingResolve()
} }
} }
@ -216,7 +276,10 @@ internal class TCCellRenderer(val getModel: () -> TCModel) : ColoredListCellRend
icon = AllIcons.General.OpenDisk icon = AllIcons.General.OpenDisk
append(ZigBrainsBundle.message("settings.toolchain.model.from-disk.text")) 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 -> { is TCListElem.None, null -> {
icon = AllIcons.General.BalloonError icon = AllIcons.General.BalloonError
append(ZigBrainsBundle.message("settings.toolchain.model.none.text"), SimpleTextAttributes.ERROR_ATTRIBUTES) append(ZigBrainsBundle.message("settings.toolchain.model.none.text"), SimpleTextAttributes.ERROR_ATTRIBUTES)

View file

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