almost feature complete - ZLS needs settings GUI

This commit is contained in:
FalsePattern 2025-04-11 02:30:17 +02:00
parent dcede7eb43
commit 281ce0ed4e
Signed by: falsepattern
GPG key ID: E930CDEC50C50E23
40 changed files with 1131 additions and 505 deletions

View file

@ -26,6 +26,7 @@ import com.falsepattern.zigbrains.ZigBrainsBundle
import com.falsepattern.zigbrains.direnv.DirenvService import com.falsepattern.zigbrains.direnv.DirenvService
import com.falsepattern.zigbrains.direnv.DirenvState import com.falsepattern.zigbrains.direnv.DirenvState
import com.falsepattern.zigbrains.project.settings.ZigProjectConfigurationProvider import com.falsepattern.zigbrains.project.settings.ZigProjectConfigurationProvider
import com.falsepattern.zigbrains.project.settings.ZigProjectConfigurationProvider.Companion.PROJECT_KEY
import com.falsepattern.zigbrains.shared.SubConfigurable import com.falsepattern.zigbrains.shared.SubConfigurable
import com.intellij.openapi.components.service import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project import com.intellij.openapi.project.Project
@ -83,8 +84,8 @@ abstract class DirenvEditor<T>(private val sharedState: ZigProjectConfigurationP
} }
class Provider: ZigProjectConfigurationProvider { class Provider: ZigProjectConfigurationProvider {
override fun create(project: Project?, sharedState: ZigProjectConfigurationProvider.IUserDataBridge): SubConfigurable<Project>? { override fun create(sharedState: ZigProjectConfigurationProvider.IUserDataBridge): SubConfigurable<Project>? {
if (project?.isDefault != false) { if (sharedState.getUserData(PROJECT_KEY)?.isDefault != false) {
return null return null
} }
DirenvService.setStateFor(sharedState, DirenvState.Auto) DirenvService.setStateFor(sharedState, DirenvState.Auto)

View file

@ -30,13 +30,15 @@ import com.intellij.openapi.util.UserDataHolder
import com.intellij.openapi.util.UserDataHolderBase import com.intellij.openapi.util.UserDataHolderBase
interface ZigProjectConfigurationProvider { interface ZigProjectConfigurationProvider {
fun create(project: Project?, sharedState: IUserDataBridge): SubConfigurable<Project>? fun create(sharedState: IUserDataBridge): SubConfigurable<Project>?
val index: Int val index: Int
companion object { companion object {
private val EXTENSION_POINT_NAME = ExtensionPointName.create<ZigProjectConfigurationProvider>("com.falsepattern.zigbrains.projectConfigProvider") private val EXTENSION_POINT_NAME = ExtensionPointName.create<ZigProjectConfigurationProvider>("com.falsepattern.zigbrains.projectConfigProvider")
val PROJECT_KEY: Key<Project> = Key.create("Project")
fun createPanels(project: Project?): List<SubConfigurable<Project>> { fun createPanels(project: Project?): List<SubConfigurable<Project>> {
val sharedState = UserDataBridge() val sharedState = UserDataBridge()
return EXTENSION_POINT_NAME.extensionList.sortedBy { it.index }.mapNotNull { it.create(project, sharedState) } sharedState.putUserData(PROJECT_KEY, project)
return EXTENSION_POINT_NAME.extensionList.sortedBy { it.index }.mapNotNull { it.create(sharedState) }
} }
} }
@ -51,7 +53,7 @@ interface ZigProjectConfigurationProvider {
class UserDataBridge: UserDataHolderBase(), IUserDataBridge { class UserDataBridge: UserDataHolderBase(), IUserDataBridge {
private val listeners = ArrayList<UserDataListener>() private val listeners = ArrayList<UserDataListener>()
override fun <T : Any?> putUserData(key: Key<T?>, value: T?) { override fun <T : Any?> putUserData(key: Key<T>, value: T?) {
super.putUserData(key, value) super.putUserData(key, value)
synchronized(listeners) { synchronized(listeners) {
listeners.forEach { listener -> listeners.forEach { listener ->

View file

@ -24,6 +24,7 @@ package com.falsepattern.zigbrains.project.toolchain
import com.falsepattern.zigbrains.project.stdlib.ZigSyntheticLibrary import com.falsepattern.zigbrains.project.stdlib.ZigSyntheticLibrary
import com.falsepattern.zigbrains.project.toolchain.base.ZigToolchain import com.falsepattern.zigbrains.project.toolchain.base.ZigToolchain
import com.falsepattern.zigbrains.shared.asUUID
import com.falsepattern.zigbrains.shared.zigCoroutineScope import com.falsepattern.zigbrains.shared.zigCoroutineScope
import com.intellij.openapi.application.EDT import com.intellij.openapi.application.EDT
import com.intellij.openapi.components.SerializablePersistentStateComponent import com.intellij.openapi.components.SerializablePersistentStateComponent
@ -44,7 +45,7 @@ import java.util.UUID
) )
class ZigToolchainService(private val project: Project): SerializablePersistentStateComponent<ZigToolchainService.State>(State()) { class ZigToolchainService(private val project: Project): SerializablePersistentStateComponent<ZigToolchainService.State>(State()) {
var toolchainUUID: UUID? var toolchainUUID: UUID?
get() = state.toolchain.ifBlank { null }?.let { UUID.fromString(it) }?.takeIf { get() = state.toolchain.ifBlank { null }?.asUUID()?.takeIf {
if (it in zigToolchainList) { if (it in zigToolchainList) {
true true
} else { } else {

View file

@ -37,12 +37,12 @@ import java.nio.file.Path
interface ZigToolchain: NamedObject<ZigToolchain> { interface ZigToolchain: NamedObject<ZigToolchain> {
val zig: ZigCompilerTool get() = ZigCompilerTool(this) val zig: ZigCompilerTool get() = ZigCompilerTool(this)
fun <T> getUserData(key: Key<T>): T? val extraData: Map<String, String>
/** /**
* Returned type must be the same class * Returned type must be the same class
*/ */
fun <T> withUserData(key: Key<T>, value: T?): ZigToolchain fun withExtraData(map: Map<String, String>): ZigToolchain
fun workingDirectory(project: Project? = null): Path? fun workingDirectory(project: Project? = null): Path?
@ -55,7 +55,18 @@ interface ZigToolchain: NamedObject<ZigToolchain> {
@Attribute @Attribute
val marker: String? = null, val marker: String? = null,
@JvmField @JvmField
@MapAnnotation(surroundWithTag = false)
val data: Map<String, String>? = null, val data: Map<String, String>? = null,
@JvmField
val extraData: Map<String, String>? = null,
) )
} }
fun <T: ZigToolchain> T.withExtraData(key: String, value: String?): T {
val newMap = HashMap<String, String>()
newMap.putAll(extraData.filter { (theKey, _) -> theKey != key})
if (value != null) {
newMap[key] = value
}
@Suppress("UNCHECKED_CAST")
return withExtraData(newMap) as T
}

View file

@ -22,23 +22,34 @@
package com.falsepattern.zigbrains.project.toolchain.base package com.falsepattern.zigbrains.project.toolchain.base
import com.falsepattern.zigbrains.project.settings.ZigProjectConfigurationProvider
import com.falsepattern.zigbrains.project.toolchain.ui.ImmutableElementPanel import com.falsepattern.zigbrains.project.toolchain.ui.ImmutableElementPanel
import com.falsepattern.zigbrains.project.toolchain.zigToolchainList import com.falsepattern.zigbrains.project.toolchain.zigToolchainList
import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.NamedConfigurable import com.intellij.openapi.ui.NamedConfigurable
import com.intellij.openapi.util.Key
import com.intellij.openapi.util.NlsContexts import com.intellij.openapi.util.NlsContexts
import com.intellij.ui.dsl.builder.panel import com.intellij.ui.dsl.builder.panel
import java.util.UUID import java.util.UUID
import java.util.function.Supplier
import javax.swing.JComponent import javax.swing.JComponent
abstract class ZigToolchainConfigurable<T: ZigToolchain>( abstract class ZigToolchainConfigurable<T: ZigToolchain>(
val uuid: UUID, val uuid: UUID,
tc: T tc: T,
val data: ZigProjectConfigurationProvider.IUserDataBridge?
): NamedConfigurable<UUID>() { ): NamedConfigurable<UUID>() {
var toolchain: T = tc var toolchain: T = tc
set(value) { set(value) {
zigToolchainList[uuid] = value zigToolchainList[uuid] = value
field = value field = value
} }
init {
data?.putUserData(TOOLCHAIN_KEY, Supplier{
myViews.fold(toolchain) { tc, view -> view.apply(tc) ?: tc }
})
}
private var myViews: List<ImmutableElementPanel<T>> = emptyList() private var myViews: List<ImmutableElementPanel<T>> = emptyList()
abstract fun createPanel(): ImmutableElementPanel<T> abstract fun createPanel(): ImmutableElementPanel<T>
@ -48,7 +59,7 @@ abstract class ZigToolchainConfigurable<T: ZigToolchain>(
if (views.isEmpty()) { if (views.isEmpty()) {
views = ArrayList<ImmutableElementPanel<T>>() views = ArrayList<ImmutableElementPanel<T>>()
views.add(createPanel()) views.add(createPanel())
views.addAll(createZigToolchainExtensionPanels()) views.addAll(createZigToolchainExtensionPanels(data))
views.forEach { it.reset(toolchain) } views.forEach { it.reset(toolchain) }
myViews = views myViews = views
} }
@ -86,4 +97,8 @@ abstract class ZigToolchainConfigurable<T: ZigToolchain>(
myViews = emptyList() myViews = emptyList()
super.disposeUIResources() super.disposeUIResources()
} }
companion object {
val TOOLCHAIN_KEY: Key<Supplier<ZigToolchain>> = Key.create("TOOLCHAIN")
}
} }

View file

@ -22,18 +22,20 @@
package com.falsepattern.zigbrains.project.toolchain.base package com.falsepattern.zigbrains.project.toolchain.base
import com.falsepattern.zigbrains.project.settings.ZigProjectConfigurationProvider
import com.falsepattern.zigbrains.project.toolchain.ui.ImmutableElementPanel import com.falsepattern.zigbrains.project.toolchain.ui.ImmutableElementPanel
import com.intellij.openapi.extensions.ExtensionPointName import com.intellij.openapi.extensions.ExtensionPointName
import com.intellij.openapi.util.UserDataHolder
private val EXTENSION_POINT_NAME = ExtensionPointName.create<ZigToolchainExtensionsProvider>("com.falsepattern.zigbrains.toolchainExtensionsProvider") private val EXTENSION_POINT_NAME = ExtensionPointName.create<ZigToolchainExtensionsProvider>("com.falsepattern.zigbrains.toolchainExtensionsProvider")
internal interface ZigToolchainExtensionsProvider { interface ZigToolchainExtensionsProvider {
fun <T : ZigToolchain> createExtensionPanel(): ImmutableElementPanel<T>? fun <T : ZigToolchain> createExtensionPanel(sharedState: ZigProjectConfigurationProvider.IUserDataBridge?): ImmutableElementPanel<T>?
val index: Int val index: Int
} }
fun <T: ZigToolchain> createZigToolchainExtensionPanels(): List<ImmutableElementPanel<T>> { fun <T: ZigToolchain> createZigToolchainExtensionPanels(sharedState: ZigProjectConfigurationProvider.IUserDataBridge?): List<ImmutableElementPanel<T>> {
return EXTENSION_POINT_NAME.extensionList.sortedBy{ it.index }.mapNotNull { return EXTENSION_POINT_NAME.extensionList.sortedBy{ it.index }.mapNotNull {
it.createExtensionPanel() it.createExtensionPanel(sharedState)
} }
} }

View file

@ -22,6 +22,7 @@
package com.falsepattern.zigbrains.project.toolchain.base package com.falsepattern.zigbrains.project.toolchain.base
import com.falsepattern.zigbrains.project.settings.ZigProjectConfigurationProvider
import com.falsepattern.zigbrains.project.toolchain.zigToolchainList import com.falsepattern.zigbrains.project.toolchain.zigToolchainList
import com.intellij.openapi.extensions.ExtensionPointName import com.intellij.openapi.extensions.ExtensionPointName
import com.intellij.openapi.project.Project import com.intellij.openapi.project.Project
@ -46,7 +47,7 @@ internal interface ZigToolchainProvider {
fun deserialize(data: Map<String, String>): ZigToolchain? fun deserialize(data: Map<String, String>): ZigToolchain?
fun serialize(toolchain: ZigToolchain): Map<String, String> fun serialize(toolchain: ZigToolchain): Map<String, String>
fun matchesSuggestion(toolchain: ZigToolchain, suggestion: ZigToolchain): Boolean fun matchesSuggestion(toolchain: ZigToolchain, suggestion: ZigToolchain): Boolean
fun createConfigurable(uuid: UUID, toolchain: ZigToolchain): ZigToolchainConfigurable<*> fun createConfigurable(uuid: UUID, toolchain: ZigToolchain, data: ZigProjectConfigurationProvider.IUserDataBridge?): ZigToolchainConfigurable<*>
suspend fun suggestToolchains(project: Project?, data: UserDataHolder): Flow<ZigToolchain> suspend fun suggestToolchains(project: Project?, data: UserDataHolder): Flow<ZigToolchain>
fun render(toolchain: ZigToolchain, component: SimpleColoredComponent, isSuggestion: Boolean, isSelected: Boolean) fun render(toolchain: ZigToolchain, component: SimpleColoredComponent, isSuggestion: Boolean, isSelected: Boolean)
} }
@ -55,17 +56,17 @@ fun ZigToolchain.Ref.resolve(): ZigToolchain? {
val marker = this.marker ?: return null val marker = this.marker ?: return null
val data = this.data ?: return null val data = this.data ?: return null
val provider = EXTENSION_POINT_NAME.extensionList.find { it.serialMarker == marker } ?: return null val provider = EXTENSION_POINT_NAME.extensionList.find { it.serialMarker == marker } ?: return null
return provider.deserialize(data) return provider.deserialize(data)?.let { tc -> this.extraData?.let { extraData -> tc.withExtraData(extraData) }}
} }
fun ZigToolchain.toRef(): ZigToolchain.Ref { fun ZigToolchain.toRef(): ZigToolchain.Ref {
val provider = EXTENSION_POINT_NAME.extensionList.find { it.isCompatible(this) } ?: throw IllegalStateException() val provider = EXTENSION_POINT_NAME.extensionList.find { it.isCompatible(this) } ?: throw IllegalStateException()
return ZigToolchain.Ref(provider.serialMarker, provider.serialize(this)) return ZigToolchain.Ref(provider.serialMarker, provider.serialize(this), this.extraData)
} }
fun ZigToolchain.createNamedConfigurable(uuid: UUID): ZigToolchainConfigurable<*> { fun ZigToolchain.createNamedConfigurable(uuid: UUID, data: ZigProjectConfigurationProvider.IUserDataBridge?): ZigToolchainConfigurable<*> {
val provider = EXTENSION_POINT_NAME.extensionList.find { it.isCompatible(this) } ?: throw IllegalStateException() val provider = EXTENSION_POINT_NAME.extensionList.find { it.isCompatible(this) } ?: throw IllegalStateException()
return provider.createConfigurable(uuid, this) return provider.createConfigurable(uuid, this, data)
} }
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)

View file

@ -1,163 +0,0 @@
/*
* This file is part of ZigBrains.
*
* Copyright (C) 2023-2025 FalsePattern
* All Rights Reserved
*
* The above copyright notice and this permission notice shall be included
* in all copies or substantial portions of the Software.
*
* ZigBrains is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, only version 3 of the License.
*
* ZigBrains is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with ZigBrains. If not, see <https://www.gnu.org/licenses/>.
*/
package com.falsepattern.zigbrains.project.toolchain.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.project.toolchain.zigToolchainList
import com.falsepattern.zigbrains.shared.coroutine.asContextElement
import com.falsepattern.zigbrains.shared.coroutine.launchWithEDT
import com.falsepattern.zigbrains.shared.coroutine.runInterruptibleEDT
import com.falsepattern.zigbrains.shared.coroutine.withEDTContext
import com.falsepattern.zigbrains.shared.withUniqueName
import com.falsepattern.zigbrains.shared.zigCoroutineScope
import com.intellij.icons.AllIcons
import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.fileChooser.FileChooser
import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory
import com.intellij.openapi.ui.DialogBuilder
import com.intellij.openapi.util.Disposer
import com.intellij.platform.ide.progress.ModalTaskOwner
import com.intellij.platform.ide.progress.TaskCancellation
import com.intellij.platform.ide.progress.withModalProgress
import com.intellij.ui.DocumentAdapter
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBTextField
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 java.util.concurrent.atomic.AtomicBoolean
import javax.swing.event.DocumentEvent
import kotlin.io.path.pathString
object LocalSelector {
suspend fun browseFromDisk(component: Component, preSelected: LocalZigToolchain? = null): ZigToolchain? {
return withEDTContext(component.asContextElement()) {
doBrowseFromDisk(component, preSelected)
}
}
@RequiresEdt
private suspend fun doBrowseFromDisk(component: Component, preSelected: LocalZigToolchain?): ZigToolchain? {
val dialog = DialogBuilder()
val name = JBTextField().also { it.columns = 25 }
val path = textFieldWithBrowseButton(
null,
FileChooserDescriptorFactory.createSingleFolderDescriptor()
.withTitle(ZigBrainsBundle.message("settings.toolchain.local-selector.chooser.title"))
)
Disposer.register(dialog, path)
lateinit var errorMessageBox: JBLabel
fun verify(tc: LocalZigToolchain?) {
var tc = tc
if (tc == null) {
errorMessageBox.icon = AllIcons.General.Error
errorMessageBox.text = ZigBrainsBundle.message("settings.toolchain.local-selector.state.invalid")
dialog.setOkActionEnabled(false)
} else {
val existingToolchain = zigToolchainList
.mapNotNull { it.second as? LocalZigToolchain }
.firstOrNull { it.location == tc.location }
if (existingToolchain != null) {
errorMessageBox.icon = AllIcons.General.Warning
errorMessageBox.text = existingToolchain.name?.let { ZigBrainsBundle.message("settings.toolchain.local-selector.state.already-exists-named", it) }
?: ZigBrainsBundle.message("settings.toolchain.local-selector.state.already-exists-unnamed")
dialog.setOkActionEnabled(true)
} else {
errorMessageBox.icon = AllIcons.General.Information
errorMessageBox.text = ZigBrainsBundle.message("settings.toolchain.local-selector.state.ok")
dialog.setOkActionEnabled(true)
}
}
if (tc != null) {
tc = zigToolchainList.withUniqueName(tc)
}
val prevNameDefault = name.emptyText.text.trim() == name.text.trim() || name.text.isBlank()
name.emptyText.text = tc?.name ?: ""
if (prevNameDefault) {
name.text = name.emptyText.text
}
}
suspend fun verify(path: String) {
val tc = runCatching { withModalProgress(ModalTaskOwner.component(component), "Resolving toolchain", TaskCancellation.cancellable()) {
LocalZigToolchain.tryFromPathString(path)
} }.getOrNull()
verify(tc)
}
val active = AtomicBoolean(false)
path.addDocumentListener(object: DocumentAdapter() {
override fun textChanged(e: DocumentEvent) {
if (!active.get())
return
zigCoroutineScope.launchWithEDT(ModalityState.current()) {
verify(path.text)
}
}
})
val center = panel {
row(ZigBrainsBundle.message("settings.toolchain.local-selector.name.label")) {
cell(name).resizableColumn().align(AlignX.FILL)
}
row(ZigBrainsBundle.message("settings.toolchain.local-selector.path.label")) {
cell(path).resizableColumn().align(AlignX.FILL)
}
row {
errorMessageBox = JBLabel()
cell(errorMessageBox)
}
}
dialog.centerPanel(center)
dialog.setTitle(ZigBrainsBundle.message("settings.toolchain.local-selector.title"))
dialog.addCancelAction()
dialog.addOkAction().also { it.setText(ZigBrainsBundle.message("settings.toolchain.local-selector.ok-action")) }
if (preSelected == null) {
val chosenFile = FileChooser.chooseFile(
FileChooserDescriptorFactory.createSingleFolderDescriptor()
.withTitle(ZigBrainsBundle.message("settings.toolchain.local-selector.chooser.title")),
null,
null
)
if (chosenFile != null) {
verify(chosenFile.path)
path.text = chosenFile.path
}
} else {
verify(preSelected)
path.text = preSelected.location.pathString
}
active.set(true)
if (!dialog.showAndGet()) {
active.set(false)
return null
}
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

@ -0,0 +1,50 @@
/*
* 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.ZigBrainsBundle
import com.falsepattern.zigbrains.project.toolchain.local.LocalZigToolchain
import com.falsepattern.zigbrains.project.toolchain.local.getSuggestedLocalToolchainPath
import com.falsepattern.zigbrains.shared.downloader.Downloader
import com.falsepattern.zigbrains.shared.downloader.LocalSelector
import com.intellij.openapi.util.NlsContexts
import java.awt.Component
import java.nio.file.Path
class LocalToolchainDownloader(component: Component) : Downloader<LocalZigToolchain, ZigVersionInfo>(component) {
override val windowTitle: String get() = ZigBrainsBundle.message("settings.toolchain.downloader.title")
override val versionInfoFetchTitle: @NlsContexts.ProgressTitle String get() = ZigBrainsBundle.message("settings.toolchain.downloader.progress.fetch")
override fun downloadProgressTitle(version: ZigVersionInfo): @NlsContexts.ProgressTitle String {
return ZigBrainsBundle.message("settings.toolchain.downloader.progress.install", version.version.rawVersion)
}
override fun localSelector(): LocalSelector<LocalZigToolchain> {
return LocalToolchainSelector(component)
}
override suspend fun downloadVersionList(): List<ZigVersionInfo> {
return ZigVersionInfo.downloadVersionList()
}
override fun getSuggestedPath(): Path? {
return getSuggestedLocalToolchainPath()
}
}

View file

@ -0,0 +1,107 @@
/*
* 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.ZigBrainsBundle
import com.falsepattern.zigbrains.project.toolchain.base.ZigToolchain
import com.falsepattern.zigbrains.project.toolchain.local.LocalZigToolchain
import com.falsepattern.zigbrains.project.toolchain.zigToolchainList
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.downloader.LocalSelector
import com.falsepattern.zigbrains.shared.withUniqueName
import com.falsepattern.zigbrains.shared.zigCoroutineScope
import com.intellij.icons.AllIcons
import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.fileChooser.FileChooser
import com.intellij.openapi.fileChooser.FileChooserDescriptor
import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory
import com.intellij.openapi.ui.DialogBuilder
import com.intellij.openapi.util.Disposer
import com.intellij.platform.ide.progress.ModalTaskOwner
import com.intellij.platform.ide.progress.TaskCancellation
import com.intellij.platform.ide.progress.withModalProgress
import com.intellij.ui.DocumentAdapter
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBTextField
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 java.nio.file.Path
import java.util.concurrent.atomic.AtomicBoolean
import javax.swing.event.DocumentEvent
import kotlin.io.path.pathString
class LocalToolchainSelector(component: Component): LocalSelector<LocalZigToolchain>(component) {
override val windowTitle: String
get() = ZigBrainsBundle.message("settings.toolchain.local-selector.title")
override val descriptor: FileChooserDescriptor
get() = FileChooserDescriptorFactory.createSingleFolderDescriptor()
.withTitle(ZigBrainsBundle.message("settings.toolchain.local-selector.chooser.title"))
override suspend fun verify(path: Path): VerifyResult {
var tc = resolve(path, null)
var result: VerifyResult
if (tc == null) {
result = VerifyResult(
null,
false,
AllIcons.General.Error,
ZigBrainsBundle.message("settings.toolchain.local-selector.state.invalid"),
)
} else {
val existingToolchain = zigToolchainList
.mapNotNull { it.second as? LocalZigToolchain }
.firstOrNull { it.location == tc.location }
if (existingToolchain != null) {
result = VerifyResult(
null,
true,
AllIcons.General.Warning,
existingToolchain.name?.let { ZigBrainsBundle.message("settings.toolchain.local-selector.state.already-exists-named", it) }
?: ZigBrainsBundle.message("settings.toolchain.local-selector.state.already-exists-unnamed")
)
} else {
result = VerifyResult(
null,
true,
AllIcons.General.Information,
ZigBrainsBundle.message("settings.toolchain.local-selector.state.ok")
)
}
}
if (tc != null) {
tc = zigToolchainList.withUniqueName(tc)
}
return result.copy(name = tc?.name)
}
override suspend fun resolve(path: Path, name: String?): LocalZigToolchain? {
return runCatching { withModalProgress(ModalTaskOwner.component(component), "Resolving toolchain", TaskCancellation.cancellable()) {
LocalZigToolchain.tryFromPath(path)?.let { it.withName(name ?: it.name) }
} }.getOrNull()
}
}

View file

@ -24,6 +24,13 @@ package com.falsepattern.zigbrains.project.toolchain.downloader
import com.falsepattern.zigbrains.ZigBrainsBundle import com.falsepattern.zigbrains.ZigBrainsBundle
import com.falsepattern.zigbrains.shared.Unarchiver import com.falsepattern.zigbrains.shared.Unarchiver
import com.falsepattern.zigbrains.shared.downloader.VersionInfo
import com.falsepattern.zigbrains.shared.downloader.VersionInfo.Tarball
import com.falsepattern.zigbrains.shared.downloader.downloadTarball
import com.falsepattern.zigbrains.shared.downloader.flattenDownloadDir
import com.falsepattern.zigbrains.shared.downloader.getTarballIfCompatible
import com.falsepattern.zigbrains.shared.downloader.tempPluginDir
import com.falsepattern.zigbrains.shared.downloader.unpackTarball
import com.intellij.openapi.application.PathManager import com.intellij.openapi.application.PathManager
import com.intellij.openapi.progress.EmptyProgressIndicator import com.intellij.openapi.progress.EmptyProgressIndicator
import com.intellij.openapi.progress.ProgressManager import com.intellij.openapi.progress.ProgressManager
@ -59,24 +66,13 @@ import kotlin.io.path.name
@JvmRecord @JvmRecord
data class ZigVersionInfo( data class ZigVersionInfo(
val version: SemVer, override val version: SemVer,
val date: String, override val date: String,
val docs: String, val docs: String,
val notes: String, val notes: String,
val src: Tarball?, val src: Tarball?,
val dist: Tarball override val dist: Tarball
) { ): VersionInfo {
@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 { companion object {
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class)
suspend fun downloadVersionList(): List<ZigVersionInfo> { suspend fun downloadVersionList(): List<ZigVersionInfo> {
@ -97,71 +93,6 @@ data class ZigVersionInfo(
} }
} }
} }
@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), ZigBrainsBundle.message("settings.toolchain.downloader.service.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 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 = ZigBrainsBundle.message("settings.toolchain.downloader.progress.flatten")
Files.newDirectoryStream(src).use { stream ->
stream.forEach {
indicator.text2 = it.name
it.move(dir.resolve(src.relativize(it)))
}
}
}
}
src.delete()
}
}
}
@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? { private fun parseVersion(versionKey: String, data: JsonElement): ZigVersionInfo? {
@ -175,36 +106,9 @@ private fun parseVersion(versionKey: String, data: JsonElement): ZigVersionInfo?
val date = data["date"]?.asSafely<JsonPrimitive>()?.content ?: "" val date = data["date"]?.asSafely<JsonPrimitive>()?.content ?: ""
val docs = data["docs"]?.asSafely<JsonPrimitive>()?.content ?: "" val docs = data["docs"]?.asSafely<JsonPrimitive>()?.content ?: ""
val notes = data["notes"]?.asSafely<JsonPrimitive>()?.content ?: "" val notes = data["notes"]?.asSafely<JsonPrimitive>()?.content ?: ""
val src = data["src"]?.asSafely<JsonObject>()?.let { Json.decodeFromJsonElement<ZigVersionInfo.Tarball>(it) } val src = data["src"]?.asSafely<JsonObject>()?.let { Json.decodeFromJsonElement<Tarball>(it) }
val dist = data.firstNotNullOfOrNull { (dist, tb) -> getTarballIfCompatible(dist, tb) } val dist = data.firstNotNullOfOrNull { (dist, tb) -> getTarballIfCompatible(dist, tb) }
?: return null ?: return null
return ZigVersionInfo(version, date, docs, notes, src, dist) 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<ZigVersionInfo.Tarball>(tb)
}
private val tempPluginDir get(): File = PathManager.getTempPath().toNioPathOrNull()!!.resolve("zigbrains").toFile()

View file

@ -36,15 +36,7 @@ import com.intellij.util.keyFMap.KeyFMap
import java.nio.file.Path import java.nio.file.Path
@JvmRecord @JvmRecord
data class LocalZigToolchain(val location: Path, val std: Path? = null, override val name: String? = null, private val userData: KeyFMap = KeyFMap.EMPTY_MAP): ZigToolchain { data class LocalZigToolchain(val location: Path, val std: Path? = null, override val name: String? = null, override val extraData: Map<String, String> = emptyMap()): ZigToolchain {
override fun <T> getUserData(key: Key<T>): T? {
return userData.get(key)
}
override fun <T> withUserData(key: Key<T>, value: T?): LocalZigToolchain {
return copy(userData = if (value == null) userData.minus(key) else userData.plus(key, value))
}
override fun workingDirectory(project: Project?): Path? { override fun workingDirectory(project: Project?): Path? {
return project?.guessProjectDir()?.toNioPathOrNull() return project?.guessProjectDir()?.toNioPathOrNull()
} }
@ -61,6 +53,10 @@ data class LocalZigToolchain(val location: Path, val std: Path? = null, override
return location.resolve(exeName) return location.resolve(exeName)
} }
override fun withExtraData(map: Map<String, String>): ZigToolchain {
return this.copy(extraData = map)
}
override fun withName(newName: String?): LocalZigToolchain { override fun withName(newName: String?): LocalZigToolchain {
return this.copy(name = newName) return this.copy(name = newName)
} }
@ -76,10 +72,6 @@ data class LocalZigToolchain(val location: Path, val std: Path? = null, override
} }
} }
suspend fun tryFromPathString(pathStr: String?): LocalZigToolchain? {
return pathStr?.ifBlank { null }?.toNioPathOrNull()?.let { tryFromPath(it) }
}
suspend fun tryFromPath(path: Path): LocalZigToolchain? { suspend fun tryFromPath(path: Path): LocalZigToolchain? {
var tc = LocalZigToolchain(path) var tc = LocalZigToolchain(path)
if (!tc.zig.fileValid()) { if (!tc.zig.fileValid()) {

View file

@ -22,13 +22,15 @@
package com.falsepattern.zigbrains.project.toolchain.local package com.falsepattern.zigbrains.project.toolchain.local
import com.falsepattern.zigbrains.project.settings.ZigProjectConfigurationProvider
import com.falsepattern.zigbrains.project.toolchain.base.ZigToolchainConfigurable import com.falsepattern.zigbrains.project.toolchain.base.ZigToolchainConfigurable
import java.util.UUID import java.util.UUID
class LocalZigToolchainConfigurable( class LocalZigToolchainConfigurable(
uuid: UUID, uuid: UUID,
toolchain: LocalZigToolchain toolchain: LocalZigToolchain,
): ZigToolchainConfigurable<LocalZigToolchain>(uuid, toolchain) { data: ZigProjectConfigurationProvider.IUserDataBridge?
): ZigToolchainConfigurable<LocalZigToolchain>(uuid, toolchain, data) {
override fun createPanel() = LocalZigToolchainPanel() override fun createPanel() = LocalZigToolchainPanel()
override fun setDisplayName(name: String?) { override fun setDisplayName(name: String?) {

View file

@ -24,6 +24,7 @@ package com.falsepattern.zigbrains.project.toolchain.local
import com.falsepattern.zigbrains.direnv.DirenvService import com.falsepattern.zigbrains.direnv.DirenvService
import com.falsepattern.zigbrains.direnv.Env import com.falsepattern.zigbrains.direnv.Env
import com.falsepattern.zigbrains.project.settings.ZigProjectConfigurationProvider
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
@ -84,10 +85,11 @@ class LocalZigToolchainProvider: ZigToolchainProvider {
override fun createConfigurable( override fun createConfigurable(
uuid: UUID, uuid: UUID,
toolchain: ZigToolchain toolchain: ZigToolchain,
data: ZigProjectConfigurationProvider.IUserDataBridge?
): ZigToolchainConfigurable<*> { ): ZigToolchainConfigurable<*> {
toolchain as LocalZigToolchain toolchain as LocalZigToolchain
return LocalZigToolchainConfigurable(uuid, toolchain) return LocalZigToolchainConfigurable(uuid, toolchain, data)
} }
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)

View file

@ -23,8 +23,8 @@
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.project.toolchain.downloader.Downloader import com.falsepattern.zigbrains.project.toolchain.downloader.LocalToolchainDownloader
import com.falsepattern.zigbrains.project.toolchain.downloader.LocalSelector import com.falsepattern.zigbrains.project.toolchain.downloader.LocalToolchainSelector
import com.falsepattern.zigbrains.project.toolchain.zigToolchainList import com.falsepattern.zigbrains.project.toolchain.zigToolchainList
import com.falsepattern.zigbrains.shared.ui.ListElem import com.falsepattern.zigbrains.shared.ui.ListElem
import com.falsepattern.zigbrains.shared.withUniqueName import com.falsepattern.zigbrains.shared.withUniqueName
@ -36,7 +36,7 @@ internal object ZigToolchainComboBoxHandler {
@RequiresBackgroundThread @RequiresBackgroundThread
suspend fun onItemSelected(context: Component, elem: ListElem.Pseudo<ZigToolchain>): UUID? = when(elem) { suspend fun onItemSelected(context: Component, elem: ListElem.Pseudo<ZigToolchain>): UUID? = when(elem) {
is ListElem.One.Suggested -> zigToolchainList.withUniqueName(elem.instance) is ListElem.One.Suggested -> zigToolchainList.withUniqueName(elem.instance)
is ListElem.Download -> Downloader.downloadToolchain(context) is ListElem.Download -> LocalToolchainDownloader(context).download()
is ListElem.FromDisk -> LocalSelector.browseFromDisk(context) is ListElem.FromDisk -> LocalToolchainSelector(context).browse()
}?.let { zigToolchainList.registerNew(it) } }?.let { zigToolchainList.registerNew(it) }
} }

View file

@ -23,6 +23,8 @@
package com.falsepattern.zigbrains.project.toolchain.ui package com.falsepattern.zigbrains.project.toolchain.ui
import com.falsepattern.zigbrains.ZigBrainsBundle import com.falsepattern.zigbrains.ZigBrainsBundle
import com.falsepattern.zigbrains.project.settings.ZigProjectConfigurationProvider
import com.falsepattern.zigbrains.project.settings.ZigProjectConfigurationProvider.Companion.PROJECT_KEY
import com.falsepattern.zigbrains.project.toolchain.base.ZigToolchain import com.falsepattern.zigbrains.project.toolchain.base.ZigToolchain
import com.falsepattern.zigbrains.project.toolchain.base.createNamedConfigurable import com.falsepattern.zigbrains.project.toolchain.base.createNamedConfigurable
import com.falsepattern.zigbrains.project.toolchain.base.suggestZigToolchains import com.falsepattern.zigbrains.project.toolchain.base.suggestZigToolchains
@ -60,13 +62,6 @@ sealed interface ZigToolchainDriver: UUIDComboBoxDriver<ZigToolchain> {
return ZigToolchainComboBoxHandler.onItemSelected(context, elem) return ZigToolchainComboBoxHandler.onItemSelected(context, elem)
} }
override fun createNamedConfigurable(
uuid: UUID,
elem: ZigToolchain
): NamedConfigurable<UUID> {
return elem.createNamedConfigurable(uuid)
}
object ForList: ZigToolchainDriver { object ForList: ZigToolchainDriver {
override fun constructModelList(): List<ListElemIn<ZigToolchain>> { override fun constructModelList(): List<ListElemIn<ZigToolchain>> {
val modelList = ArrayList<ListElemIn<ZigToolchain>>() val modelList = ArrayList<ListElemIn<ZigToolchain>>()
@ -75,9 +70,16 @@ sealed interface ZigToolchainDriver: UUIDComboBoxDriver<ZigToolchain> {
modelList.add(suggestZigToolchains().asPending()) modelList.add(suggestZigToolchains().asPending())
return modelList return modelList
} }
override fun createNamedConfigurable(
uuid: UUID,
elem: ZigToolchain
): NamedConfigurable<UUID> {
return elem.createNamedConfigurable(uuid, ZigProjectConfigurationProvider.UserDataBridge())
}
} }
class ForSelector(val project: Project?, val data: UserDataHolder): ZigToolchainDriver { class ForSelector(val data: ZigProjectConfigurationProvider.IUserDataBridge): ZigToolchainDriver {
override fun constructModelList(): List<ListElemIn<ZigToolchain>> { override fun constructModelList(): List<ListElemIn<ZigToolchain>> {
val modelList = ArrayList<ListElemIn<ZigToolchain>>() val modelList = ArrayList<ListElemIn<ZigToolchain>>()
modelList.add(ListElem.None()) modelList.add(ListElem.None())
@ -85,8 +87,15 @@ sealed interface ZigToolchainDriver: UUIDComboBoxDriver<ZigToolchain> {
modelList.add(Separator("", true)) modelList.add(Separator("", true))
modelList.addAll(ListElem.fetchGroup()) modelList.addAll(ListElem.fetchGroup())
modelList.add(Separator(ZigBrainsBundle.message("settings.toolchain.model.detected.separator"), true)) modelList.add(Separator(ZigBrainsBundle.message("settings.toolchain.model.detected.separator"), true))
modelList.add(suggestZigToolchains(project, data).asPending()) modelList.add(suggestZigToolchains(data.getUserData(PROJECT_KEY), data).asPending())
return modelList return modelList
} }
override fun createNamedConfigurable(
uuid: UUID,
elem: ZigToolchain
): NamedConfigurable<UUID> {
return elem.createNamedConfigurable(uuid, data)
}
} }
} }

View file

@ -24,6 +24,7 @@ package com.falsepattern.zigbrains.project.toolchain.ui
import com.falsepattern.zigbrains.ZigBrainsBundle import com.falsepattern.zigbrains.ZigBrainsBundle
import com.falsepattern.zigbrains.project.settings.ZigProjectConfigurationProvider import com.falsepattern.zigbrains.project.settings.ZigProjectConfigurationProvider
import com.falsepattern.zigbrains.project.settings.ZigProjectConfigurationProvider.Companion.PROJECT_KEY
import com.falsepattern.zigbrains.project.toolchain.ZigToolchainService import com.falsepattern.zigbrains.project.toolchain.ZigToolchainService
import com.falsepattern.zigbrains.project.toolchain.base.ZigToolchain import com.falsepattern.zigbrains.project.toolchain.base.ZigToolchain
import com.falsepattern.zigbrains.shared.SubConfigurable import com.falsepattern.zigbrains.shared.SubConfigurable
@ -35,9 +36,8 @@ import com.intellij.openapi.util.Key
import com.intellij.ui.dsl.builder.Panel import com.intellij.ui.dsl.builder.Panel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class ZigToolchainEditor(private var project: Project?, class ZigToolchainEditor(private val sharedState: ZigProjectConfigurationProvider.IUserDataBridge):
private val sharedState: ZigProjectConfigurationProvider.IUserDataBridge): UUIDMapSelector<ZigToolchain>(ZigToolchainDriver.ForSelector(sharedState)),
UUIDMapSelector<ZigToolchain>(ZigToolchainDriver.ForSelector(project, sharedState)),
SubConfigurable<Project>, SubConfigurable<Project>,
ZigProjectConfigurationProvider.UserDataListener ZigProjectConfigurationProvider.UserDataListener
{ {
@ -52,7 +52,7 @@ class ZigToolchainEditor(private var project: Project?,
override fun attach(p: Panel): Unit = with(p) { override fun attach(p: Panel): Unit = with(p) {
row(ZigBrainsBundle.message( row(ZigBrainsBundle.message(
if (project?.isDefault == true) if (sharedState.getUserData(PROJECT_KEY)?.isDefault == true)
"settings.toolchain.editor.toolchain-default.label" "settings.toolchain.editor.toolchain-default.label"
else else
"settings.toolchain.editor.toolchain.label") "settings.toolchain.editor.toolchain.label")
@ -81,8 +81,8 @@ class ZigToolchainEditor(private var project: Project?,
override val newProjectBeforeInitSelector get() = true override val newProjectBeforeInitSelector get() = true
class Provider: ZigProjectConfigurationProvider { class Provider: ZigProjectConfigurationProvider {
override fun create(project: Project?, sharedState: ZigProjectConfigurationProvider.IUserDataBridge): SubConfigurable<Project>? { override fun create(sharedState: ZigProjectConfigurationProvider.IUserDataBridge): SubConfigurable<Project>? {
return ZigToolchainEditor(project, sharedState).also { it.reset(project) } return ZigToolchainEditor(sharedState).also { it.reset(sharedState.getUserData(PROJECT_KEY)) }
} }
override val index: Int get() = 0 override val index: Int get() = 0

View file

@ -0,0 +1,28 @@
/*
* 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.shared
import java.util.UUID
fun String.asUUID(): UUID? = UUID.fromString(this)
fun UUID.asString(): String = toString()

View file

@ -58,10 +58,10 @@ abstract class UUIDMapSerializable<T, S: Any>(init: S): SerializablePersistentSt
updateState { updateState {
val newMap = HashMap<String, T>() val newMap = HashMap<String, T>()
newMap.putAll(getStorage(it)) newMap.putAll(getStorage(it))
var uuidStr = uuid.toString() var uuidStr = uuid.asString()
while (newMap.containsKey(uuidStr)) { while (newMap.containsKey(uuidStr)) {
uuid = UUID.randomUUID() uuid = UUID.randomUUID()
uuidStr = uuid.toString() uuidStr = uuid.asString()
} }
newMap[uuidStr] = value newMap[uuidStr] = value
updateStorage(it, newMap) updateStorage(it, newMap)
@ -71,7 +71,7 @@ abstract class UUIDMapSerializable<T, S: Any>(init: S): SerializablePersistentSt
} }
protected fun setStateUUID(uuid: UUID, value: T) { protected fun setStateUUID(uuid: UUID, value: T) {
val str = uuid.toString() val str = uuid.asString()
updateState { updateState {
val newMap = HashMap<String, T>() val newMap = HashMap<String, T>()
newMap.putAll(getStorage(it)) newMap.putAll(getStorage(it))
@ -82,15 +82,15 @@ abstract class UUIDMapSerializable<T, S: Any>(init: S): SerializablePersistentSt
} }
protected fun getStateUUID(uuid: UUID): T? { protected fun getStateUUID(uuid: UUID): T? {
return getStorage(state)[uuid.toString()] return getStorage(state)[uuid.asString()]
} }
protected fun hasStateUUID(uuid: UUID): Boolean { protected fun hasStateUUID(uuid: UUID): Boolean {
return getStorage(state).containsKey(uuid.toString()) return getStorage(state).containsKey(uuid.asString())
} }
protected fun removeStateUUID(uuid: UUID) { protected fun removeStateUUID(uuid: UUID) {
val str = uuid.toString() val str = uuid.asString()
updateState { updateState {
updateStorage(state, getStorage(state).filter { it.key != str }) updateStorage(state, getStorage(state).filter { it.key != str })
} }
@ -143,7 +143,7 @@ abstract class UUIDMapSerializable<T, S: Any>(init: S): SerializablePersistentSt
return getStorage(state) return getStorage(state)
.asSequence() .asSequence()
.mapNotNull { .mapNotNull {
val uuid = UUID.fromString(it.key) ?: return@mapNotNull null val uuid = it.key.asUUID() ?: return@mapNotNull null
val tc = deserialize(it.value) ?: return@mapNotNull null val tc = deserialize(it.value) ?: return@mapNotNull null
uuid to tc uuid to tc
}.iterator() }.iterator()

View file

@ -1,8 +1,29 @@
package com.falsepattern.zigbrains.project.toolchain.downloader /*
* 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.shared.downloader
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import kotlin.contracts.ExperimentalContracts
import kotlin.io.path.exists import kotlin.io.path.exists
import kotlin.io.path.isDirectory import kotlin.io.path.isDirectory

View file

@ -20,20 +20,17 @@
* along with ZigBrains. If not, see <https://www.gnu.org/licenses/>. * along with ZigBrains. If not, see <https://www.gnu.org/licenses/>.
*/ */
package com.falsepattern.zigbrains.project.toolchain.downloader package com.falsepattern.zigbrains.shared.downloader
import com.falsepattern.zigbrains.ZigBrainsBundle import com.falsepattern.zigbrains.ZigBrainsBundle
import com.falsepattern.zigbrains.project.toolchain.base.ZigToolchain
import com.falsepattern.zigbrains.project.toolchain.local.LocalZigToolchain
import com.falsepattern.zigbrains.project.toolchain.local.getSuggestedLocalToolchainPath
import com.falsepattern.zigbrains.shared.coroutine.asContextElement import com.falsepattern.zigbrains.shared.coroutine.asContextElement
import com.falsepattern.zigbrains.shared.coroutine.runInterruptibleEDT import com.falsepattern.zigbrains.shared.coroutine.runInterruptibleEDT
import com.intellij.icons.AllIcons import com.intellij.icons.AllIcons
import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory
import com.intellij.openapi.observable.util.whenFocusGained import com.intellij.openapi.observable.util.whenFocusGained
import com.intellij.openapi.ui.ComboBox import com.intellij.openapi.ui.ComboBox
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.openapi.util.NlsContexts
import com.intellij.openapi.util.io.toNioPathOrNull import com.intellij.openapi.util.io.toNioPathOrNull
import com.intellij.platform.ide.progress.ModalTaskOwner import com.intellij.platform.ide.progress.ModalTaskOwner
import com.intellij.platform.ide.progress.TaskCancellation import com.intellij.platform.ide.progress.TaskCancellation
@ -45,45 +42,53 @@ import com.intellij.ui.components.textFieldWithBrowseButton
import com.intellij.ui.dsl.builder.AlignX import com.intellij.ui.dsl.builder.AlignX
import com.intellij.ui.dsl.builder.Cell import com.intellij.ui.dsl.builder.Cell
import com.intellij.ui.dsl.builder.panel import com.intellij.ui.dsl.builder.panel
import com.intellij.util.asSafely
import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.concurrency.annotations.RequiresEdt
import java.awt.Component import java.awt.Component
import java.nio.file.Path import java.nio.file.Path
import java.util.Vector
import javax.swing.DefaultComboBoxModel import javax.swing.DefaultComboBoxModel
import javax.swing.JList import javax.swing.JList
import javax.swing.event.DocumentEvent import javax.swing.event.DocumentEvent
import kotlin.io.path.pathString import kotlin.io.path.pathString
object Downloader { abstract class Downloader<T, V: VersionInfo>(val component: Component) {
suspend fun downloadToolchain(component: Component): ZigToolchain? { suspend fun download(): T? {
val info = withModalProgress( val info = withModalProgress(
ModalTaskOwner.component(component), ModalTaskOwner.component(component),
ZigBrainsBundle.message("settings.toolchain.downloader.progress.fetch"), versionInfoFetchTitle,
TaskCancellation.cancellable() TaskCancellation.cancellable()
) { ) {
ZigVersionInfo.downloadVersionList() downloadVersionList()
} }
val selector = localSelector()
val (downloadPath, version) = runInterruptibleEDT(component.asContextElement()) { val (downloadPath, version) = runInterruptibleEDT(component.asContextElement()) {
selectToolchain(info) selectVersion(info, selector)
} ?: return null } ?: return null
withModalProgress( withModalProgress(
ModalTaskOwner.component(component), ModalTaskOwner.component(component),
ZigBrainsBundle.message("settings.toolchain.downloader.progress.install", version.version.rawVersion), downloadProgressTitle(version),
TaskCancellation.cancellable() TaskCancellation.cancellable()
) { ) {
version.downloadAndUnpack(downloadPath) version.downloadAndUnpack(downloadPath)
} }
return LocalZigToolchain.tryFromPath(downloadPath)?.let { LocalSelector.browseFromDisk(component, it) } return selector.browse(downloadPath)
} }
protected abstract val windowTitle: String
protected abstract val versionInfoFetchTitle: @NlsContexts.ProgressTitle String
protected abstract fun downloadProgressTitle(version: V): @NlsContexts.ProgressTitle String
protected abstract fun localSelector(): LocalSelector<T>
protected abstract suspend fun downloadVersionList(): List<V>
protected abstract fun getSuggestedPath(): Path?
@RequiresEdt @RequiresEdt
private fun selectToolchain(info: List<ZigVersionInfo>): Pair<Path, ZigVersionInfo>? { private fun selectVersion(info: List<V>, selector: LocalSelector<T>): Pair<Path, V>? {
val dialog = DialogBuilder() val dialog = DialogBuilder()
val theList = ComboBox(DefaultComboBoxModel(info.toTypedArray())) val theList = ComboBox(DefaultComboBoxModel(Vector(info)))
theList.renderer = object: ColoredListCellRenderer<ZigVersionInfo>() { theList.renderer = object: ColoredListCellRenderer<V>() {
override fun customizeCellRenderer( override fun customizeCellRenderer(
list: JList<out ZigVersionInfo>, list: JList<out V>,
value: ZigVersionInfo?, value: V?,
index: Int, index: Int,
selected: Boolean, selected: Boolean,
hasFocus: Boolean hasFocus: Boolean
@ -91,18 +96,14 @@ object Downloader {
value?.let { append(it.version.rawVersion) } value?.let { append(it.version.rawVersion) }
} }
} }
val outputPath = textFieldWithBrowseButton( val outputPath = textFieldWithBrowseButton(null, selector.descriptor)
null,
FileChooserDescriptorFactory.createSingleFolderDescriptor()
.withTitle(ZigBrainsBundle.message("settings.toolchain.downloader.chooser.title"))
)
Disposer.register(dialog, outputPath) Disposer.register(dialog, outputPath)
outputPath.textField.columns = 50 outputPath.textField.columns = 50
lateinit var errorMessageBox: JBLabel lateinit var errorMessageBox: JBLabel
fun onChanged() { fun onChanged() {
val path = outputPath.text.ifBlank { null }?.toNioPathOrNull() val path = outputPath.text.ifBlank { null }?.toNioPathOrNull()
val state = DirectoryState.determine(path) val state = DirectoryState.Companion.determine(path)
if (state.isValid()) { if (state.isValid()) {
errorMessageBox.icon = AllIcons.General.Information errorMessageBox.icon = AllIcons.General.Information
dialog.setOkActionEnabled(true) dialog.setOkActionEnabled(true)
@ -111,12 +112,12 @@ object Downloader {
dialog.setOkActionEnabled(false) dialog.setOkActionEnabled(false)
} }
errorMessageBox.text = ZigBrainsBundle.message(when(state) { errorMessageBox.text = ZigBrainsBundle.message(when(state) {
DirectoryState.Invalid -> "settings.toolchain.downloader.state.invalid" DirectoryState.Invalid -> "settings.shared.downloader.state.invalid"
DirectoryState.NotAbsolute -> "settings.toolchain.downloader.state.not-absolute" DirectoryState.NotAbsolute -> "settings.shared.downloader.state.not-absolute"
DirectoryState.NotDirectory -> "settings.toolchain.downloader.state.not-directory" DirectoryState.NotDirectory -> "settings.shared.downloader.state.not-directory"
DirectoryState.NotEmpty -> "settings.toolchain.downloader.state.not-empty" DirectoryState.NotEmpty -> "settings.shared.downloader.state.not-empty"
DirectoryState.CreateNew -> "settings.toolchain.downloader.state.create-new" DirectoryState.CreateNew -> "settings.shared.downloader.state.create-new"
DirectoryState.Ok -> "settings.toolchain.downloader.state.ok" DirectoryState.Ok -> "settings.shared.downloader.state.ok"
}) })
dialog.window.repaint() dialog.window.repaint()
} }
@ -129,20 +130,21 @@ object Downloader {
} }
}) })
var archiveSizeCell: Cell<*>? = null var archiveSizeCell: Cell<*>? = null
fun detect(item: ZigVersionInfo) { fun detect(item: V) {
outputPath.text = getSuggestedLocalToolchainPath()?.resolve(item.version.rawVersion)?.pathString ?: "" outputPath.text = getSuggestedPath()?.resolve(item.version.rawVersion)?.pathString ?: ""
val size = item.dist.size val size = item.dist.size
val sizeMb = size / (1024f * 1024f) val sizeMb = size / (1024f * 1024f)
archiveSizeCell?.comment?.text = ZigBrainsBundle.message("settings.toolchain.downloader.archive-size.text", "%.2fMB".format(sizeMb)) archiveSizeCell?.comment?.text = ZigBrainsBundle.message("settings.shared.downloader.archive-size.text", "%.2fMB".format(sizeMb))
} }
theList.addItemListener { theList.addItemListener {
detect(it.item as ZigVersionInfo) @Suppress("UNCHECKED_CAST")
detect(it.item as V)
} }
val center = panel { val center = panel {
row(ZigBrainsBundle.message("settings.toolchain.downloader.version.label")) { row(ZigBrainsBundle.message("settings.shared.downloader.version.label")) {
cell(theList).resizableColumn().align(AlignX.FILL) cell(theList).resizableColumn().align(AlignX.FILL)
} }
row(ZigBrainsBundle.message("settings.toolchain.downloader.location.label")) { row(ZigBrainsBundle.message("settings.shared.downloader.location.label")) {
cell(outputPath).resizableColumn().align(AlignX.FILL).apply { archiveSizeCell = comment("") } cell(outputPath).resizableColumn().align(AlignX.FILL).apply { archiveSizeCell = comment("") }
} }
row { row {
@ -152,19 +154,18 @@ object Downloader {
} }
detect(info[0]) detect(info[0])
dialog.centerPanel(center) dialog.centerPanel(center)
dialog.setTitle(ZigBrainsBundle.message("settings.toolchain.downloader.title")) dialog.setTitle(windowTitle)
dialog.addCancelAction() dialog.addCancelAction()
dialog.addOkAction().also { it.setText(ZigBrainsBundle.message("settings.toolchain.downloader.ok-action")) } dialog.addOkAction().also { it.setText(ZigBrainsBundle.message("settings.shared.downloader.ok-action")) }
if (!dialog.showAndGet()) { if (!dialog.showAndGet()) {
return null return null
} }
val path = outputPath.text.ifBlank { null }?.toNioPathOrNull() val path = outputPath.text.ifBlank { null }?.toNioPathOrNull()
?: return null ?: return null
if (!DirectoryState.determine(path).isValid()) { if (!DirectoryState.Companion.determine(path).isValid()) {
return null return null
} }
val version = theList.selectedItem?.asSafely<ZigVersionInfo>() val version = theList.item ?: return null
?: return null
return path to version return path to version
} }

View file

@ -0,0 +1,138 @@
/*
* 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.shared.downloader
import com.falsepattern.zigbrains.ZigBrainsBundle
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.icons.AllIcons
import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.fileChooser.FileChooser
import com.intellij.openapi.fileChooser.FileChooserDescriptor
import com.intellij.openapi.ui.DialogBuilder
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.JBTextField
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 java.nio.file.Path
import java.util.concurrent.atomic.AtomicBoolean
import javax.swing.Icon
import javax.swing.event.DocumentEvent
import kotlin.io.path.pathString
abstract class LocalSelector<T>(val component: Component) {
suspend fun browse(preSelected: Path? = null): T? {
return withEDTContext(component.asContextElement()) {
doBrowseFromDisk(preSelected)
}
}
abstract val windowTitle: String
abstract val descriptor: FileChooserDescriptor
protected abstract suspend fun verify(path: Path): VerifyResult
protected abstract suspend fun resolve(path: Path, name: String?): T?
@RequiresEdt
private suspend fun doBrowseFromDisk(preSelected: Path?): T? {
val dialog = DialogBuilder()
val name = JBTextField().also { it.columns = 25 }
val path = textFieldWithBrowseButton(null, descriptor)
Disposer.register(dialog, path)
lateinit var errorMessageBox: JBLabel
suspend fun verifyAndUpdate(path: Path?) {
val result = path?.let { verify(it) } ?: VerifyResult(
"",
false,
AllIcons.General.Error,
ZigBrainsBundle.message("settings.shared.local-selector.state.invalid")
)
val prevNameDefault = name.emptyText.text.trim() == name.text.trim() || name.text.isBlank()
name.emptyText.text = result.name ?: ""
if (prevNameDefault) {
name.text = name.emptyText.text
}
errorMessageBox.icon = result.errorIcon
errorMessageBox.text = result.errorText
dialog.setOkActionEnabled(result.allowed)
}
val active = AtomicBoolean(false)
path.addDocumentListener(object: DocumentAdapter() {
override fun textChanged(e: DocumentEvent) {
if (!active.get())
return
zigCoroutineScope.launchWithEDT(ModalityState.current()) {
verifyAndUpdate(path.text.ifBlank { null }?.toNioPathOrNull())
}
}
})
val center = panel {
row(ZigBrainsBundle.message("settings.shared.local-selector.name.label")) {
cell(name).resizableColumn().align(AlignX.FILL)
}
row(ZigBrainsBundle.message("settings.shared.local-selector.path.label")) {
cell(path).resizableColumn().align(AlignX.FILL)
}
row {
errorMessageBox = JBLabel()
cell(errorMessageBox)
}
}
dialog.centerPanel(center)
dialog.setTitle(windowTitle)
dialog.addCancelAction()
dialog.addOkAction().also { it.setText(ZigBrainsBundle.message("settings.shared.local-selector.ok-action")) }
if (preSelected == null) {
val chosenFile = FileChooser.chooseFile(descriptor, null, null)
if (chosenFile != null) {
verifyAndUpdate(chosenFile.toNioPath())
path.text = chosenFile.path
}
} else {
verifyAndUpdate(preSelected)
path.text = preSelected.pathString
}
active.set(true)
if (!dialog.showAndGet()) {
active.set(false)
return null
}
active.set(false)
return path.text.ifBlank { null }?.toNioPathOrNull()?.let { resolve(it, name.text.ifBlank { null }) }
}
@JvmRecord
data class VerifyResult(
val name: String?,
val allowed: Boolean,
val errorIcon: Icon,
val errorText: String,
)
}

View file

@ -0,0 +1,164 @@
/*
* 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.shared.downloader
import com.falsepattern.zigbrains.ZigBrainsBundle
import com.falsepattern.zigbrains.project.toolchain.downloader.ZigVersionInfo
import com.falsepattern.zigbrains.shared.Unarchiver
import com.falsepattern.zigbrains.shared.downloader.VersionInfo.Tarball
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.ProgressReporter
import com.intellij.platform.util.progress.reportProgress
import com.intellij.util.download.DownloadableFileService
import com.intellij.util.io.createDirectories
import com.intellij.util.io.delete
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.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.decodeFromJsonElement
import java.io.File
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.deleteRecursively
import kotlin.io.path.isDirectory
import kotlin.io.path.name
interface VersionInfo {
val version: SemVer
val date: String
val dist: Tarball
@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)
}
}
@JvmRecord
@Serializable
data class Tarball(val tarball: String, val shasum: String, val size: Int)
}
suspend fun downloadTarball(dist: 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), ZigBrainsBundle.message("settings.toolchain.downloader.service.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()
}
}
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 = ZigBrainsBundle.message("settings.toolchain.downloader.progress.flatten")
Files.newDirectoryStream(src).use { stream ->
stream.forEach {
indicator.text2 = it.name
it.move(dir.resolve(src.relativize(it)))
}
}
}
}
src.delete()
}
}
}
@OptIn(ExperimentalPathApi::class)
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
}
}
}
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", "x86" -> CpuArch.X86
"armv7a" -> CpuArch.ARM32
"aarch64" -> CpuArch.ARM64
else -> return null
}
val theOS = when (os) {
"linux" -> OS.Linux
"windows" -> OS.Windows
"macos" -> OS.macOS
"freebsd" -> OS.FreeBSD
else -> return null
}
if (theArch != CpuArch.CURRENT || theOS != OS.CURRENT) {
return null
}
return Json.decodeFromJsonElement<Tarball>(tb)
}
val tempPluginDir get(): File = PathManager.getTempPath().toNioPathOrNull()!!.resolve("zigbrains").toFile()

View file

@ -28,9 +28,11 @@ import com.falsepattern.zigbrains.shared.StorageChangeListener
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.launchWithEDT
import com.falsepattern.zigbrains.shared.coroutine.withEDTContext import com.falsepattern.zigbrains.shared.coroutine.withEDTContext
import com.falsepattern.zigbrains.shared.ui.ListElem
import com.falsepattern.zigbrains.shared.zigCoroutineScope import com.falsepattern.zigbrains.shared.zigCoroutineScope
import com.intellij.openapi.Disposable import com.intellij.openapi.Disposable
import com.intellij.openapi.application.EDT import com.intellij.openapi.application.EDT
import com.intellij.openapi.application.runInEdt
import com.intellij.openapi.observable.util.whenListChanged import com.intellij.openapi.observable.util.whenListChanged
import com.intellij.openapi.options.ShowSettingsUtil import com.intellij.openapi.options.ShowSettingsUtil
import com.intellij.openapi.ui.DialogWrapper import com.intellij.openapi.ui.DialogWrapper
@ -57,6 +59,9 @@ abstract class UUIDMapSelector<T>(val driver: UUIDComboBoxDriver<T>): Disposable
comboBox.addItemListener(::itemStateChanged) comboBox.addItemListener(::itemStateChanged)
driver.theMap.addChangeListener(changeListener) driver.theMap.addChangeListener(changeListener)
model.whenListChanged { model.whenListChanged {
zigCoroutineScope.launchWithEDT(comboBox.asContextElement()) {
tryReloadSelection()
}
if (comboBox.isPopupVisible) { if (comboBox.isPopupVisible) {
comboBox.isPopupVisible = false comboBox.isPopupVisible = false
comboBox.isPopupVisible = true comboBox.isPopupVisible = true
@ -67,11 +72,12 @@ abstract class UUIDMapSelector<T>(val driver: UUIDComboBoxDriver<T>): Disposable
protected var selectedUUID: UUID? protected var selectedUUID: UUID?
get() = comboBox.selectedUUID get() = comboBox.selectedUUID
set(value) { set(value) {
comboBox.selectedUUID = value runInEdt {
refreshButtonState(value) applyUUIDNowOrOnReload(value)
}
} }
private fun refreshButtonState(item: Any?) { private fun refreshButtonState(item: ListElem<*>) {
editButton?.isEnabled = item is ListElem.One.Actual<*> editButton?.isEnabled = item is ListElem.One.Actual<*>
editButton?.repaint() editButton?.repaint()
} }
@ -81,6 +87,8 @@ abstract class UUIDMapSelector<T>(val driver: UUIDComboBoxDriver<T>): Disposable
return return
} }
val item = event.item val item = event.item
if (item !is ListElem<*>)
return
refreshButtonState(item) refreshButtonState(item)
if (item !is ListElem.Pseudo<*>) if (item !is ListElem.Pseudo<*>)
return return
@ -95,35 +103,45 @@ abstract class UUIDMapSelector<T>(val driver: UUIDComboBoxDriver<T>): Disposable
} }
} }
@RequiresEdt
private fun tryReloadSelection() {
val list = model.toList()
val onReload = selectOnNextReload
selectOnNextReload = null
if (onReload != null) {
val element = list.firstOrNull { when(it) {
is ListElem.One.Actual<*> -> it.uuid == onReload
else -> false
} }
if (element == null) {
selectOnNextReload = onReload
} else {
model.selectedItem = element
return
}
}
val selected = model.selected
if (selected != null && list.contains(selected)) {
model.selectedItem = selected
return
}
if (selected is ListElem.One.Actual<*>) {
val uuid = selected.uuid
val element = list.firstOrNull { when(it) {
is ListElem.One.Actual -> it.uuid == uuid
else -> false
} }
model.selectedItem = element
return
}
model.selectedItem = ListElem.None<Any>()
}
protected suspend fun listChanged() { protected suspend fun listChanged() {
withContext(Dispatchers.EDT + comboBox.asContextElement()) { withContext(Dispatchers.EDT + comboBox.asContextElement()) {
val list = driver.constructModelList() val list = driver.constructModelList()
model.updateContents(list) model.updateContents(list)
val onReload = selectOnNextReload tryReloadSelection()
selectOnNextReload = null
if (onReload != null) {
val element = list.firstOrNull { when(it) {
is ListElem.One.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 ListElem.One.Actual<*>) {
val uuid = selected.uuid
val element = list.firstOrNull { when(it) {
is ListElem.One.Actual -> it.uuid == uuid
else -> false
} }
model.selectedItem = element
return@withContext
}
model.selectedItem = ListElem.None<Any>()
} }
} }
@ -141,7 +159,6 @@ abstract class UUIDMapSelector<T>(val driver: UUIDComboBoxDriver<T>): Disposable
} }
}.component.let { }.component.let {
editButton = it editButton = it
refreshButtonState(comboBox.selectedItem)
} }
} }

View file

@ -67,7 +67,7 @@ sealed interface ListElem<T> : ListElemIn<T> {
data class Pending<T>(val elems: Flow<ListElem<T>>): ListElem<T> data class Pending<T>(val elems: Flow<ListElem<T>>): ListElem<T>
companion object { companion object {
private val fetchGroup = listOf<ListElem<Any>>(Download(), FromDisk()) private val fetchGroup: List<ListElem<Any>> = listOf(Download(), FromDisk())
fun <T> fetchGroup() = fetchGroup as List<ListElem<T>> fun <T> fetchGroup() = fetchGroup as List<ListElem<T>>
} }
} }
@ -79,4 +79,8 @@ fun <T> Pair<UUID, T>.asActual() = ListElem.One.Actual(first, second)
fun <T> T.asSuggested() = ListElem.One.Suggested(this) fun <T> T.asSuggested() = ListElem.One.Suggested(this)
fun <T> Flow<T>.asPending() = ListElem.Pending(map { it.asSuggested() }) @JvmName("listElemFlowAsPending")
fun <T> Flow<ListElem<T>>.asPending() = ListElem.Pending(this)
fun <T> Flow<T>.asPending() = map { it.asSuggested() }.asPending()

View file

@ -112,6 +112,20 @@ build.tool.window.status.timeout=zig build -l timed out after {0} seconds.
zig=Zig zig=Zig
settings.shared.list.add-action.name=Add New settings.shared.list.add-action.name=Add New
settings.shared.list.empty=Select an entry to view or edit its details here settings.shared.list.empty=Select an entry to view or edit its details here
settings.shared.downloader.version.label=Version:
settings.shared.downloader.location.label=Location:
settings.shared.downloader.ok-action=Download
settings.shared.downloader.state.invalid=Invalid path
settings.shared.downloader.state.not-absolute=Must be an absolute path
settings.shared.downloader.state.not-directory=Path is not a directory
settings.shared.downloader.state.not-empty=Directory is not empty
settings.shared.downloader.state.create-new=Directory will be created
settings.shared.downloader.state.ok=Directory OK
settings.shared.downloader.archive-size.text=Archive size: {0}
settings.shared.local-selector.name.label=Name:
settings.shared.local-selector.path.label=Path:
settings.shared.local-selector.ok-action=Add
settings.shared.local-selector.state.invalid=Invalid path
settings.project.display-name=Zig settings.project.display-name=Zig
settings.toolchain.base.name.label=Name settings.toolchain.base.name.label=Name
settings.toolchain.local.path.label=Toolchain location settings.toolchain.local.path.label=Toolchain location
@ -127,27 +141,14 @@ settings.toolchain.model.from-disk.text=Add Zig from disk\u2026
settings.toolchain.model.download.text=Download Zig\u2026 settings.toolchain.model.download.text=Download Zig\u2026
settings.toolchain.list.title=Toolchains settings.toolchain.list.title=Toolchains
settings.toolchain.downloader.title=Install Zig settings.toolchain.downloader.title=Install Zig
settings.toolchain.downloader.version.label=Version:
settings.toolchain.downloader.location.label=Location:
settings.toolchain.downloader.ok-action=Download
settings.toolchain.downloader.progress.fetch=Fetching zig version information settings.toolchain.downloader.progress.fetch=Fetching zig version information
settings.toolchain.downloader.progress.install=Installing Zig {0} settings.toolchain.downloader.progress.install=Installing Zig {0}
settings.toolchain.downloader.progress.flatten=Flattening unpacked archive settings.toolchain.downloader.progress.flatten=Flattening unpacked archive
settings.toolchain.downloader.chooser.title=Zig Install Directory settings.toolchain.downloader.chooser.title=Zig Install Directory
settings.toolchain.downloader.state.invalid=Invalid path
settings.toolchain.downloader.state.not-absolute=Must be an absolute path
settings.toolchain.downloader.state.not-directory=Path is not a directory
settings.toolchain.downloader.state.not-empty=Directory is not empty
settings.toolchain.downloader.state.create-new=Directory will be created
settings.toolchain.downloader.state.ok=Directory OK
settings.toolchain.downloader.archive-size.text=Archive size: {0}
settings.toolchain.downloader.service.index=Zig version information settings.toolchain.downloader.service.index=Zig version information
settings.toolchain.downloader.service.tarball=Zig archive settings.toolchain.downloader.service.tarball=Zig archive
settings.toolchain.local-selector.title=Select Zig From Disk settings.toolchain.local-selector.title=Select Zig From Disk
settings.toolchain.local-selector.name.label=Name: settings.toolchain.local-selector.chooser.title=Zig Installation Directory
settings.toolchain.local-selector.path.label=Path:
settings.toolchain.local-selector.ok-action=Add
settings.toolchain.local-selector.chooser.title=Existing Zig Install Directory
settings.toolchain.local-selector.state.invalid=Invalid toolchain path settings.toolchain.local-selector.state.invalid=Invalid toolchain path
settings.toolchain.local-selector.state.already-exists-unnamed=Toolchain already exists settings.toolchain.local-selector.state.already-exists-unnamed=Toolchain already exists
settings.toolchain.local-selector.state.already-exists-named=Toolchain already exists as "{0}" settings.toolchain.local-selector.state.already-exists-named=Toolchain already exists as "{0}"

View file

@ -22,6 +22,7 @@
package com.falsepattern.zigbrains.lsp package com.falsepattern.zigbrains.lsp
import com.falsepattern.zigbrains.lsp.zls.zls
import com.falsepattern.zigbrains.shared.zigCoroutineScope import com.falsepattern.zigbrains.shared.zigCoroutineScope
import com.intellij.openapi.project.Project import com.intellij.openapi.project.Project
import com.intellij.openapi.startup.ProjectActivity import com.intellij.openapi.startup.ProjectActivity
@ -33,7 +34,13 @@ class ZLSStartup: ProjectActivity {
override suspend fun execute(project: Project) { override suspend fun execute(project: Project) {
project.zigCoroutineScope.launch { project.zigCoroutineScope.launch {
var currentState = project.zlsRunning() var currentState = project.zlsRunning()
var currentZLS = project.zls
while (!project.isDisposed) { while (!project.isDisposed) {
val zls = project.zls
if (currentZLS != zls) {
startLSP(project, true)
}
currentZLS = zls
val running = project.zlsRunning() val running = project.zlsRunning()
if (currentState != running) { if (currentState != running) {
EditorNotifications.getInstance(project).updateAllNotifications() EditorNotifications.getInstance(project).updateAllNotifications()

View file

@ -23,7 +23,8 @@
package com.falsepattern.zigbrains.lsp package com.falsepattern.zigbrains.lsp
import com.falsepattern.zigbrains.lsp.config.ZLSConfigProviderBase import com.falsepattern.zigbrains.lsp.config.ZLSConfigProviderBase
import com.falsepattern.zigbrains.lsp.zls.ZLSService import com.falsepattern.zigbrains.lsp.zls.zls
import com.falsepattern.zigbrains.project.toolchain.ZigToolchainService
import com.intellij.execution.configurations.GeneralCommandLine import com.intellij.execution.configurations.GeneralCommandLine
import com.intellij.notification.Notification import com.intellij.notification.Notification
import com.intellij.notification.NotificationType import com.intellij.notification.NotificationType
@ -52,8 +53,7 @@ class ZLSStreamConnectionProvider private constructor(private val project: Proje
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class)
suspend fun getCommand(project: Project): List<String>? { suspend fun getCommand(project: Project): List<String>? {
val svc = ZLSService.getInstance(project) val zls = project.zls ?: return null
val zls = svc.zls ?: return null
val zlsPath: Path = zls.path val zlsPath: Path = zls.path
if (!zlsPath.toFile().exists()) { if (!zlsPath.toFile().exists()) {
Notification( Notification(

View file

@ -22,7 +22,7 @@
package com.falsepattern.zigbrains.lsp package com.falsepattern.zigbrains.lsp
import com.falsepattern.zigbrains.lsp.zls.ZLSService import com.falsepattern.zigbrains.lsp.zls.zls
import com.falsepattern.zigbrains.shared.zigCoroutineScope import com.falsepattern.zigbrains.shared.zigCoroutineScope
import com.intellij.openapi.components.service import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project import com.intellij.openapi.project.Project
@ -68,7 +68,7 @@ class ZigLanguageServerFactory: LanguageServerFactory, LanguageServerEnablementS
} }
features.inlayHintFeature = object: LSPInlayHintFeature() { features.inlayHintFeature = object: LSPInlayHintFeature() {
override fun isEnabled(file: PsiFile): Boolean { override fun isEnabled(file: PsiFile): Boolean {
return ZLSService.getInstance(project).zls?.settings?.inlayHints == true return project.zls?.settings?.inlayHints == true
} }
} }
return features return features
@ -82,7 +82,7 @@ class ZigLanguageServerFactory: LanguageServerFactory, LanguageServerEnablementS
} }
fun Project.zlsEnabled(): Boolean { fun Project.zlsEnabled(): Boolean {
return (getUserData(ENABLED_KEY) != false) && ZLSService.getInstance(this).zls?.isValid() == true return (getUserData(ENABLED_KEY) != false) && zls?.isValid() == true
} }
fun Project.zlsEnabled(value: Boolean) { fun Project.zlsEnabled(value: Boolean) {
@ -125,7 +125,7 @@ private suspend fun doStart(project: Project, restart: Boolean) {
project.lsm.stop("ZigBrains") project.lsm.stop("ZigBrains")
delay(250) delay(250)
} }
if (ZLSService.getInstance(project).zls?.isValid() == true) { if (project.zls?.isValid() == true) {
delay(250) delay(250)
project.lsm.start("ZigBrains") project.lsm.start("ZigBrains")
} }

View file

@ -23,7 +23,7 @@
package com.falsepattern.zigbrains.lsp.notification package com.falsepattern.zigbrains.lsp.notification
import com.falsepattern.zigbrains.lsp.ZLSBundle import com.falsepattern.zigbrains.lsp.ZLSBundle
import com.falsepattern.zigbrains.lsp.zls.ZLSService import com.falsepattern.zigbrains.lsp.zls.zls
import com.falsepattern.zigbrains.lsp.zlsRunning import com.falsepattern.zigbrains.lsp.zlsRunning
import com.falsepattern.zigbrains.shared.zigCoroutineScope import com.falsepattern.zigbrains.shared.zigCoroutineScope
import com.falsepattern.zigbrains.zig.ZigFileType import com.falsepattern.zigbrains.zig.ZigFileType
@ -52,7 +52,7 @@ class ZigEditorNotificationProvider: EditorNotificationProvider, DumbAware {
if (project.zlsRunning()) { if (project.zlsRunning()) {
return@async null return@async null
} else { } else {
return@async ZLSService.getInstance(project).zls?.isValid() == true return@async project.zls?.isValid() == true
} }
} }
return Function { editor -> return Function { editor ->

View file

@ -24,13 +24,13 @@ package com.falsepattern.zigbrains.lsp.settings
import com.falsepattern.zigbrains.lsp.config.ZLSConfig import com.falsepattern.zigbrains.lsp.config.ZLSConfig
import com.falsepattern.zigbrains.lsp.config.ZLSConfigProvider import com.falsepattern.zigbrains.lsp.config.ZLSConfigProvider
import com.falsepattern.zigbrains.lsp.zls.ZLSService import com.falsepattern.zigbrains.lsp.zls.zls
import com.falsepattern.zigbrains.shared.cli.translateCommandline import com.falsepattern.zigbrains.shared.cli.translateCommandline
import com.intellij.openapi.project.Project import com.intellij.openapi.project.Project
class ZLSSettingsConfigProvider: ZLSConfigProvider { class ZLSSettingsConfigProvider: ZLSConfigProvider {
override fun getEnvironment(project: Project, previous: ZLSConfig): ZLSConfig { override fun getEnvironment(project: Project, previous: ZLSConfig): ZLSConfig {
val state = ZLSService.getInstance(project).zls?.settings ?: return previous val state = project.zls?.settings ?: return previous
return previous.copy( return previous.copy(
enable_snippets = state.enable_snippets, enable_snippets = state.enable_snippets,
enable_argument_placeholders = state.enable_argument_placeholders, enable_argument_placeholders = state.enable_argument_placeholders,

View file

@ -22,42 +22,24 @@
package com.falsepattern.zigbrains.lsp.zls package com.falsepattern.zigbrains.lsp.zls
import com.falsepattern.zigbrains.project.toolchain.zigToolchainList import com.falsepattern.zigbrains.project.toolchain.ZigToolchainService
import com.intellij.openapi.components.* import com.falsepattern.zigbrains.project.toolchain.base.ZigToolchain
import com.falsepattern.zigbrains.project.toolchain.base.withExtraData
import com.falsepattern.zigbrains.shared.asString
import com.falsepattern.zigbrains.shared.asUUID
import com.intellij.openapi.project.Project import com.intellij.openapi.project.Project
import com.intellij.util.xmlb.annotations.Attribute
import java.util.UUID import java.util.UUID
@Service(Service.Level.PROJECT) fun <T: ZigToolchain> T.withZLS(uuid: UUID?): T {
@State( return withExtraData("zls_uuid", uuid?.asString())
name = "ZLS",
storages = [Storage("zigbrains.xml")]
)
class ZLSService: SerializablePersistentStateComponent<ZLSService.MyState>(MyState()) {
var zlsUUID: UUID?
get() = state.zls.ifBlank { null }?.let { UUID.fromString(it) }?.takeIf {
if (it in zigToolchainList) {
true
} else {
updateState {
it.copy(zls = "")
}
false
}
}
set(value) {
updateState {
it.copy(zls = value?.toString() ?: "")
}
}
val zls: ZLSVersion?
get() = zlsUUID?.let { zlsInstallations[it] }
data class MyState(@JvmField @Attribute var zls: String = "")
companion object {
@JvmStatic
fun getInstance(project: Project): ZLSService = project.service<ZLSService>()
}
} }
val ZigToolchain.zlsUUID: UUID? get() {
return extraData["zls_uuid"]?.asUUID()
}
val ZigToolchain.zls: ZLSVersion? get() {
return zlsUUID?.let { zlsInstallations[it] }
}
val Project.zls: ZLSVersion? get() = ZigToolchainService.getInstance(this).toolchain?.zls

View file

@ -24,14 +24,19 @@ package com.falsepattern.zigbrains.lsp.zls
import com.falsepattern.zigbrains.lsp.settings.ZLSSettings import com.falsepattern.zigbrains.lsp.settings.ZLSSettings
import com.falsepattern.zigbrains.shared.NamedObject import com.falsepattern.zigbrains.shared.NamedObject
import com.falsepattern.zigbrains.shared.cli.call
import com.falsepattern.zigbrains.shared.cli.createCommandLineSafe
import com.intellij.openapi.util.SystemInfo
import com.intellij.openapi.util.io.toNioPathOrNull import com.intellij.openapi.util.io.toNioPathOrNull
import com.intellij.util.text.SemVer
import java.nio.file.Path import java.nio.file.Path
import com.intellij.util.xmlb.annotations.Attribute import com.intellij.util.xmlb.annotations.Attribute
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
import kotlin.io.path.pathString import kotlin.io.path.pathString
data class ZLSVersion(val path: Path, override val name: String?, val settings: ZLSSettings): NamedObject<ZLSVersion> { data class ZLSVersion(val path: Path, override val name: String? = null, val settings: ZLSSettings = ZLSSettings()): NamedObject<ZLSVersion> {
override fun withName(newName: String?): ZLSVersion { override fun withName(newName: String?): ZLSVersion {
return copy(name = newName) return copy(name = newName)
} }
@ -48,6 +53,31 @@ data class ZLSVersion(val path: Path, override val name: String?, val settings:
return true return true
} }
suspend fun version(): SemVer? {
if (!isValid())
return null
val cli = createCommandLineSafe(null, path, "--version").getOrElse { return null }
val info = cli.call(5000).getOrElse { return null }
return SemVer.parseFromText(info.stdout.trim())
}
companion object {
suspend fun tryFromPath(path: Path): ZLSVersion? {
if (path.isDirectory()) {
val exeName = if (SystemInfo.isWindows) "zls.exe" else "zls"
return tryFromPath(path.resolve(exeName))
}
var zls = ZLSVersion(path)
if (!zls.isValid())
return null
val version = zls.version()?.rawVersion
if (version != null) {
zls = zls.copy(name = "ZLS $version")
}
return zls
}
}
data class Ref( data class Ref(
@JvmField @JvmField
@Attribute @Attribute

View file

@ -0,0 +1,92 @@
/*
* 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.lsp.zls.downloader
import com.falsepattern.zigbrains.lsp.zls.ZLSVersion
import com.falsepattern.zigbrains.project.settings.ZigProjectConfigurationProvider
import com.falsepattern.zigbrains.project.toolchain.base.ZigToolchainConfigurable
import com.falsepattern.zigbrains.shared.downloader.Downloader
import com.falsepattern.zigbrains.shared.downloader.LocalSelector
import com.intellij.openapi.util.NlsContexts
import com.intellij.openapi.util.io.toNioPathOrNull
import com.intellij.util.system.OS
import java.awt.Component
import java.nio.file.Path
import kotlin.io.path.isDirectory
class ZLSDownloader(component: Component, private val data: ZigProjectConfigurationProvider.IUserDataBridge?) : Downloader<ZLSVersion, ZLSVersionInfo>(component) {
override val windowTitle: String
get() = "Install ZLS"
override val versionInfoFetchTitle: @NlsContexts.ProgressTitle String
get() = "Fetching zls version information"
override fun downloadProgressTitle(version: ZLSVersionInfo): @NlsContexts.ProgressTitle String {
return "Installing ZLS ${version.version.rawVersion}"
}
override fun localSelector(): LocalSelector<ZLSVersion> {
return ZLSLocalSelector(component)
}
override suspend fun downloadVersionList(): List<ZLSVersionInfo> {
val toolchain = data?.getUserData(ZigToolchainConfigurable.TOOLCHAIN_KEY)?.get() ?: return emptyList()
val project = data.getUserData(ZigProjectConfigurationProvider.PROJECT_KEY)
return ZLSVersionInfo.downloadVersionInfoFor(toolchain, project)
}
override fun getSuggestedPath(): Path? {
return getSuggestedZLSPath()
}
}
fun getSuggestedZLSPath(): Path? {
return getWellKnownZLS().getOrNull(0)
}
/**
* Returns the paths to the following list of folders:
*
* 1. DATA/zls
* 2. HOME/.zig
*
* Where DATA is:
* - ~/Library on macOS
* - %LOCALAPPDATA% on Windows
* - $XDG_DATA_HOME (or ~/.local/share if not set) on other OSes
*
* and HOME is the user home path
*/
private fun getWellKnownZLS(): List<Path> {
val home = System.getProperty("user.home")?.toNioPathOrNull() ?: return emptyList()
val xdgDataHome = when(OS.CURRENT) {
OS.macOS -> home.resolve("Library")
OS.Windows -> System.getenv("LOCALAPPDATA")?.toNioPathOrNull()
else -> System.getenv("XDG_DATA_HOME")?.toNioPathOrNull() ?: home.resolve(Path.of(".local", "share"))
}
val res = ArrayList<Path>()
if (xdgDataHome != null && xdgDataHome.isDirectory()) {
res.add(xdgDataHome.resolve("zls"))
}
res.add(home.resolve(".zls"))
return res
}

View file

@ -0,0 +1,64 @@
/*
* 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.lsp.zls.downloader
import com.falsepattern.zigbrains.lsp.zls.ZLSVersion
import com.falsepattern.zigbrains.lsp.zls.zlsInstallations
import com.falsepattern.zigbrains.shared.downloader.LocalSelector
import com.falsepattern.zigbrains.shared.withUniqueName
import com.intellij.icons.AllIcons
import com.intellij.openapi.fileChooser.FileChooserDescriptor
import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory
import java.awt.Component
import java.nio.file.Path
class ZLSLocalSelector(component: Component) : LocalSelector<ZLSVersion>(component) {
override val windowTitle: String
get() = "Select ZLS from disk"
override val descriptor: FileChooserDescriptor
get() = FileChooserDescriptorFactory.createSingleFileNoJarsDescriptor().withTitle("ZLS binary")
override suspend fun verify(path: Path): VerifyResult {
var zls = resolve(path, null)
var result: VerifyResult
result = if (zls == null) VerifyResult(
null,
false,
AllIcons.General.Error,
"Invalid ZLS path",
) else VerifyResult(
null,
true,
AllIcons.General.Information,
"ZLS path OK"
)
if (zls != null) {
zls = zlsInstallations.withUniqueName(zls)
}
return result.copy(name = zls?.name)
}
override suspend fun resolve(path: Path, name: String?): ZLSVersion? {
return ZLSVersion.tryFromPath(path)?.let { zls -> name?.let { zls.copy(name = it) } ?: zls }
}
}

View file

@ -0,0 +1,85 @@
/*
* 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.lsp.zls.downloader
import com.falsepattern.zigbrains.project.toolchain.base.ZigToolchain
import com.falsepattern.zigbrains.project.toolchain.downloader.ZigVersionInfo
import com.falsepattern.zigbrains.shared.downloader.VersionInfo
import com.falsepattern.zigbrains.shared.downloader.VersionInfo.Tarball
import com.falsepattern.zigbrains.shared.downloader.getTarballIfCompatible
import com.falsepattern.zigbrains.shared.downloader.tempPluginDir
import com.intellij.openapi.progress.coroutineToIndicator
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.io.FileUtil
import com.intellij.util.asSafely
import com.intellij.util.download.DownloadableFileService
import com.intellij.util.text.SemVer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.decodeFromStream
import java.net.URLEncoder
@JvmRecord
data class ZLSVersionInfo(
override val version: SemVer,
override val date: String,
override val dist: Tarball
): VersionInfo {
companion object {
@OptIn(ExperimentalSerializationApi::class)
suspend fun downloadVersionInfoFor(toolchain: ZigToolchain, project: Project?): List<ZLSVersionInfo> {
return withContext(Dispatchers.IO) {
val zigVersion = toolchain.zig.getEnv(project).getOrNull()?.version ?: return@withContext emptyList()
val service = DownloadableFileService.getInstance()
val tempFile = FileUtil.createTempFile(tempPluginDir, "zls_version_info", ".json", false, false)
val desc = service.createFileDescription("https://releases.zigtools.org/v1/zls/select-version?zig_version=${URLEncoder.encode(zigVersion, Charsets.UTF_8)}&compatibility=only-runtime", tempFile.name)
val downloader = service.createDownloader(listOf(desc), "ZLS 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 listOfNotNull(parseVersion(info))
}
}
}
}
private fun parseVersion(data: JsonObject): ZLSVersionInfo? {
val versionTag = data["version"]?.asSafely<JsonPrimitive>()?.content
val version = SemVer.parseFromText(versionTag) ?: return null
val date = data["date"]?.asSafely<JsonPrimitive>()?.content ?: ""
val dist = data.firstNotNullOfOrNull { (dist, tb) -> getTarballIfCompatible(dist, tb) }
?: return null
return ZLSVersionInfo(version, date, dist)
}

View file

@ -24,26 +24,35 @@ package com.falsepattern.zigbrains.lsp.zls.ui
import com.falsepattern.zigbrains.lsp.zls.ZLSConfigurable import com.falsepattern.zigbrains.lsp.zls.ZLSConfigurable
import com.falsepattern.zigbrains.lsp.zls.ZLSVersion import com.falsepattern.zigbrains.lsp.zls.ZLSVersion
import com.falsepattern.zigbrains.lsp.zls.downloader.ZLSDownloader
import com.falsepattern.zigbrains.lsp.zls.downloader.ZLSLocalSelector
import com.falsepattern.zigbrains.lsp.zls.zlsInstallations import com.falsepattern.zigbrains.lsp.zls.zlsInstallations
import com.falsepattern.zigbrains.project.settings.ZigProjectConfigurationProvider
import com.falsepattern.zigbrains.project.toolchain.base.ZigToolchainConfigurable
import com.falsepattern.zigbrains.shared.UUIDMapSerializable import com.falsepattern.zigbrains.shared.UUIDMapSerializable
import com.falsepattern.zigbrains.shared.ui.ListElem import com.falsepattern.zigbrains.shared.ui.ListElem
import com.falsepattern.zigbrains.shared.ui.ListElem.One.Actual
import com.falsepattern.zigbrains.shared.ui.ListElemIn import com.falsepattern.zigbrains.shared.ui.ListElemIn
import com.falsepattern.zigbrains.shared.ui.Separator
import com.falsepattern.zigbrains.shared.ui.UUIDComboBoxDriver import com.falsepattern.zigbrains.shared.ui.UUIDComboBoxDriver
import com.falsepattern.zigbrains.shared.ui.ZBComboBox import com.falsepattern.zigbrains.shared.ui.ZBComboBox
import com.falsepattern.zigbrains.shared.ui.ZBContext import com.falsepattern.zigbrains.shared.ui.ZBContext
import com.falsepattern.zigbrains.shared.ui.ZBModel import com.falsepattern.zigbrains.shared.ui.ZBModel
import com.falsepattern.zigbrains.shared.ui.asPending
import com.falsepattern.zigbrains.shared.zigCoroutineScope
import com.intellij.openapi.ui.NamedConfigurable import com.intellij.openapi.ui.NamedConfigurable
import com.intellij.util.text.SemVer
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import java.awt.Component import java.awt.Component
import java.util.UUID import java.util.UUID
object ZLSDriver: UUIDComboBoxDriver<ZLSVersion> { sealed interface ZLSDriver: UUIDComboBoxDriver<ZLSVersion> {
override val theMap: UUIDMapSerializable.Converting<ZLSVersion, *, *> override val theMap: UUIDMapSerializable.Converting<ZLSVersion, *, *>
get() = zlsInstallations get() = zlsInstallations
override fun constructModelList(): List<ListElemIn<ZLSVersion>> {
return ListElem.fetchGroup()
}
override fun createContext(model: ZBModel<ZLSVersion>): ZBContext<ZLSVersion> { override fun createContext(model: ZBModel<ZLSVersion>): ZBContext<ZLSVersion> {
return ZLSContext(null, model) return ZLSContext(null, model)
} }
@ -52,16 +61,67 @@ object ZLSDriver: UUIDComboBoxDriver<ZLSVersion> {
return ZLSComboBox(model) return ZLSComboBox(model)
} }
override fun createNamedConfigurable(uuid: UUID, elem: ZLSVersion): NamedConfigurable<UUID> {
return ZLSConfigurable(uuid, elem)
}
override suspend fun resolvePseudo( override suspend fun resolvePseudo(
context: Component, context: Component,
elem: ListElem.Pseudo<ZLSVersion> elem: ListElem.Pseudo<ZLSVersion>
): UUID? { ): UUID? {
//TODO return when(elem) {
return null is ListElem.FromDisk<*> -> ZLSLocalSelector(context).browse()
else -> null
}?.let { zlsInstallations.registerNew(it) }
} }
override fun createNamedConfigurable(uuid: UUID, elem: ZLSVersion): NamedConfigurable<UUID> { object ForList: ZLSDriver {
//TODO override fun constructModelList(): List<ListElemIn<ZLSVersion>> {
return ZLSConfigurable(uuid, elem) return listOf(ListElem.None(), Separator("", true), ListElem.FromDisk())
}
}
@JvmRecord
data class ForSelector(private val data: ZigProjectConfigurationProvider.IUserDataBridge?): ZLSDriver {
override fun constructModelList(): List<ListElemIn<ZLSVersion>> {
val res = ArrayList<ListElemIn<ZLSVersion>>()
res.add(ListElem.None())
res.add(compatibleInstallations().asPending())
res.add(Separator("", true))
res.addAll(ListElem.fetchGroup())
return res
}
override suspend fun resolvePseudo(
context: Component,
elem: ListElem.Pseudo<ZLSVersion>
): UUID? {
return when(elem) {
is ListElem.FromDisk<*> -> ZLSLocalSelector(context).browse()
is ListElem.Download<*> -> ZLSDownloader(context, data).download()
else -> null
}?.let { zlsInstallations.registerNew(it) }
}
private fun compatibleInstallations(): Flow<Actual<ZLSVersion>> = flow {
val project = data?.getUserData(ZigProjectConfigurationProvider.PROJECT_KEY)
val toolchainVersion = data?.getUserData(ZigToolchainConfigurable.TOOLCHAIN_KEY)
?.get()
?.zig
?.getEnv(project)
?.getOrNull()
?.version
?.let { SemVer.parseFromText(it) }
?: return@flow
zlsInstallations.forEach { (uuid, version) ->
val zlsVersion = version.version() ?: return@forEach
if (numericVersionEquals(toolchainVersion, zlsVersion)) {
emit(Actual(uuid, version))
}
}
}
} }
} }
private fun numericVersionEquals(a: SemVer, b: SemVer): Boolean {
return a.major == b.major && a.minor == b.minor && a.patch == b.patch
}

View file

@ -22,27 +22,28 @@
package com.falsepattern.zigbrains.lsp.zls.ui package com.falsepattern.zigbrains.lsp.zls.ui
import com.falsepattern.zigbrains.lsp.zls.ZLSService import com.falsepattern.zigbrains.lsp.ZLSStarter
import com.falsepattern.zigbrains.lsp.startLSP
import com.falsepattern.zigbrains.lsp.zls.ZLSVersion import com.falsepattern.zigbrains.lsp.zls.ZLSVersion
import com.falsepattern.zigbrains.lsp.zls.withZLS
import com.falsepattern.zigbrains.lsp.zls.zlsUUID
import com.falsepattern.zigbrains.project.settings.ZigProjectConfigurationProvider import com.falsepattern.zigbrains.project.settings.ZigProjectConfigurationProvider
import com.falsepattern.zigbrains.shared.SubConfigurable import com.falsepattern.zigbrains.project.toolchain.base.ZigToolchain
import com.falsepattern.zigbrains.shared.ui.UUIDMapEditor import com.falsepattern.zigbrains.project.toolchain.base.ZigToolchainExtensionsProvider
import com.falsepattern.zigbrains.project.toolchain.ui.ImmutableElementPanel
import com.falsepattern.zigbrains.shared.ui.UUIDMapSelector import com.falsepattern.zigbrains.shared.ui.UUIDMapSelector
import com.falsepattern.zigbrains.shared.zigCoroutineScope import com.falsepattern.zigbrains.shared.zigCoroutineScope
import com.intellij.openapi.project.Project
import com.intellij.openapi.project.ProjectManager
import com.intellij.openapi.util.Key import com.intellij.openapi.util.Key
import com.intellij.ui.dsl.builder.Panel import com.intellij.ui.dsl.builder.Panel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class ZLSEditor(private var project: Project?, class ZLSEditor<T: ZigToolchain>(private val sharedState: ZigProjectConfigurationProvider.IUserDataBridge?):
private val sharedState: ZigProjectConfigurationProvider.IUserDataBridge): UUIDMapSelector<ZLSVersion>(ZLSDriver.ForSelector(sharedState)),
UUIDMapSelector<ZLSVersion>(ZLSDriver), ImmutableElementPanel<T>,
SubConfigurable<Project>,
ZigProjectConfigurationProvider.UserDataListener ZigProjectConfigurationProvider.UserDataListener
{ {
init { init {
sharedState.addUserDataChangeListener(this) sharedState?.addUserDataChangeListener(this)
} }
override fun onUserDataChanged(key: Key<*>) { override fun onUserDataChanged(key: Key<*>) {
@ -55,35 +56,30 @@ class ZLSEditor(private var project: Project?,
} }
} }
override fun isModified(context: Project): Boolean { override fun isModified(toolchain: T): Boolean {
return ZLSService.getInstance(context).zlsUUID != selectedUUID return toolchain.zlsUUID != selectedUUID
} }
override fun apply(context: Project) { override fun apply(toolchain: T): T {
ZLSService.getInstance(context).zlsUUID = selectedUUID return toolchain.withZLS(selectedUUID)
} }
override fun reset(context: Project?) { override fun reset(toolchain: T) {
val project = context ?: ProjectManager.getInstance().defaultProject selectedUUID = toolchain.zlsUUID
selectedUUID = ZLSService.getInstance(project).zlsUUID
} }
override fun dispose() { override fun dispose() {
super.dispose() super.dispose()
sharedState.removeUserDataChangeListener(this) sharedState?.removeUserDataChangeListener(this)
} }
override val newProjectBeforeInitSelector: Boolean get() = true class Provider: ZigToolchainExtensionsProvider {
class Provider: ZigProjectConfigurationProvider { override fun <T : ZigToolchain> createExtensionPanel(sharedState: ZigProjectConfigurationProvider.IUserDataBridge?): ImmutableElementPanel<T>? {
override fun create( return ZLSEditor(sharedState)
project: Project?,
sharedState: ZigProjectConfigurationProvider.IUserDataBridge
): SubConfigurable<Project>? {
return ZLSEditor(project, sharedState)
} }
override val index: Int override val index: Int
get() = 50 get() = 100
} }
} }

View file

@ -28,7 +28,7 @@ import com.falsepattern.zigbrains.shared.ui.UUIDMapEditor
import com.intellij.openapi.ui.MasterDetailsComponent import com.intellij.openapi.ui.MasterDetailsComponent
import com.intellij.openapi.util.NlsContexts import com.intellij.openapi.util.NlsContexts
class ZLSListEditor : UUIDMapEditor<ZLSVersion>(ZLSDriver) { class ZLSListEditor : UUIDMapEditor<ZLSVersion>(ZLSDriver.ForList) {
override fun getEmptySelectionString(): String { override fun getEmptySelectionString(): String {
return ZLSBundle.message("settings.list.empty") return ZLSBundle.message("settings.list.empty")
} }

View file

@ -65,7 +65,7 @@
<zlsConfigProvider <zlsConfigProvider
implementation="com.falsepattern.zigbrains.lsp.ToolchainZLSConfigProvider" implementation="com.falsepattern.zigbrains.lsp.ToolchainZLSConfigProvider"
/> />
<projectConfigProvider <toolchainExtensionsProvider
implementation="com.falsepattern.zigbrains.lsp.zls.ui.ZLSEditor$Provider" implementation="com.falsepattern.zigbrains.lsp.zls.ui.ZLSEditor$Provider"
/> />
</extensions> </extensions>