fully functional selection logic

This commit is contained in:
FalsePattern 2025-04-08 00:36:44 +02:00
parent 9676b70821
commit ee5a2463b9
Signed by: falsepattern
GPG key ID: E930CDEC50C50E23
14 changed files with 618 additions and 264 deletions

View file

@ -35,6 +35,7 @@ import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.DialogBuilder
import com.intellij.openapi.util.SystemInfo
import com.intellij.openapi.util.io.toNioPathOrNull
import com.intellij.platform.util.progress.reportSequentialProgress
import com.intellij.ui.BrowserHyperlinkListener
import com.intellij.ui.HyperlinkLabel
import com.intellij.ui.components.JBPanel
@ -47,6 +48,7 @@ import com.jetbrains.cidr.execution.debugger.CidrDebuggerPathManager
import com.jetbrains.cidr.execution.debugger.backend.bin.UrlProvider
import com.jetbrains.cidr.execution.debugger.backend.lldb.LLDBDriverConfiguration
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import java.io.IOException
import java.net.URL
@ -167,7 +169,9 @@ class ZigDebuggerToolchainService {
}
try {
withContext(Dispatchers.IO) {
downloadAndUnArchive(baseDir, downloadableBinaries)
}
return DownloadResult.Ok(baseDir)
} catch (e: IOException) {
//TODO logging
@ -206,6 +210,7 @@ class ZigDebuggerToolchainService {
@Throws(IOException::class)
@RequiresEdt
private suspend fun downloadAndUnArchive(baseDir: Path, binariesToDownload: List<DownloadableDebuggerBinary>) {
reportSequentialProgress { reporter ->
val service = DownloadableFileService.getInstance()
val downloadDir = baseDir.toFile()
@ -217,7 +222,7 @@ class ZigDebuggerToolchainService {
val downloader = service.createDownloader(descriptions, "Debugger downloading")
val downloadDirectory = downloadPath().toFile()
val downloadResults = withContext(Dispatchers.IO) {
val downloadResults = reporter.sizedStep(100) {
coroutineToIndicator {
downloader.download(downloadDirectory)
}
@ -228,13 +233,18 @@ class ZigDebuggerToolchainService {
val binaryToDownload = binariesToDownload.first { it.url == downloadUrl }
val propertyName = binaryToDownload.propertyName
val archiveFile = result.first
reporter.indeterminateStep {
coroutineToIndicator {
Unarchiver.unarchive(archiveFile.toPath(), baseDir, binaryToDownload.prefix)
}
}
archiveFile.delete()
versions[propertyName] = binaryToDownload.version
}
saveVersionsFile(baseDir, versions)
}
}
private fun lldbUrls(): Pair<URL, URL>? {
val lldb = UrlProvider.lldb(OS.CURRENT, CpuArch.CURRENT) ?: return null

View file

@ -25,7 +25,9 @@ package com.falsepattern.zigbrains.project.toolchain
import com.falsepattern.zigbrains.project.toolchain.base.ZigToolchain
import com.falsepattern.zigbrains.project.toolchain.base.resolve
import com.falsepattern.zigbrains.project.toolchain.base.toRef
import com.falsepattern.zigbrains.shared.zigCoroutineScope
import com.intellij.openapi.components.*
import kotlinx.coroutines.launch
import java.lang.ref.WeakReference
import java.util.UUID
@ -37,19 +39,43 @@ import java.util.UUID
class ZigToolchainListService: SerializablePersistentStateComponent<ZigToolchainListService.State>(State()) {
private val changeListeners = ArrayList<WeakReference<ToolchainListChangeListener>>()
fun setToolchain(uuid: UUID, toolchain: ZigToolchain) {
val str = uuid.toString()
val ref = toolchain.toRef()
updateState {
val newMap = HashMap<String, ZigToolchain.Ref>()
newMap.putAll(it.toolchains)
newMap[uuid.toString()] = toolchain.toRef()
newMap[str] = ref
it.copy(toolchains = newMap)
}
notifyChanged()
}
fun registerNewToolchain(toolchain: ZigToolchain): UUID {
val ref = toolchain.toRef()
var uuid = UUID.randomUUID()
updateState {
val newMap = HashMap<String, ZigToolchain.Ref>()
newMap.putAll(it.toolchains)
var uuidStr = uuid.toString()
while (newMap.containsKey(uuidStr)) {
uuid = UUID.randomUUID()
uuidStr = uuid.toString()
}
newMap[uuidStr] = ref
it.copy(toolchains = newMap)
}
notifyChanged()
return uuid
}
fun getToolchain(uuid: UUID): ZigToolchain? {
return state.toolchains[uuid.toString()]?.resolve()
}
fun hasToolchain(uuid: UUID): Boolean {
return state.toolchains.containsKey(uuid.toString())
}
fun removeToolchain(uuid: UUID) {
val str = uuid.toString()
updateState {
@ -67,7 +93,9 @@ class ZigToolchainListService: SerializablePersistentStateComponent<ZigToolchain
changeListeners.removeAt(i)
continue
}
zigCoroutineScope.launch {
v.toolchainListChanged()
}
i++
}
}
@ -88,6 +116,7 @@ class ZigToolchainListService: SerializablePersistentStateComponent<ZigToolchain
}
}
val toolchains: Sequence<Pair<UUID, ZigToolchain>>
get() = state.toolchains
.asSequence()
@ -109,6 +138,6 @@ class ZigToolchainListService: SerializablePersistentStateComponent<ZigToolchain
@FunctionalInterface
interface ToolchainListChangeListener {
fun toolchainListChanged()
suspend fun toolchainListChanged()
}
}

View file

@ -39,7 +39,16 @@ import java.util.UUID
)
class ZigToolchainService: SerializablePersistentStateComponent<ZigToolchainService.State>(State()) {
var toolchainUUID: UUID?
get() = state.toolchain.ifBlank { null }?.let { UUID.fromString(it) }
get() = state.toolchain.ifBlank { null }?.let { UUID.fromString(it) }?.takeIf {
if (ZigToolchainListService.getInstance().hasToolchain(it)) {
true
} else {
updateState {
it.copy(toolchain = "")
}
false
}
}
set(value) {
updateState {
it.copy(toolchain = value?.toString() ?: "")
@ -49,11 +58,10 @@ class ZigToolchainService: SerializablePersistentStateComponent<ZigToolchainServ
val toolchain: ZigToolchain?
get() = toolchainUUID?.let { ZigToolchainListService.getInstance().getToolchain(it) }
@JvmRecord
data class State(
@JvmField
@Attribute
val toolchain: String = ""
var toolchain: String = ""
)
companion object {

View file

@ -0,0 +1,57 @@
package com.falsepattern.zigbrains.project.toolchain.downloader
import java.nio.file.Files
import java.nio.file.Path
import kotlin.contracts.ExperimentalContracts
import kotlin.io.path.exists
import kotlin.io.path.isDirectory
enum class DirectoryState {
Invalid,
NotAbsolute,
NotDirectory,
NotEmpty,
CreateNew,
Ok;
fun isValid(): Boolean {
return when(this) {
Invalid, NotAbsolute, NotDirectory, NotEmpty -> false
CreateNew, Ok -> true
}
}
companion object {
@JvmStatic
fun determine(path: Path?): DirectoryState {
if (path == null) {
return Invalid
}
if (!path.isAbsolute) {
return NotAbsolute
}
if (!path.exists()) {
var parent: Path? = path.parent
while(parent != null) {
if (!parent.exists()) {
parent = parent.parent
continue
}
if (!parent.isDirectory()) {
return NotDirectory
}
return CreateNew
}
return Invalid
}
if (!path.isDirectory()) {
return NotDirectory
}
val isEmpty = Files.newDirectoryStream(path).use { !it.iterator().hasNext() }
if (!isEmpty) {
return NotEmpty
}
return Ok
}
}
}

View file

@ -20,14 +20,16 @@
* along with ZigBrains. If not, see <https://www.gnu.org/licenses/>.
*/
package com.falsepattern.zigbrains.project.toolchain.ui
package com.falsepattern.zigbrains.project.toolchain.downloader
import com.falsepattern.zigbrains.ZigBrainsBundle
import com.falsepattern.zigbrains.project.toolchain.downloader.ZigVersionInfo
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.runInterruptibleEDT
import com.intellij.icons.AllIcons
import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory
import com.intellij.openapi.observable.util.whenFocusGained
import com.intellij.openapi.ui.ComboBox
import com.intellij.openapi.ui.DialogBuilder
import com.intellij.openapi.util.Disposer
@ -43,95 +45,38 @@ import com.intellij.ui.dsl.builder.AlignX
import com.intellij.ui.dsl.builder.Cell
import com.intellij.ui.dsl.builder.panel
import com.intellij.util.asSafely
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import com.intellij.util.concurrency.annotations.RequiresEdt
import java.awt.Component
import java.nio.file.Files
import java.nio.file.Path
import java.util.UUID
import java.util.*
import javax.swing.DefaultComboBoxModel
import javax.swing.JList
import javax.swing.event.DocumentEvent
import kotlin.contracts.ExperimentalContracts
import kotlin.io.path.exists
import kotlin.io.path.isDirectory
//TODO lang
object Downloader {
suspend fun downloadToolchain(component: Component): UUID? {
suspend fun downloadToolchain(component: Component): ZigToolchain? {
val info = withModalProgress(
ModalTaskOwner.component(component),
"Fetching zig version information",
TaskCancellation.cancellable()) {
withContext(Dispatchers.IO) {
TaskCancellation.cancellable()
) {
ZigVersionInfo.downloadVersionList()
}
}
val (downloadPath, version) = runInterruptibleEDT(component.asContextElement()) {
selectToolchain(info)
} ?: return null
withModalProgress(
ModalTaskOwner.component(component),
"Downloading zig tarball",
TaskCancellation.cancellable()) {
withContext(Dispatchers.IO) {
"Installing Zig ${version.version}",
TaskCancellation.cancellable()
) {
version.downloadAndUnpack(downloadPath)
}
}
return null
}
private enum class DirectoryState {
Invalid,
NotAbsolute,
NotDirectory,
NotEmpty,
CreateNew,
Ok;
fun isValid(): Boolean {
return when(this) {
Invalid, NotAbsolute, NotDirectory, NotEmpty -> false
CreateNew, Ok -> true
}
}
companion object {
@OptIn(ExperimentalContracts::class)
@JvmStatic
fun determine(path: Path?): DirectoryState {
if (path == null) {
return Invalid
}
if (!path.isAbsolute) {
return NotAbsolute
}
if (!path.exists()) {
var parent: Path? = path.parent
while(parent != null) {
if (!parent.exists()) {
parent = parent.parent
continue
}
if (!parent.isDirectory()) {
return NotDirectory
}
return CreateNew
}
return Invalid
}
if (!path.isDirectory()) {
return NotDirectory
}
val isEmpty = Files.newDirectoryStream(path).use { !it.iterator().hasNext() }
if (!isEmpty) {
return NotEmpty
}
return Ok
}
}
return LocalZigToolchain.tryFromPath(downloadPath)
}
@RequiresEdt
private fun selectToolchain(info: List<ZigVersionInfo>): Pair<Path, ZigVersionInfo>? {
val dialog = DialogBuilder()
val theList = ComboBox(DefaultComboBoxModel(info.toTypedArray()))
@ -148,14 +93,14 @@ object Downloader {
}
val outputPath = textFieldWithBrowseButton(
null,
FileChooserDescriptorFactory.createSingleFolderDescriptor().withTitle(ZigBrainsBundle.message("dialog.title.zig-toolchain"))
FileChooserDescriptorFactory.createSingleFolderDescriptor()
.withTitle(ZigBrainsBundle.message("dialog.title.zig-toolchain"))
)
Disposer.register(dialog, outputPath)
outputPath.textField.columns = 50
lateinit var errorMessageBox: JBLabel
outputPath.addDocumentListener(object: DocumentAdapter() {
override fun textChanged(e: DocumentEvent) {
fun onChanged() {
val path = outputPath.text.ifBlank { null }?.toNioPathOrNull()
val state = DirectoryState.determine(path)
if (state.isValid()) {
@ -175,6 +120,13 @@ object Downloader {
}
dialog.window.repaint()
}
outputPath.whenFocusGained {
onChanged()
}
outputPath.addDocumentListener(object: DocumentAdapter() {
override fun textChanged(e: DocumentEvent) {
onChanged()
}
})
var archiveSizeCell: Cell<*>? = null
fun detect(item: ZigVersionInfo) {
@ -200,7 +152,7 @@ object Downloader {
}
detect(info[0])
dialog.centerPanel(center)
dialog.setTitle("Version Selector")
dialog.setTitle("Zig Downloader")
dialog.addCancelAction()
dialog.addOkAction().also { it.setText("Download") }
if (!dialog.showAndGet()) {
@ -216,8 +168,4 @@ object Downloader {
return path to version
}
private suspend fun installToolchain(path: Path, version: ZigVersionInfo): Boolean {
TODO("Not yet implemented")
}
}

View file

@ -0,0 +1,104 @@
/*
* 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.falsepattern.zigbrains.Icons
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.local.LocalZigToolchain
import com.falsepattern.zigbrains.shared.coroutine.asContextElement
import com.falsepattern.zigbrains.shared.coroutine.runInterruptibleEDT
import com.intellij.icons.AllIcons
import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory
import com.intellij.openapi.ui.DialogBuilder
import com.intellij.openapi.util.Disposer
import com.intellij.ui.DocumentAdapter
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.textFieldWithBrowseButton
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 javax.swing.event.DocumentEvent
object LocalSelector {
suspend fun browseFromDisk(component: Component): ZigToolchain? {
return runInterruptibleEDT(component.asContextElement()) {
doBrowseFromDisk()
}
}
@RequiresEdt
private fun doBrowseFromDisk(): ZigToolchain? {
val dialog = DialogBuilder()
val path = textFieldWithBrowseButton(
null,
FileChooserDescriptorFactory.createSingleFolderDescriptor()
.withTitle(ZigBrainsBundle.message("dialog.title.zig-toolchain"))
)
Disposer.register(dialog, path)
path.textField.columns = 50
lateinit var errorMessageBox: JBLabel
path.addDocumentListener(object: DocumentAdapter() {
override fun textChanged(e: DocumentEvent) {
val tc = LocalZigToolchain.tryFromPathString(path.text)
if (tc == null) {
errorMessageBox.icon = AllIcons.General.Error
errorMessageBox.text = "Invalid toolchain path"
dialog.setOkActionEnabled(false)
} else if (ZigToolchainListService
.getInstance()
.toolchains
.mapNotNull { it.second as? LocalZigToolchain }
.any { it.location == tc.location }
) {
errorMessageBox.icon = AllIcons.General.Warning
errorMessageBox.text = tc.name?.let { "Toolchain already exists as \"$it\"" } ?: "Toolchain already exists"
dialog.setOkActionEnabled(true)
} else {
errorMessageBox.icon = Icons.Zig
errorMessageBox.text = tc.name ?: "OK"
dialog.setOkActionEnabled(true)
}
}
})
val center = panel {
row("Path:") {
cell(path).resizableColumn().align(AlignX.FILL)
}
row {
errorMessageBox = JBLabel()
cell(errorMessageBox)
}
}
dialog.centerPanel(center)
dialog.setTitle("Zig Browser")
dialog.addCancelAction()
dialog.addOkAction().also { it.setText("Add") }
if (!dialog.showAndGet()) {
return null
}
return LocalZigToolchain.tryFromPathString(path.text)
}
}

View file

@ -24,12 +24,12 @@ package com.falsepattern.zigbrains.project.toolchain.downloader
import com.falsepattern.zigbrains.shared.Unarchiver
import com.intellij.openapi.application.PathManager
import com.intellij.openapi.progress.EmptyProgressIndicator
import com.intellij.openapi.progress.ProgressManager
import com.intellij.openapi.progress.coroutineToIndicator
import com.intellij.openapi.util.io.FileUtil
import com.intellij.openapi.util.io.toNioPathOrNull
import com.intellij.platform.util.progress.reportProgress
import com.intellij.platform.util.progress.reportSequentialProgress
import com.intellij.platform.util.progress.withProgressText
import com.intellij.platform.util.progress.*
import com.intellij.util.asSafely
import com.intellij.util.download.DownloadableFileService
import com.intellij.util.io.createDirectories
@ -38,6 +38,8 @@ import com.intellij.util.io.move
import com.intellij.util.system.CpuArch
import com.intellij.util.system.OS
import com.intellij.util.text.SemVer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@ -47,9 +49,14 @@ import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.decodeFromStream
import java.io.File
import java.lang.IllegalStateException
import java.nio.file.Files
import java.nio.file.Path
import java.util.*
import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.deleteRecursively
import kotlin.io.path.isDirectory
import kotlin.io.path.name
@JvmRecord
data class ZigVersionInfo(
@ -60,62 +67,105 @@ data class ZigVersionInfo(
val src: Tarball?,
val dist: Tarball
) {
suspend fun downloadAndUnpack(into: Path): Boolean {
return reportProgress { reporter ->
try {
@Throws(Exception::class)
suspend fun downloadAndUnpack(into: Path) {
reportProgress { reporter ->
into.createDirectories()
} catch (e: Exception) {
return@reportProgress false
val tarball = downloadTarball(dist, into, reporter)
unpackTarball(tarball, into, reporter)
tarball.delete()
flattenDownloadDir(into, reporter)
}
}
companion object {
@OptIn(ExperimentalSerializationApi::class)
suspend fun downloadVersionList(): List<ZigVersionInfo> {
return withContext(Dispatchers.IO) {
val service = DownloadableFileService.getInstance()
val tempFile = FileUtil.createTempFile(tempPluginDir, "index", ".json", false, false)
val desc = service.createFileDescription("https://ziglang.org/download/index.json", tempFile.name)
val downloader = service.createDownloader(listOf(desc), "Zig version information")
val downloadResults = coroutineToIndicator {
downloader.download(tempPluginDir)
}
if (downloadResults.isEmpty())
return@withContext emptyList()
val index = downloadResults[0].first
val info = index.inputStream().use { Json.decodeFromStream<JsonObject>(it) }
index.delete()
return@withContext info.mapNotNull { (version, data) -> parseVersion(version, data) }.toList()
}
}
}
@JvmRecord
@Serializable
data class Tarball(val tarball: String, val shasum: String, val size: Int)
}
private suspend fun downloadTarball(dist: ZigVersionInfo.Tarball, into: Path, reporter: ProgressReporter): Path {
return withContext(Dispatchers.IO) {
val service = DownloadableFileService.getInstance()
val fileName = dist.tarball.substringAfterLast('/')
val tempFile = FileUtil.createTempFile(into.toFile(), "tarball", fileName, false, false)
val desc = service.createFileDescription(dist.tarball, tempFile.name)
val downloader = service.createDownloader(listOf(desc), "Zig version information downloading")
val downloader = service.createDownloader(listOf(desc), "Zig tarball")
val downloadResults = reporter.sizedStep(100) {
coroutineToIndicator {
downloader.download(into.toFile())
}
}
if (downloadResults.isEmpty())
return@reportProgress false
val tarball = downloadResults[0].first
reporter.indeterminateStep("Extracting tarball") {
Unarchiver.unarchive(tarball.toPath(), into)
tarball.delete()
val contents = Files.newDirectoryStream(into).use { it.toList() }
throw IllegalStateException("No file downloaded")
return@withContext downloadResults[0].first.toPath()
}
}
private suspend fun flattenDownloadDir(dir: Path, reporter: ProgressReporter) {
withContext(Dispatchers.IO) {
val contents = Files.newDirectoryStream(dir).use { it.toList() }
if (contents.size == 1 && contents[0].isDirectory()) {
val src = contents[0]
reporter.indeterminateStep {
coroutineToIndicator {
val indicator = ProgressManager.getInstance().progressIndicator ?: EmptyProgressIndicator()
indicator.isIndeterminate = true
indicator.text = "Flattening directory"
Files.newDirectoryStream(src).use { stream ->
stream.forEach {
it.move(into.resolve(src.relativize(it)))
indicator.text2 = it.name
it.move(dir.resolve(src.relativize(it)))
}
}
}
}
src.delete()
}
}
return@reportProgress true
}
}
companion object {
@OptIn(ExperimentalSerializationApi::class)
suspend fun downloadVersionList(): List<ZigVersionInfo> {
val service = DownloadableFileService.getInstance()
val tempFile = FileUtil.createTempFile(tempPluginDir, "index", ".json", false, false)
val desc = service.createFileDescription("https://ziglang.org/download/index.json", tempFile.name)
val downloader = service.createDownloader(listOf(desc), "Zig version information downloading")
val downloadResults = coroutineToIndicator {
downloader.download(tempPluginDir)
}
if (downloadResults.isEmpty())
return emptyList()
val index = downloadResults[0].first
val info = index.inputStream().use { Json.decodeFromStream<JsonObject>(it) }
index.delete()
return info.mapNotNull { (version, data) -> parseVersion(version, data) }.toList()
}
}
private fun parseVersion(versionKey: String, data: JsonElement): ZigVersionInfo? {
@OptIn(ExperimentalPathApi::class)
private suspend fun unpackTarball(tarball: Path, into: Path, reporter: ProgressReporter) {
withContext(Dispatchers.IO) {
try {
reporter.indeterminateStep {
coroutineToIndicator {
Unarchiver.unarchive(tarball, into)
}
}
} catch (e: Throwable) {
tarball.delete()
val contents = Files.newDirectoryStream(into).use { it.toList() }
if (contents.size == 1 && contents[0].isDirectory()) {
contents[0].deleteRecursively()
}
throw e
}
}
}
private fun parseVersion(versionKey: String, data: JsonElement): ZigVersionInfo? {
if (data !is JsonObject)
return null
@ -125,16 +175,16 @@ data class ZigVersionInfo(
?: 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 notes = data["notes"]?.asSafely<JsonPrimitive>()?.content ?: ""
val src = data["src"]?.asSafely<JsonObject>()?.let { Json.decodeFromJsonElement<ZigVersionInfo.Tarball>(it) }
val dist = data.firstNotNullOfOrNull { (dist, tb) -> getTarballIfCompatible(dist, tb) }
?: return null
return ZigVersionInfo(version, date, docs, notes, src, dist)
}
}
private fun getTarballIfCompatible(dist: String, tb: JsonElement): Tarball? {
private fun getTarballIfCompatible(dist: String, tb: JsonElement): ZigVersionInfo.Tarball? {
if (!dist.contains('-'))
return null
val (arch, os) = dist.split('-', limit = 2)
@ -155,13 +205,7 @@ data class ZigVersionInfo(
if (theArch != CpuArch.CURRENT || theOS != OS.CURRENT) {
return null
}
return Json.decodeFromJsonElement<Tarball>(tb)
}
}
return Json.decodeFromJsonElement<ZigVersionInfo.Tarball>(tb)
}
@JvmRecord
@Serializable
data class Tarball(val tarball: String, val shasum: String, val size: Int)
private val tempPluginDir get(): File = PathManager.getTempPath().toNioPathOrNull()!!.resolve("zigbrains").toFile()

View file

@ -65,8 +65,8 @@ data class LocalZigToolchain(val location: Path, val std: Path? = null, override
}
}
fun tryFromPathString(pathStr: String): LocalZigToolchain? {
return pathStr.toNioPathOrNull()?.let(::tryFromPath)
fun tryFromPathString(pathStr: String?): LocalZigToolchain? {
return pathStr?.ifBlank { null }?.toNioPathOrNull()?.let(::tryFromPath)
}
fun tryFromPath(path: Path): LocalZigToolchain? {

View file

@ -0,0 +1,39 @@
/*
* 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.downloader.Downloader
import com.falsepattern.zigbrains.project.toolchain.downloader.LocalSelector
import com.intellij.util.concurrency.annotations.RequiresBackgroundThread
import java.awt.Component
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.Download -> Downloader.downloadToolchain(context)
is TCListElem.FromDisk -> LocalSelector.browseFromDisk(context)
}?.let { ZigToolchainListService.getInstance().registerNewToolchain(it) }
}

View file

@ -25,15 +25,33 @@ 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.falsepattern.zigbrains.shared.coroutine.asContextElement
import com.falsepattern.zigbrains.shared.coroutine.launchWithEDT
import com.falsepattern.zigbrains.shared.coroutine.withEDTContext
import com.falsepattern.zigbrains.shared.zigCoroutineScope
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.EDT
import com.intellij.openapi.options.Configurable
import com.intellij.openapi.options.ShowSettingsUtil
import com.intellij.openapi.options.newEditor.SettingsDialog
import com.intellij.openapi.options.newEditor.SettingsTreeView
import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.DialogWrapper
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 com.intellij.util.concurrency.annotations.RequiresEdt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import java.awt.event.ItemEvent
import java.lang.reflect.Field
import java.lang.reflect.Method
import java.util.UUID
import javax.swing.JComponent
import kotlin.collections.addAll
@ -74,7 +92,7 @@ class ZigToolchainEditor(private val project: Project): Configurable {
inner class UI(): Disposable, ZigToolchainListService.ToolchainListChangeListener {
private val toolchainBox: TCComboBox
private var oldSelectionIndex: Int = 0
private var selectOnNextReload: UUID? = null
private val model: TCModel
init {
model = TCModel(getModelList())
@ -89,27 +107,44 @@ class ZigToolchainEditor(private val project: Project): Configurable {
return
}
val item = event.item
if (item !is TCListElem) {
toolchainBox.selectedIndex = oldSelectionIndex
if (item !is TCListElem.Pseudo)
return
}
when(item) {
is TCListElem.None, is TCListElem.Toolchain.Actual -> {
oldSelectionIndex = toolchainBox.selectedIndex
}
else -> {
toolchainBox.selectedIndex = oldSelectionIndex
zigCoroutineScope.launch(toolchainBox.asContextElement()) {
val uuid = ZigToolchainComboBoxHandler.onItemSelected(toolchainBox, item)
withEDTContext(toolchainBox.asContextElement()) {
applyUUIDNowOrOnReload(uuid)
}
}
}
override fun toolchainListChanged() {
val selected = model.selected
override suspend fun toolchainListChanged() {
withContext(Dispatchers.EDT + toolchainBox.asContextElement()) {
val list = getModelList()
model.updateContents(list)
val onReload = selectOnNextReload
selectOnNextReload = null
if (onReload != null) {
val element = list.firstOrNull { when(it) {
is TCListElem.Toolchain.Actual -> it.uuid == onReload
else -> false
} }
model.selectedItem = element
return@withContext
}
val selected = model.selected
if (selected != null && list.contains(selected)) {
model.selectedItem = selected
} else {
return@withContext
}
if (selected is TCListElem.Toolchain.Actual) {
val uuid = selected.uuid
val element = list.firstOrNull { when(it) {
is TCListElem.Toolchain.Actual -> it.uuid == uuid
else -> false
} }
model.selectedItem = element
return@withContext
}
model.selectedItem = TCListElem.None
}
}
@ -117,6 +152,35 @@ class ZigToolchainEditor(private val project: Project): Configurable {
fun attach(p: Panel): Unit = with(p) {
row("Toolchain") {
cell(toolchainBox).resizableColumn().align(AlignX.FILL)
button("Funny") { e ->
zigCoroutineScope.launchWithEDT(toolchainBox.asContextElement()) {
val config = ZigToolchainListEditor()
var inited = false
var selectedUUID: UUID? = toolchainBox.selectedToolchain
config.addItemSelectedListener {
if (inited) {
selectedUUID = it
}
}
val apply = ShowSettingsUtil.getInstance().editConfigurable(DialogWrapper.findInstance(toolchainBox)?.contentPane, config) {
config.selectNodeInTree(selectedUUID)
inited = true
}
if (apply) {
applyUUIDNowOrOnReload(selectedUUID)
}
}
}
}
}
@RequiresEdt
private fun applyUUIDNowOrOnReload(uuid: UUID?) {
toolchainBox.selectedToolchain = uuid
if (uuid != null && toolchainBox.selectedToolchain == null) {
selectOnNextReload = uuid
} else {
selectOnNextReload = null
}
}
@ -130,9 +194,10 @@ class ZigToolchainEditor(private val project: Project): Configurable {
fun reset() {
toolchainBox.selectedToolchain = ZigToolchainService.getInstance(project).toolchainUUID
oldSelectionIndex = toolchainBox.selectedIndex
}
override fun dispose() {
ZigToolchainListService.getInstance().removeChangeListener(this)
}

View file

@ -22,27 +22,56 @@
package com.falsepattern.zigbrains.project.toolchain.ui
import com.falsepattern.zigbrains.Icons
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.project.toolchain.downloader.Downloader
import com.falsepattern.zigbrains.project.toolchain.local.LocalZigToolchain
import com.falsepattern.zigbrains.shared.coroutine.asContextElement
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.EDT
import com.intellij.openapi.components.Service
import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory
import com.intellij.openapi.project.DumbAwareAction
import com.intellij.openapi.ui.DialogBuilder
import com.intellij.openapi.ui.MasterDetailsComponent
import com.intellij.openapi.ui.NamedConfigurable
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.io.toNioPathOrNull
import com.intellij.ui.DocumentAdapter
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.textFieldWithBrowseButton
import com.intellij.ui.dsl.builder.AlignX
import com.intellij.ui.dsl.builder.panel
import com.intellij.util.Consumer
import com.intellij.util.IconUtil
import com.intellij.util.asSafely
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.UUID
import javax.swing.JComponent
import javax.swing.event.DocumentEvent
import javax.swing.tree.DefaultTreeModel
class ZigToolchainListEditor() : MasterDetailsComponent(), ZigToolchainListService.ToolchainListChangeListener {
class ZigToolchainListEditor : MasterDetailsComponent(), ZigToolchainListService.ToolchainListChangeListener {
private var isTreeInitialized = false
private var registered: Boolean = false
private var itemSelectedListeners = ArrayList<Consumer<UUID?>>()
fun addItemSelectedListener(c: Consumer<UUID?>) {
synchronized(itemSelectedListeners) {
itemSelectedListeners.add(c)
}
}
override fun createComponent(): JComponent {
if (!isTreeInitialized) {
@ -72,6 +101,14 @@ class ZigToolchainListEditor() : MasterDetailsComponent(), ZigToolchainListServi
return listOf(add, MyDeleteAction())
}
override fun updateSelection(configurable: NamedConfigurable<*>?) {
super.updateSelection(configurable)
val uuid = configurable?.editableObject as? UUID
synchronized(itemSelectedListeners) {
itemSelectedListeners.forEach { it.consume(uuid) }
}
}
override fun onItemDeleted(item: Any?) {
if (item is UUID) {
ZigToolchainListService.getInstance().removeToolchain(item)
@ -80,18 +117,15 @@ class ZigToolchainListEditor() : MasterDetailsComponent(), ZigToolchainListServi
}
private fun onItemSelected(elem: TCListElem) {
when (elem) {
is TCListElem.Toolchain -> {
val uuid = UUID.randomUUID()
ZigToolchainListService.getInstance().setToolchain(uuid, elem.toolchain)
}
is TCListElem.Download -> {
if (elem !is TCListElem.Pseudo)
return
zigCoroutineScope.launch(myWholePanel.asContextElement()) {
Downloader.downloadToolchain(myWholePanel)
val uuid = ZigToolchainComboBoxHandler.onItemSelected(myWholePanel, elem)
if (uuid != null) {
withEDTContext(myWholePanel.asContextElement()) {
selectNodeInTree(uuid)
}
}
is TCListElem.FromDisk -> {}
is TCListElem.None -> {}
}
}
@ -110,11 +144,15 @@ class ZigToolchainListEditor() : MasterDetailsComponent(), ZigToolchainListServi
}
private fun reloadTree() {
val currentSelection = selectedObject?.asSafely<UUID>()
myRoot.removeAllChildren()
ZigToolchainListService.getInstance().toolchains.forEach { (uuid, toolchain) ->
addToolchain(uuid, toolchain)
}
(myTree.model as DefaultTreeModel).reload()
currentSelection?.let {
selectNodeInTree(it)
}
}
override fun disposeUIResources() {
@ -124,7 +162,9 @@ class ZigToolchainListEditor() : MasterDetailsComponent(), ZigToolchainListServi
}
}
override fun toolchainListChanged() {
override suspend fun toolchainListChanged() {
withEDTContext(myWholePanel.asContextElement()) {
reloadTree()
}
}
}

View file

@ -27,20 +27,20 @@ import java.util.UUID
internal sealed interface TCListElemIn
internal sealed interface TCListElem : TCListElemIn {
sealed interface Pseudo: TCListElem
sealed interface Toolchain : TCListElem {
val toolchain: ZigToolchain
@JvmRecord
data class Suggested(override val toolchain: ZigToolchain): Toolchain
data class Suggested(override val toolchain: ZigToolchain): Toolchain, Pseudo
@JvmRecord
data class Actual(val uuid: UUID, override val toolchain: ZigToolchain): Toolchain
}
object None: TCListElem
object Download : TCListElem
object FromDisk : TCListElem
object Download : TCListElem, Pseudo
object FromDisk : TCListElem, Pseudo
companion object {
val fetchGroup get() = listOf(Download, FromDisk)

View file

@ -22,8 +22,9 @@
package com.falsepattern.zigbrains.shared
import com.intellij.openapi.progress.EmptyProgressIndicator
import com.intellij.openapi.progress.ProgressManager
import com.intellij.util.io.Decompressor
import kotlinx.coroutines.runInterruptible
import java.io.IOException
import java.nio.file.Path
import kotlin.io.path.name
@ -51,16 +52,22 @@ enum class Unarchiver {
companion object {
@Throws(IOException::class)
suspend fun unarchive(archivePath: Path, dst: Path, prefix: String? = null) {
runInterruptible {
fun unarchive(archivePath: Path, dst: Path, prefix: String? = null) {
val unarchiver = entries.find { archivePath.name.endsWith(it.extension) }
?: error("Unexpected archive type: $archivePath")
val dec = unarchiver.createDecompressor(archivePath)
val indicator = ProgressManager.getInstance().progressIndicator ?: EmptyProgressIndicator()
indicator.isIndeterminate = true
indicator.text = "Extracting archive"
dec.filter {
indicator.text2 = it
indicator.checkCanceled()
true
}
if (prefix != null) {
dec.removePrefixPath(prefix)
}
dec.extract(dst)
}
}
}
}

View file

@ -63,7 +63,10 @@ suspend inline fun <T> runInterruptibleEDT(context: CoroutineContext, noinline t
}
fun CoroutineScope.launchWithEDT(state: ModalityState, block: suspend CoroutineScope.() -> Unit): Job {
return launch(Dispatchers.EDT + state.asContextElement(), block = block)
return launchWithEDT(state.asContextElement(), block = block)
}
fun CoroutineScope.launchWithEDT(context: CoroutineContext, block: suspend CoroutineScope.() -> Unit): Job {
return launch(Dispatchers.EDT + context, block = block)
}
fun Component.asContextElement(): CoroutineContext {