diff --git a/cidr/src/main/kotlin/com/falsepattern/zigbrains/debugger/toolchain/ZigDebuggerToolchainService.kt b/cidr/src/main/kotlin/com/falsepattern/zigbrains/debugger/toolchain/ZigDebuggerToolchainService.kt index 7feadf32..d8a8837c 100644 --- a/cidr/src/main/kotlin/com/falsepattern/zigbrains/debugger/toolchain/ZigDebuggerToolchainService.kt +++ b/cidr/src/main/kotlin/com/falsepattern/zigbrains/debugger/toolchain/ZigDebuggerToolchainService.kt @@ -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 { - downloadAndUnArchive(baseDir, downloadableBinaries) + withContext(Dispatchers.IO) { + downloadAndUnArchive(baseDir, downloadableBinaries) + } return DownloadResult.Ok(baseDir) } catch (e: IOException) { //TODO logging @@ -206,34 +210,40 @@ class ZigDebuggerToolchainService { @Throws(IOException::class) @RequiresEdt private suspend fun downloadAndUnArchive(baseDir: Path, binariesToDownload: List) { - val service = DownloadableFileService.getInstance() + reportSequentialProgress { reporter -> + val service = DownloadableFileService.getInstance() - val downloadDir = baseDir.toFile() - downloadDir.deleteRecursively() + val downloadDir = baseDir.toFile() + downloadDir.deleteRecursively() - val descriptions = binariesToDownload.map { - service.createFileDescription(it.url, fileName(it.url)) - } - - val downloader = service.createDownloader(descriptions, "Debugger downloading") - val downloadDirectory = downloadPath().toFile() - val downloadResults = withContext(Dispatchers.IO) { - coroutineToIndicator { - downloader.download(downloadDirectory) + val descriptions = binariesToDownload.map { + service.createFileDescription(it.url, fileName(it.url)) } - } - val versions = Properties() - for (result in downloadResults) { - val downloadUrl = result.second.downloadUrl - val binaryToDownload = binariesToDownload.first { it.url == downloadUrl } - val propertyName = binaryToDownload.propertyName - val archiveFile = result.first - Unarchiver.unarchive(archiveFile.toPath(), baseDir, binaryToDownload.prefix) - archiveFile.delete() - versions[propertyName] = binaryToDownload.version - } - saveVersionsFile(baseDir, versions) + val downloader = service.createDownloader(descriptions, "Debugger downloading") + val downloadDirectory = downloadPath().toFile() + val downloadResults = reporter.sizedStep(100) { + coroutineToIndicator { + downloader.download(downloadDirectory) + } + } + val versions = Properties() + for (result in downloadResults) { + val downloadUrl = result.second.downloadUrl + 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? { diff --git a/core/src/main/kotlin/com/falsepattern/zigbrains/project/toolchain/ZigToolchainListService.kt b/core/src/main/kotlin/com/falsepattern/zigbrains/project/toolchain/ZigToolchainListService.kt index 740f551a..1d38ea2d 100644 --- a/core/src/main/kotlin/com/falsepattern/zigbrains/project/toolchain/ZigToolchainListService.kt +++ b/core/src/main/kotlin/com/falsepattern/zigbrains/project/toolchain/ZigToolchainListService.kt @@ -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(State()) { private val changeListeners = ArrayList>() fun setToolchain(uuid: UUID, toolchain: ZigToolchain) { + val str = uuid.toString() + val ref = toolchain.toRef() updateState { val newMap = HashMap() 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() + 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> get() = state.toolchains .asSequence() @@ -109,6 +138,6 @@ class ZigToolchainListService: SerializablePersistentStateComponent(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 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 + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/com/falsepattern/zigbrains/project/toolchain/ui/Downloader.kt b/core/src/main/kotlin/com/falsepattern/zigbrains/project/toolchain/downloader/Downloader.kt similarity index 58% rename from core/src/main/kotlin/com/falsepattern/zigbrains/project/toolchain/ui/Downloader.kt rename to core/src/main/kotlin/com/falsepattern/zigbrains/project/toolchain/downloader/Downloader.kt index f7770eb8..5cf76ace 100644 --- a/core/src/main/kotlin/com/falsepattern/zigbrains/project/toolchain/ui/Downloader.kt +++ b/core/src/main/kotlin/com/falsepattern/zigbrains/project/toolchain/downloader/Downloader.kt @@ -20,14 +20,16 @@ * along with ZigBrains. If not, see . */ -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) { - ZigVersionInfo.downloadVersionList() - } + 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) { - 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 - } + "Installing Zig ${version.version}", + TaskCancellation.cancellable() + ) { + version.downloadAndUnpack(downloadPath) } + return LocalZigToolchain.tryFromPath(downloadPath) } + @RequiresEdt private fun selectToolchain(info: List): Pair? { val dialog = DialogBuilder() val theList = ComboBox(DefaultComboBoxModel(info.toTypedArray())) @@ -148,32 +93,39 @@ 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 + fun onChanged() { + val path = outputPath.text.ifBlank { null }?.toNioPathOrNull() + val state = DirectoryState.determine(path) + if (state.isValid()) { + errorMessageBox.icon = AllIcons.General.Information + dialog.setOkActionEnabled(true) + } else { + errorMessageBox.icon = AllIcons.General.Error + dialog.setOkActionEnabled(false) + } + errorMessageBox.text = when(state) { + DirectoryState.Invalid -> "Invalid path" + DirectoryState.NotAbsolute -> "Must be an absolute path" + DirectoryState.NotDirectory -> "Path is not a directory" + DirectoryState.NotEmpty -> "Directory is not empty" + DirectoryState.CreateNew -> "Directory will be created" + DirectoryState.Ok -> "Directory OK" + } + dialog.window.repaint() + } + outputPath.whenFocusGained { + onChanged() + } outputPath.addDocumentListener(object: DocumentAdapter() { override fun textChanged(e: DocumentEvent) { - val path = outputPath.text.ifBlank { null }?.toNioPathOrNull() - val state = DirectoryState.determine(path) - if (state.isValid()) { - errorMessageBox.icon = AllIcons.General.Information - dialog.setOkActionEnabled(true) - } else { - errorMessageBox.icon = AllIcons.General.Error - dialog.setOkActionEnabled(false) - } - errorMessageBox.text = when(state) { - DirectoryState.Invalid -> "Invalid path" - DirectoryState.NotAbsolute -> "Must be an absolute path" - DirectoryState.NotDirectory -> "Path is not a directory" - DirectoryState.NotEmpty -> "Directory is not empty" - DirectoryState.CreateNew -> "Directory will be created" - DirectoryState.Ok -> "Directory OK" - } - dialog.window.repaint() + onChanged() } }) var archiveSizeCell: Cell<*>? = null @@ -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") - } } \ No newline at end of file diff --git a/core/src/main/kotlin/com/falsepattern/zigbrains/project/toolchain/downloader/LocalSelector.kt b/core/src/main/kotlin/com/falsepattern/zigbrains/project/toolchain/downloader/LocalSelector.kt new file mode 100644 index 00000000..0f74a927 --- /dev/null +++ b/core/src/main/kotlin/com/falsepattern/zigbrains/project/toolchain/downloader/LocalSelector.kt @@ -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 . + */ + +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) + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/com/falsepattern/zigbrains/project/toolchain/downloader/ZigVersionInfo.kt b/core/src/main/kotlin/com/falsepattern/zigbrains/project/toolchain/downloader/ZigVersionInfo.kt index 09e8ba2f..b2ba7f8a 100644 --- a/core/src/main/kotlin/com/falsepattern/zigbrains/project/toolchain/downloader/ZigVersionInfo.kt +++ b/core/src/main/kotlin/com/falsepattern/zigbrains/project/toolchain/downloader/ZigVersionInfo.kt @@ -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,108 +67,145 @@ data class ZigVersionInfo( val src: Tarball?, val dist: Tarball ) { - suspend fun downloadAndUnpack(into: Path): Boolean { - return reportProgress { reporter -> - try { - into.createDirectories() - } catch (e: Exception) { - return@reportProgress false - } - 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 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() } - if (contents.size == 1 && contents[0].isDirectory()) { - val src = contents[0] - Files.newDirectoryStream(src).use { stream -> - stream.forEach { - it.move(into.resolve(src.relativize(it))) - } - } - src.delete() - } - } - return@reportProgress true + @Throws(Exception::class) + suspend fun downloadAndUnpack(into: Path) { + reportProgress { reporter -> + into.createDirectories() + val tarball = downloadTarball(dist, into, reporter) + unpackTarball(tarball, into, reporter) + tarball.delete() + flattenDownloadDir(into, reporter) } } + companion object { @OptIn(ExperimentalSerializationApi::class) suspend fun downloadVersionList(): List { - 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) + 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(it) } + index.delete() + return@withContext info.mapNotNull { (version, data) -> parseVersion(version, data) }.toList() } - if (downloadResults.isEmpty()) - return emptyList() - val index = downloadResults[0].first - val info = index.inputStream().use { Json.decodeFromStream(it) } - index.delete() - return info.mapNotNull { (version, data) -> parseVersion(version, data) }.toList() } + } - private fun parseVersion(versionKey: String, data: JsonElement): ZigVersionInfo? { - if (data !is JsonObject) - return null + @JvmRecord + @Serializable + data class Tarball(val tarball: String, val shasum: String, val size: Int) +} - val versionTag = data["version"]?.asSafely()?.content - - val version = SemVer.parseFromText(versionTag) ?: SemVer.parseFromText(versionKey) - ?: return null - val date = data["date"]?.asSafely()?.content ?: "" - val docs = data["docs"]?.asSafely()?.content ?: "" - val notes = data["notes"]?.asSafely()?.content?: "" - val src = data["src"]?.asSafely()?.let { Json.decodeFromJsonElement(it) } - val dist = data.firstNotNullOfOrNull { (dist, tb) -> getTarballIfCompatible(dist, tb) } - ?: return null - - - return ZigVersionInfo(version, date, docs, notes, src, dist) +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 tarball") + val downloadResults = reporter.sizedStep(100) { + coroutineToIndicator { + downloader.download(into.toFile()) + } } + if (downloadResults.isEmpty()) + throw IllegalStateException("No file downloaded") + return@withContext downloadResults[0].first.toPath() + } +} - private fun getTarballIfCompatible(dist: String, tb: JsonElement): Tarball? { - if (!dist.contains('-')) - return null - val (arch, os) = dist.split('-', limit = 2) - val theArch = when (arch) { - "x86_64" -> CpuArch.X86_64 - "i386" -> CpuArch.X86 - "armv7a" -> CpuArch.ARM32 - "aarch64" -> CpuArch.ARM64 - else -> return null +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 { + indicator.text2 = it.name + it.move(dir.resolve(src.relativize(it))) + } + } + } } - val theOS = when (os) { - "linux" -> OS.Linux - "windows" -> OS.Windows - "macos" -> OS.macOS - "freebsd" -> OS.FreeBSD - else -> return null - } - if (theArch != CpuArch.CURRENT || theOS != OS.CURRENT) { - return null - } - return Json.decodeFromJsonElement(tb) + src.delete() } } } -@JvmRecord -@Serializable -data class Tarball(val tarball: String, val shasum: String, val size: Int) +@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 + + val versionTag = data["version"]?.asSafely()?.content + + val version = SemVer.parseFromText(versionTag) ?: SemVer.parseFromText(versionKey) + ?: return null + val date = data["date"]?.asSafely()?.content ?: "" + val docs = data["docs"]?.asSafely()?.content ?: "" + val notes = data["notes"]?.asSafely()?.content ?: "" + val src = data["src"]?.asSafely()?.let { Json.decodeFromJsonElement(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): ZigVersionInfo.Tarball? { + if (!dist.contains('-')) + return null + val (arch, os) = dist.split('-', limit = 2) + val theArch = when (arch) { + "x86_64" -> CpuArch.X86_64 + "i386" -> CpuArch.X86 + "armv7a" -> CpuArch.ARM32 + "aarch64" -> CpuArch.ARM64 + else -> return null + } + val theOS = when (os) { + "linux" -> OS.Linux + "windows" -> OS.Windows + "macos" -> OS.macOS + "freebsd" -> OS.FreeBSD + else -> return null + } + if (theArch != CpuArch.CURRENT || theOS != OS.CURRENT) { + return null + } + return Json.decodeFromJsonElement(tb) +} private val tempPluginDir get(): File = PathManager.getTempPath().toNioPathOrNull()!!.resolve("zigbrains").toFile() \ No newline at end of file diff --git a/core/src/main/kotlin/com/falsepattern/zigbrains/project/toolchain/local/LocalZigToolchain.kt b/core/src/main/kotlin/com/falsepattern/zigbrains/project/toolchain/local/LocalZigToolchain.kt index f23cb30b..6062afc6 100644 --- a/core/src/main/kotlin/com/falsepattern/zigbrains/project/toolchain/local/LocalZigToolchain.kt +++ b/core/src/main/kotlin/com/falsepattern/zigbrains/project/toolchain/local/LocalZigToolchain.kt @@ -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? { diff --git a/core/src/main/kotlin/com/falsepattern/zigbrains/project/toolchain/ui/ZigToolchainComboBoxHandler.kt b/core/src/main/kotlin/com/falsepattern/zigbrains/project/toolchain/ui/ZigToolchainComboBoxHandler.kt new file mode 100644 index 00000000..9354cf5a --- /dev/null +++ b/core/src/main/kotlin/com/falsepattern/zigbrains/project/toolchain/ui/ZigToolchainComboBoxHandler.kt @@ -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 . + */ + +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) } +} \ No newline at end of file diff --git a/core/src/main/kotlin/com/falsepattern/zigbrains/project/toolchain/ui/ZigToolchainEditor.kt b/core/src/main/kotlin/com/falsepattern/zigbrains/project/toolchain/ui/ZigToolchainEditor.kt index e83c00e1..f7749990 100644 --- a/core/src/main/kotlin/com/falsepattern/zigbrains/project/toolchain/ui/ZigToolchainEditor.kt +++ b/core/src/main/kotlin/com/falsepattern/zigbrains/project/toolchain/ui/ZigToolchainEditor.kt @@ -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 - val list = getModelList() - model.updateContents(list) - if (selected != null && list.contains(selected)) { - model.selectedItem = selected - } else { + 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 + 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) } diff --git a/core/src/main/kotlin/com/falsepattern/zigbrains/project/toolchain/ui/ZigToolchainListEditor.kt b/core/src/main/kotlin/com/falsepattern/zigbrains/project/toolchain/ui/ZigToolchainListEditor.kt index 0d202210..84194464 100644 --- a/core/src/main/kotlin/com/falsepattern/zigbrains/project/toolchain/ui/ZigToolchainListEditor.kt +++ b/core/src/main/kotlin/com/falsepattern/zigbrains/project/toolchain/ui/ZigToolchainListEditor.kt @@ -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>() + + fun addItemSelectedListener(c: Consumer) { + 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 -> { - zigCoroutineScope.launch(myWholePanel.asContextElement()) { - Downloader.downloadToolchain(myWholePanel) + if (elem !is TCListElem.Pseudo) + return + zigCoroutineScope.launch(myWholePanel.asContextElement()) { + 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() 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() { - reloadTree() + override suspend fun toolchainListChanged() { + withEDTContext(myWholePanel.asContextElement()) { + reloadTree() + } } } \ No newline at end of file diff --git a/core/src/main/kotlin/com/falsepattern/zigbrains/project/toolchain/ui/elements.kt b/core/src/main/kotlin/com/falsepattern/zigbrains/project/toolchain/ui/elements.kt index 45045252..1915f91b 100644 --- a/core/src/main/kotlin/com/falsepattern/zigbrains/project/toolchain/ui/elements.kt +++ b/core/src/main/kotlin/com/falsepattern/zigbrains/project/toolchain/ui/elements.kt @@ -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) diff --git a/core/src/main/kotlin/com/falsepattern/zigbrains/shared/Unarchiver.kt b/core/src/main/kotlin/com/falsepattern/zigbrains/shared/Unarchiver.kt index 578567f1..acc89145 100644 --- a/core/src/main/kotlin/com/falsepattern/zigbrains/shared/Unarchiver.kt +++ b/core/src/main/kotlin/com/falsepattern/zigbrains/shared/Unarchiver.kt @@ -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 { - val unarchiver = entries.find { archivePath.name.endsWith(it.extension) } - ?: error("Unexpected archive type: $archivePath") - val dec = unarchiver.createDecompressor(archivePath) - if (prefix != null) { - dec.removePrefixPath(prefix) - } - dec.extract(dst) + 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) } } } \ No newline at end of file diff --git a/core/src/main/kotlin/com/falsepattern/zigbrains/shared/coroutine/CoroutinesUtil.kt b/core/src/main/kotlin/com/falsepattern/zigbrains/shared/coroutine/CoroutinesUtil.kt index 8f5d76b4..2b61b2e8 100644 --- a/core/src/main/kotlin/com/falsepattern/zigbrains/shared/coroutine/CoroutinesUtil.kt +++ b/core/src/main/kotlin/com/falsepattern/zigbrains/shared/coroutine/CoroutinesUtil.kt @@ -63,7 +63,10 @@ suspend inline fun 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 {