diff --git a/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/ISPScanner.kt b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/SourceScanner.kt similarity index 60% rename from src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/ISPScanner.kt rename to src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/SourceScanner.kt index d8ff56f..7654903 100644 --- a/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/ISPScanner.kt +++ b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/SourceScanner.kt @@ -1,23 +1,23 @@ package com.github.warningimhack3r.intellijshadcnplugin.backend import com.github.warningimhack3r.intellijshadcnplugin.backend.helpers.FileManager -import com.github.warningimhack3r.intellijshadcnplugin.backend.sources.ISPSource -import com.github.warningimhack3r.intellijshadcnplugin.backend.sources.impl.ISPReactSource -import com.github.warningimhack3r.intellijshadcnplugin.backend.sources.impl.ISPSolidSource -import com.github.warningimhack3r.intellijshadcnplugin.backend.sources.impl.ISPSvelteSource -import com.github.warningimhack3r.intellijshadcnplugin.backend.sources.impl.ISPVueSource +import com.github.warningimhack3r.intellijshadcnplugin.backend.sources.Source +import com.github.warningimhack3r.intellijshadcnplugin.backend.sources.impl.ReactSource +import com.github.warningimhack3r.intellijshadcnplugin.backend.sources.impl.SolidSource +import com.github.warningimhack3r.intellijshadcnplugin.backend.sources.impl.SvelteSource +import com.github.warningimhack3r.intellijshadcnplugin.backend.sources.impl.VueSource import com.intellij.openapi.project.Project -object ISPScanner { +object SourceScanner { - fun findShadcnImplementation(project: Project): ISPSource? { + fun findShadcnImplementation(project: Project): Source<*>? { return FileManager(project).getVirtualFilesByName("components.json").firstOrNull()?.let { componentsJson -> val contents = componentsJson.contentsToByteArray().decodeToString() when { - contents.contains("shadcn-svelte.com") -> ISPSvelteSource(project) - contents.contains("ui.shadcn.com") -> ISPReactSource(project) - contents.contains("shadcn-vue.com") -> ISPVueSource(project) - contents.contains("shadcn-solid") -> ISPSolidSource(project) + contents.contains("shadcn-svelte.com") -> SvelteSource(project) + contents.contains("ui.shadcn.com") -> ReactSource(project) + contents.contains("shadcn-vue.com") -> VueSource(project) + contents.contains("shadcn-solid") -> SolidSource(project) else -> null } } diff --git a/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/http/RequestSender.kt b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/http/RequestSender.kt index 0cf12bc..7dbf67b 100644 --- a/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/http/RequestSender.kt +++ b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/http/RequestSender.kt @@ -20,18 +20,29 @@ object RequestSender { requestMethod = method doOutput = body != null headers?.forEach(::setRequestProperty) - } - - if (body != null) { - conn.outputStream.use { - it.write(body.toByteArray()) + body?.let { + outputStream.use { + it.write(body.toByteArray()) + } } } - val responseBody = conn.inputStream.use { it.readBytes() }.toString(Charsets.UTF_8) + if (conn.responseCode in 300..399) { + return sendRequest(conn.getHeaderField("Location"), method, mapOf( + "Cookie" to conn.getHeaderField("Set-Cookie") + ).filter { it.value != null }, body) + } - return Response(conn.responseCode, conn.headerFields, responseBody) + return Response(conn.responseCode, conn.headerFields, conn.inputStream.bufferedReader().readText()) } - data class Response(val statusCode: Int, val headers: Map>? = null, val body: String? = null) + data class Response(val statusCode: Int, val headers: Map>, val body: String) { + + fun ok(action: (Response) -> T): T? { + if (statusCode in 200..299) { + return action(this) + } + return null + } + } } diff --git a/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/ISPSource.kt b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/ISPSource.kt deleted file mode 100644 index 7571449..0000000 --- a/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/ISPSource.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.github.warningimhack3r.intellijshadcnplugin.backend.sources - -interface ISPSource { - - var domain: String - var language: String - - fun fetchAllComponents(): List - - fun fetchAllStyles(): List - - fun getInstalledComponents(): List - - fun addComponent(componentName: String) - - fun isComponentUpToDate(componentName: String): Boolean - - fun removeComponent(componentName: String) -} diff --git a/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/ISPStyle.kt b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/ISPStyle.kt deleted file mode 100644 index e2d317f..0000000 --- a/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/ISPStyle.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.github.warningimhack3r.intellijshadcnplugin.backend.sources - -data class ISPStyle( - val name: String, - val label: String -) diff --git a/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/Source.kt b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/Source.kt new file mode 100644 index 0000000..62f1bc9 --- /dev/null +++ b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/Source.kt @@ -0,0 +1,152 @@ +package com.github.warningimhack3r.intellijshadcnplugin.backend.sources + +import com.github.warningimhack3r.intellijshadcnplugin.backend.helpers.DependencyManager +import com.github.warningimhack3r.intellijshadcnplugin.backend.helpers.FileManager +import com.github.warningimhack3r.intellijshadcnplugin.backend.http.RequestSender +import com.github.warningimhack3r.intellijshadcnplugin.backend.sources.config.Config +import com.github.warningimhack3r.intellijshadcnplugin.backend.sources.remote.Component +import com.github.warningimhack3r.intellijshadcnplugin.backend.sources.remote.ComponentWithContents +import com.github.warningimhack3r.intellijshadcnplugin.notifications.NotificationManager +import com.intellij.notification.NotificationAction +import com.intellij.openapi.fileTypes.FileTypeManager +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiFileFactory +import kotlinx.serialization.KSerializer +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import org.jetbrains.concurrency.runAsync +import java.net.URI +import java.nio.file.NoSuchFileException + +abstract class Source(val project: Project, private val serializer: KSerializer) { + abstract var framework: String + private val domain: String + get() = URI(getLocalConfig().`$schema`).let { uri -> + "${uri.scheme}://${uri.host}" + } + + // Utility methods + protected fun getLocalConfig(): C { + return FileManager(project).getFileContentsAtPath("components.json")?.let { + Json.decodeFromString(serializer, it) + } ?: throw NoSuchFileException("components.json not found") + } + + protected abstract fun resolveAlias(alias: String): String + + protected fun cleanAlias(alias: String): String = if (alias.startsWith("\$")) { + "\\$alias" // fixes Kotlin silently crashing when the replacement starts with $ with a regex + } else alias + + protected open fun adaptFileExtensionToConfig(extension: String): String = extension + + protected abstract fun adaptFileToConfig(contents: String): String + + private fun fetchComponent(componentName: String): ComponentWithContents { + val style = getLocalConfig().style + val response = RequestSender.sendRequest("$domain/registry/styles/$style/$componentName.json") + return response.ok { Json.decodeFromString(it.body) } ?: throw Exception("Component not found") + } + + protected fun fetchColors(): JsonElement { + val baseColor = getLocalConfig().tailwind.baseColor + val response = RequestSender.sendRequest("$domain/registry/colors/$baseColor.json") + return response.ok { Json.parseToJsonElement(it.body) } ?: throw Exception("Colors not found") + } + + // Public methods + open fun fetchAllComponents(): List { + val response = RequestSender.sendRequest("$domain/registry/index.json") + return response.ok { + Json.decodeFromString>(it.body) + }?.map { ISPComponent(it.name) } ?: emptyList() + } + + open fun getInstalledComponents(): List { + return FileManager(project).getFileAtPath( + "${resolveAlias(getLocalConfig().aliases.components)}/ui" + )?.children?.map { it.name }?.sorted() ?: emptyList() + } + + open fun addComponent(componentName: String) { + val installedComponents = getInstalledComponents() + fun getRegistryDependencies(component: ComponentWithContents): List { + return component.registryDependencies.filter { + !installedComponents.contains(it) + }.map { registryDependency -> + val dependency = fetchComponent(registryDependency) + listOf(dependency, *getRegistryDependencies(dependency).toTypedArray()) + }.flatten() + } + + // Install component + val component = fetchComponent(componentName) + val components = setOf(component, *getRegistryDependencies(fetchComponent(componentName)).toTypedArray()) + val config = getLocalConfig() + components.forEach { downloadedComponent -> + downloadedComponent.files.forEach { file -> + val path = "${resolveAlias(config.aliases.components)}/${component.type.substringAfterLast(":")}/${downloadedComponent.name}" + val psiFile = PsiFileFactory.getInstance(project).createFileFromText( + adaptFileExtensionToConfig(file.name), + FileTypeManager.getInstance().getFileTypeByExtension( + adaptFileExtensionToConfig(file.name).substringAfterLast('.') + ), + adaptFileToConfig(file.content) + ) + FileManager(project).saveFileAtPath(psiFile, path) + } + } + + // Install dependencies + val depsManager = DependencyManager(project) + val depsToInstall = component.dependencies.filter { dependency -> + !depsManager.isDependencyInstalled(dependency) + } + if (depsToInstall.isEmpty()) return + val dependenciesList = with(depsToInstall) { + if (size == 1) first() else { + "${dropLast(1).joinToString(", ")} and ${last()}" + } + } + val notifManager = NotificationManager(project) + notifManager.sendNotification( + "Installed ${component.name}", + "${component.name} requires $dependenciesList to be installed." + ) { notif -> + mapOf( + "Install" to DependencyManager.InstallationType.PROD, + "Install as dev" to DependencyManager.InstallationType.DEV + ).map { (label, installType) -> + NotificationAction.createSimple(label) { + runAsync { + depsManager.installDependencies(depsToInstall, installType) + }.then { + notifManager.sendNotificationAndHide( + "Installed $dependenciesList", + "Installed $dependenciesList for ${component.name}.", + ) + } + notif.hideBalloon() + } + } + } + } + + open fun isComponentUpToDate(componentName: String): Boolean { + val config = getLocalConfig() + val remoteComponent = fetchComponent(componentName) + return remoteComponent.files.all { file -> + FileManager(project).getFileContentsAtPath( + "${resolveAlias(config.aliases.components)}/${remoteComponent.type.substringAfterLast(":")}/${remoteComponent.name}/${file.name}" + ) == adaptFileToConfig(file.content) + } + } + + open fun removeComponent(componentName: String) { + val remoteComponent = fetchComponent(componentName) + FileManager(project).deleteFileAtPath( + "${resolveAlias(getLocalConfig().aliases.components)}/${remoteComponent.type.substringAfterLast(":")}/${remoteComponent.name}" + ) + } +} diff --git a/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/config/Config.kt b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/config/Config.kt new file mode 100644 index 0000000..9eaf1b0 --- /dev/null +++ b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/config/Config.kt @@ -0,0 +1,61 @@ +package com.github.warningimhack3r.intellijshadcnplugin.backend.sources.config + +import kotlinx.serialization.Serializable + +/** + * A shadcn-svelte locally installed components.json file. + */ +@Suppress("PROVIDED_RUNTIME_TOO_LOW", "kotlin:S117") +@Serializable +sealed class Config { + /** + * The schema URL for the file. + */ + abstract val `$schema`: String + /** + * The library's style used. + */ + abstract val style: String + /** + * The Tailwind configuration. + */ + abstract val tailwind: Tailwind + /** + * The aliases for the components and utils directories. + */ + abstract val aliases: Aliases + + /** + * The Tailwind configuration. + */ + @Serializable + sealed class Tailwind { + /** + * The relative path to the Tailwind config file. + */ + abstract val config: String + /** + * The relative path of the Tailwind CSS file. + */ + abstract val css: String + /** + * The library's base color. + */ + abstract val baseColor: String + } + + /** + * The aliases for the components and utils directories. + */ + @Serializable + sealed class Aliases { + /** + * The alias for the components' directory. + */ + abstract val components: String + /** + * The alias for the utils directory. + */ + abstract val utils: String + } +} diff --git a/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/config/ReactConfig.kt b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/config/ReactConfig.kt new file mode 100644 index 0000000..43ec3e7 --- /dev/null +++ b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/config/ReactConfig.kt @@ -0,0 +1,54 @@ +package com.github.warningimhack3r.intellijshadcnplugin.backend.sources.config + +import kotlinx.serialization.Serializable + +/** + * A shadcn-svelte locally installed components.json file. + * @param `$schema` The schema URL for the file. + * @param style The library style used. + * @param tailwind The Tailwind configuration. + * @param rsc Whether to support React Server Components. + * @param tsx Whether to use TypeScript over JavaScript. + * @param aliases The aliases for the components and utils directories. + */ +@Suppress("PROVIDED_RUNTIME_TOO_LOW", "kotlin:S117") +@Serializable +class ReactConfig( + override val `$schema`: String, + override val style: String, + override val tailwind: Tailwind, + val rsc: Boolean, + val tsx: Boolean = true, + override val aliases: Aliases +) : Config() { + + /** + * The Tailwind configuration. + * @param config The relative path to the Tailwind config file. + * @param css The relative path of the Tailwind CSS file. + * @param baseColor The library's base color. + * @param cssVariables Whether to use CSS variables or utility classes. + * @param prefix The prefix to use for utility classes. + */ + @Serializable + class Tailwind( + override val config: String, + override val css: String, + override val baseColor: String, + val cssVariables: Boolean, + val prefix: String = "" + ) : Config.Tailwind() + + /** + * The aliases for the components and utils directories. + * @param components The alias for the components' directory. + * @param utils The alias for the utils directory. + * @param ui The alias for UI components. + */ + @Serializable + class Aliases( + override val components: String, + override val utils: String, + val ui: String? = null + ) : Config.Aliases() +} diff --git a/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/config/SolidConfig.kt b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/config/SolidConfig.kt new file mode 100644 index 0000000..da4fcc9 --- /dev/null +++ b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/config/SolidConfig.kt @@ -0,0 +1,19 @@ +package com.github.warningimhack3r.intellijshadcnplugin.backend.sources.config + +import kotlinx.serialization.Serializable + +@Suppress("PROVIDED_RUNTIME_TOO_LOW", "kotlin:S117") +@Serializable +class SolidConfig( + override val `$schema`: String, + override val style: String, + override val tailwind: VueConfig.Tailwind, + override val aliases: Aliases +) : Config() { + + @Serializable + class Aliases( + override val components: String, + override val utils: String + ) : Config.Aliases() +} diff --git a/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/config/SvelteConfig.kt b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/config/SvelteConfig.kt new file mode 100644 index 0000000..45175d2 --- /dev/null +++ b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/config/SvelteConfig.kt @@ -0,0 +1,26 @@ +package com.github.warningimhack3r.intellijshadcnplugin.backend.sources.config + +import kotlinx.serialization.Serializable + +@Suppress("PROVIDED_RUNTIME_TOO_LOW", "kotlin:S117") +@Serializable +class SvelteConfig( + override val `$schema`: String, + override val style: String, + override val tailwind: Tailwind, + override val aliases: Aliases +) : Config() { + + @Serializable + class Tailwind( + override val config: String, + override val css: String, + override val baseColor: String + ) : Config.Tailwind() + + @Serializable + class Aliases( + override val components: String, + override val utils: String + ) : Config.Aliases() +} diff --git a/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/config/VueConfig.kt b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/config/VueConfig.kt new file mode 100644 index 0000000..eb4ac47 --- /dev/null +++ b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/config/VueConfig.kt @@ -0,0 +1,61 @@ +package com.github.warningimhack3r.intellijshadcnplugin.backend.sources.config + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * A shadcn-svelte locally installed components.json file. + * @param `$schema` The schema URL for the file. + * @param style The library style installed. + * @param typescript Whether to use TypeScript over JavaScript. + * @param tailwind The Tailwind configuration. + * @param framework The Vue framework to use. + * @param aliases The aliases for the components and utils directories. + */ +@Suppress("PROVIDED_RUNTIME_TOO_LOW", "kotlin:S117") +@Serializable +class VueConfig( + override val `$schema`: String, + override val style: String, + val typescript: Boolean = true, + override val tailwind: Tailwind, + val framework: Framework = Framework.VITE, + override val aliases: Aliases +) : Config() { + + /** + * The Tailwind configuration. + * @param config The relative path to the Tailwind config file. + * @param css The relative path of the Tailwind CSS file. + * @param baseColor The library's base color. + * @param cssVariables Whether to use CSS variables instead of Tailwind utility classes. + */ + @Serializable + open class Tailwind( + override val config: String, + override val css: String, + override val baseColor: String, + open val cssVariables: Boolean = true + ) : Config.Tailwind() + + /** + * The framework used. + */ + @Serializable + enum class Framework { + @SerialName("vite") + VITE, + @SerialName("nuxt") + NUXT, + @SerialName("laravel") + LARAVEL, + @SerialName("astro") + ASTRO + } + + @Serializable + class Aliases( + override val components: String, + override val utils: String, + ) : Config.Aliases() +} diff --git a/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/impl/ISPReactSource.kt b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/impl/ISPReactSource.kt deleted file mode 100644 index 2ec5c7e..0000000 --- a/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/impl/ISPReactSource.kt +++ /dev/null @@ -1,432 +0,0 @@ -package com.github.warningimhack3r.intellijshadcnplugin.backend.sources.impl - -import com.github.warningimhack3r.intellijshadcnplugin.backend.helpers.DependencyManager -import com.github.warningimhack3r.intellijshadcnplugin.backend.helpers.FileManager -import com.github.warningimhack3r.intellijshadcnplugin.backend.http.RequestSender -import com.github.warningimhack3r.intellijshadcnplugin.backend.sources.ISPComponent -import com.github.warningimhack3r.intellijshadcnplugin.backend.sources.ISPSource -import com.github.warningimhack3r.intellijshadcnplugin.backend.sources.ISPStyle -import com.intellij.notification.Notification -import com.intellij.notification.NotificationAction -import com.intellij.notification.NotificationType -import com.intellij.notification.Notifications -import com.intellij.openapi.fileTypes.FileTypeManager -import com.intellij.openapi.project.Project -import com.intellij.psi.PsiFileFactory -import com.intellij.util.applyIf -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.json.* -import org.jetbrains.concurrency.runAsync -import java.nio.file.NoSuchFileException - -class ISPReactSource(private val project: Project): ISPSource { - override var domain = "https://ui.shadcn.com" - override var language = "React" - - private fun fetchComponent(componentName: String): ReactTypes.ComponentWithContents { - val style = getLocalConfig().style - val response = RequestSender.sendRequest("$domain/registry/styles/$style/$componentName.json") - return response.body?.let { Json.decodeFromString(it) } ?: throw Exception("Component not found") - } - - private fun fetchColors(): JsonElement { - val baseColor = getLocalConfig().tailwind.baseColor - val response = RequestSender.sendRequest("$domain/registry/colors/$baseColor.json") - return response.body?.let { Json.parseToJsonElement(it) } ?: throw Exception("Colors not found") - } - - private fun getLocalConfig(): ReactTypes.Config { - return FileManager(project).getFileContentsAtPath("components.json")?.let { - Json.decodeFromString(it) - } ?: throw NoSuchFileException("components.json not found") - } - - private fun getConfigFileName(fileName: String): String { - return if (!getLocalConfig().tsx) { - fileName - .replace(Regex("\\.tsx$"), ".ts") - .replace(Regex("\\.jsx$"), ".js") - } else fileName - } - - private fun resolveAlias(alias: String): String { - if (!alias.startsWith("$") && !alias.startsWith("@")) return alias - val tsConfig = FileManager(project).getFileContentsAtPath("tsconfig.json") ?: throw NoSuchFileException("tsconfig.json not found") - val aliasPath = Json.parseToJsonElement(tsConfig) - .jsonObject["compilerOptions"] - ?.jsonObject?.get("paths") - ?.jsonObject?.get("${alias.substringBefore("/")}/*") - ?.jsonArray?.get(0) - ?.jsonPrimitive?.content ?: throw Exception("Cannot find alias $alias") // TODO: fallback to vite.config.(j|t)s for all - return aliasPath.replace(Regex("^\\./"), "") - .replace(Regex("\\*$"), alias.substringAfter("/")) - } - - private fun adaptFileContents(contents: String): String { - fun cleanAlias(alias: String): String { - return if (alias.startsWith("\$")) { - "\\$alias" // fixes Kotlin silently crashing when the replacement starts with $ with a regex - } else alias - } - - val config = getLocalConfig() - // Note: this condition does not replace UI paths (= $components/$ui) by the components path - // if the UI alias is not set. - // For me, this is a bug, but I'm following what the original code does for parity - // (https://github.com/shadcn-ui/ui/blob/fb614ac2921a84b916c56e9091aa0ae8e129c565/packages/cli/src/utils/transformers/transform-import.ts#L10-L23). - var newContents = if (config.aliases.ui != null) { - contents.replace( - Regex("@/registry/[^/]+/ui"), cleanAlias(config.aliases.ui) - ) - } else contents.replace( - Regex("@/registry/[^/]+"), cleanAlias(config.aliases.components) - ) - newContents = newContents.replace( - // Note: this does not prevent additional imports other than "cn" from being replaced, - // but I'm once again following what the original code does for parity - // (https://github.com/shadcn-ui/ui/blob/fb614ac2921a84b916c56e9091aa0ae8e129c565/packages/cli/src/utils/transformers/transform-import.ts#L25-L35). - Regex(".*\\{.*[ ,\n\t]+cn[ ,].*}.*\"@/lib/utils"), config.aliases.utils - ).applyIf(config.rsc) { - replace( - Regex("\"use client\";*\n"), "" - ) - } - - /** - * Prepends `tw-` to all Tailwind classes. - * @param classes The classes to prefix, an unquoted string of space-separated class names. - * @param prefix The prefix to add to each class name. - * @return The prefixed classes. - */ - fun prefixClasses(classes: String, prefix: String): String = classes - .split(" ") - .filterNot { it.isEmpty() } - .joinToString(" ") { - val className = it.trim().split(":") - if (className.size == 1) { - "$prefix${className[0]}" - } else { - "${className.dropLast(1).joinToString(":")}:$prefix${className.last()}" - } - } - - /** - * Converts CSS variables to Tailwind utility classes. - * @param classes The classes to convert, an unquoted string of space-separated class names. - * @param lightColors The light colors map to use. - * @param darkColors The dark colors map to use. - * @return The converted classes. - */ - fun variablesToUtilities(classes: String, lightColors: Map, darkColors: Map): String { - // Note: this does not include `border` classes at the beginning or end of the string, - // but I'm once again following what the original code does for parity - // (https://github.com/shadcn-ui/ui/blob/fb614ac2921a84b916c56e9091aa0ae8e129c565/packages/cli/src/utils/transformers/transform-css-vars.ts#L142-L145). - val newClasses = classes.replace(" border ", " border border-border ") - - val prefixesToReplace = listOf("bg-", "text-", "border-", "ring-offset-", "ring-") - - /** - * Replaces a class with CSS variables with Tailwind utility classes. - * @param class The class to replace. - * @return The replaced class. - */ - fun replaceClass(`class`: String): String { - val prefix = prefixesToReplace.find { `class`.startsWith(it) } ?: return `class` - val color = `class`.substringAfter(prefix) - val lightColor = lightColors[color] - val darkColor = darkColors[color] - return if (lightColor != null && darkColor != null) { - "$prefix$lightColor dark:$prefix$darkColor" - } else `class` - } - - return newClasses - .split(" ") - .filterNot { it.isEmpty() } - .joinToString(" ") { - val className = it.trim().split(":") - if (className.size == 1) { - replaceClass(className[0]) - } else { - "${className.dropLast(1).joinToString(":")}:${replaceClass(className.last())}" - } - } - } - - fun handleClasses(classes: String): String { - var newClasses = classes - if (!config.tailwind.cssVariables) { - val inlineColors = fetchColors().jsonObject["inlineColors"]?.jsonObject ?: throw Exception("Inline colors not found") - newClasses = variablesToUtilities( - newClasses, - inlineColors.jsonObject["light"]?.jsonObject?.let { lightColors -> - lightColors.keys.associateWith { lightColors[it]?.jsonPrimitive?.content ?: "" } - } ?: emptyMap(), - inlineColors.jsonObject["dark"]?.jsonObject?.let { darkColors -> - darkColors.keys.associateWith { darkColors[it]?.jsonPrimitive?.content ?: "" } - } ?: emptyMap() - ) - } - if (config.tailwind.prefix.isNotEmpty()) { - newClasses = prefixClasses(newClasses, config.tailwind.prefix) - } - return newClasses - } - - return Regex("className=(?:(?!>)[^\"'])*[\"']([^>]*)[\"']").replace(newContents) { result -> - // matches any className, and takes everything inside the first quote to the last quote found before the closing `>` - // if no quotes are found before the closing `>`, skips the match - val match = result.groupValues[0] - val group = result.groupValues[1] - match.replace( - group, - // if the group contains a quote, we assume the classes are the last quoted string in the group - if (group.contains("\"")) { - group.substringBeforeLast('"') + "\"" + handleClasses( - group.substringAfterLast('"') - ) - } else if (group.contains("'")) { - group.substringBeforeLast("'") + "'" + handleClasses( - group.substringAfterLast("'") - ) - } else handleClasses(group) - ) - } - } - - override fun fetchAllComponents(): List { - val response = RequestSender.sendRequest("$domain/registry/index.json") - return response.body?.let { - Json.decodeFromString>(it) - }?.map { ISPComponent(it.name) } ?: emptyList() - } - - override fun fetchAllStyles(): List { - val response = RequestSender.sendRequest("$domain/registry/styles/index.json") - return response.body?.let { Json.decodeFromString>(it) } ?: emptyList() - } - - override fun getInstalledComponents(): List { - return FileManager(project).getFileAtPath( - resolveAlias(getLocalConfig().aliases.components) + "/" + ReactTypes.ComponentKind.UI.name.lowercase() - )?.children?.map { it.name }?.sorted() ?: emptyList() - } - - override fun addComponent(componentName: String) { - val installedComponents = getInstalledComponents() - fun getRegistryDependencies(component: ReactTypes.ComponentWithContents): List { - return component.registryDependencies.filter { - !installedComponents.contains(it) - }.map { registryDependency -> - val dependency = fetchComponent(registryDependency) - listOf(dependency, *getRegistryDependencies(dependency).toTypedArray()) - }.flatten() - } - - // Install component - val component = fetchComponent(componentName) - val components = setOf(component, *getRegistryDependencies(fetchComponent(componentName)).toTypedArray()) - val config = getLocalConfig() - components.forEach { downloadedComponent -> - downloadedComponent.files.forEach { file -> - val path = "${resolveAlias(config.aliases.components)}/${component.type.name.lowercase()}/${downloadedComponent.name}" - val psiFile = PsiFileFactory.getInstance(project).createFileFromText( - getConfigFileName(file.name), - FileTypeManager.getInstance().getFileTypeByExtension(getConfigFileName(file.name).substringAfterLast('.')), - adaptFileContents(file.content) - ) - FileManager(project).saveFileAtPath(psiFile, path) - } - } - - // Install dependencies - val manager = DependencyManager(project) - val depsToInstall = component.dependencies.filter { dependency -> - !manager.isDependencyInstalled(dependency) - } - if (depsToInstall.isEmpty()) return - val dependenciesList = with(depsToInstall) { - if (size == 1) first() else { - "${dropLast(1).joinToString(", ")} and ${last()}" - } - } - Notifications.Bus.notify( - Notification( - "shadcn/ui", - "Installed ${component.name}", - "${component.name} requires $dependenciesList to be installed.", - NotificationType.INFORMATION - ).apply { - mapOf( - "Install" to DependencyManager.InstallationType.PROD, - "Install as dev" to DependencyManager.InstallationType.DEV - ).forEach { (label, installType) -> - addAction(NotificationAction.createSimple(label) { - runAsync { - manager.installDependencies(depsToInstall, installType) - }.then { - Notifications.Bus.notifyAndHide( - Notification( - "shadcn/ui", - "Installed $dependenciesList", - "Installed $dependenciesList for ${component.name}.", - NotificationType.INFORMATION - ), - project - ) - } - hideBalloon() - }) - } - }, - project - ) - } - - override fun isComponentUpToDate(componentName: String): Boolean { - val config = getLocalConfig() - val remoteComponent = fetchComponent(componentName) - return remoteComponent.files.all { file -> - FileManager(project).getFileContentsAtPath( - "${resolveAlias(config.aliases.components)}/${remoteComponent.type.name.lowercase()}/${remoteComponent.name}/${getConfigFileName(file.name)}" - ) == adaptFileContents(file.content) - } - } - - override fun removeComponent(componentName: String) { - val remoteComponent = fetchComponent(componentName) - FileManager(project).deleteFileAtPath( - "${resolveAlias(getLocalConfig().aliases.components)}/${remoteComponent.type.name.lowercase()}/${remoteComponent.name}" - ) - } -} - -object ReactTypes { - /** - * The kind of component. - */ - @Suppress("PROVIDED_RUNTIME_TOO_LOW") - @Serializable - enum class ComponentKind { - @SerialName("components:ui") - UI, - @SerialName("components:component") - COMPONENT, - @SerialName("components:example") - EXAMPLE - } - - /** - * A shadcn-svelte component in the registry. - * @param name The name of the component. - * @param dependencies The npm dependencies of the component. - * @param registryDependencies The other components that this component depends on. - * @param files The files that make up the component. - * @param type The kind of component. - */ - @Suppress("PROVIDED_RUNTIME_TOO_LOW") - @Serializable - data class Component( - val name: String, - val dependencies: List, - val registryDependencies: List, - val files: List, - val type: ComponentKind - ) - - /** - * A shadcn-svelte component in the registry. - * @param name The name of the component. - * @param dependencies The npm dependencies of the component. - * @param registryDependencies The other components that this component depends on. - * @param files The files that make up the component. - * @param type The kind of component. - */ - @Suppress("PROVIDED_RUNTIME_TOO_LOW") - @Serializable - data class ComponentWithContents( - val name: String, - val dependencies: List, - val registryDependencies: List, - val files: List, - val type: ComponentKind - ) { - /** - * A component's file. - * @param name The name of the file. - * @param content The contents of the file. - */ - @Suppress("PROVIDED_RUNTIME_TOO_LOW") - @Serializable - data class File( - val name: String, - val content: String - ) - } - - /** - * A shadcn-svelte locally installed components.json file. - * @param `$schema` The schema URL for the file. - * @param style The library style used. - * @param tailwind The Tailwind configuration. - * @param rsc Whether to support React Server Components. - * @param tsx Whether to use TypeScript over JavaScript. - * @param aliases The aliases for the components and utils directories. - */ - @Suppress("PROVIDED_RUNTIME_TOO_LOW", "kotlin:S117") - @Serializable - data class Config( - val `$schema`: String, - val style: Styles, - val tailwind: Tailwind, - val rsc: Boolean, - val tsx: Boolean = true, - val aliases: Aliases - ) { - /** - * The library style used. - */ - @Suppress("PROVIDED_RUNTIME_TOO_LOW") - @Serializable - enum class Styles { - @SerialName("default") - DEFAULT, - @SerialName("new-york") - NEW_YORK - } - - /** - * The Tailwind configuration. - * @param config The relative path to the Tailwind config file. - * @param css The relative path of the Tailwind CSS file. - * @param baseColor The library's base color. - * @param cssVariables Whether to use CSS variables or utility classes. - * @param prefix The prefix to use for utility classes. - */ - @Suppress("PROVIDED_RUNTIME_TOO_LOW") - @Serializable - data class Tailwind( - val config: String, - val css: String, - val baseColor: String, - val cssVariables: Boolean, - val prefix: String = "" - ) - - /** - * The aliases for the components and utils directories. - * @param components The alias for the components' directory. - * @param utils The alias for the utils directory. - * @param ui The alias for UI components. - */ - @Suppress("PROVIDED_RUNTIME_TOO_LOW") - @Serializable - data class Aliases( - val components: String, - val utils: String, - val ui: String? = null - ) - } -} diff --git a/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/impl/ISPSolidSource.kt b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/impl/ISPSolidSource.kt deleted file mode 100644 index 2799568..0000000 --- a/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/impl/ISPSolidSource.kt +++ /dev/null @@ -1,384 +0,0 @@ -package com.github.warningimhack3r.intellijshadcnplugin.backend.sources.impl - -import com.github.warningimhack3r.intellijshadcnplugin.backend.helpers.DependencyManager -import com.github.warningimhack3r.intellijshadcnplugin.backend.helpers.FileManager -import com.github.warningimhack3r.intellijshadcnplugin.backend.http.RequestSender -import com.github.warningimhack3r.intellijshadcnplugin.backend.sources.ISPComponent -import com.github.warningimhack3r.intellijshadcnplugin.backend.sources.ISPSource -import com.github.warningimhack3r.intellijshadcnplugin.backend.sources.ISPStyle -import com.intellij.notification.Notification -import com.intellij.notification.NotificationAction -import com.intellij.notification.NotificationType -import com.intellij.notification.Notifications -import com.intellij.openapi.fileTypes.FileTypeManager -import com.intellij.openapi.project.Project -import com.intellij.psi.PsiFileFactory -import com.intellij.util.applyIf -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.json.* -import org.jetbrains.concurrency.runAsync -import java.nio.file.NoSuchFileException - -class ISPSolidSource(private val project: Project): ISPSource { - override var domain = "https://shadcn-solid.vercel.app" - override var language = "Solid" - - private fun fetchComponent(componentName: String): SolidTypes.ComponentWithContents { - val style = getLocalConfig().style - val response = RequestSender.sendRequest("$domain/registry/styles/$style/$componentName.json") - return response.body?.let { Json.decodeFromString(it) } ?: throw Exception("Component not found") - } - - private fun fetchColors(): JsonElement { - val baseColor = getLocalConfig().tailwind.baseColor - val response = RequestSender.sendRequest("$domain/registry/colors/$baseColor.json") - return response.body?.let { Json.parseToJsonElement(it) } ?: throw Exception("Colors not found") - } - - private fun getLocalConfig(): SolidTypes.Config { - return FileManager(project).getFileContentsAtPath("components.json")?.let { - Json.decodeFromString(it) - } ?: throw NoSuchFileException("components.json not found") - } - - private fun resolveAlias(alias: String): String { - if (!alias.startsWith("$") && !alias.startsWith("@")) return alias - val tsConfig = FileManager(project).getFileContentsAtPath("tsconfig.json") ?: throw NoSuchFileException("tsconfig.json not found") - val aliasPath = Json.parseToJsonElement(tsConfig) - .jsonObject["compilerOptions"] - ?.jsonObject?.get("paths") - ?.jsonObject?.get("${alias.substringBefore("/")}/*") - ?.jsonArray?.get(0) - ?.jsonPrimitive?.content ?: throw Exception("Cannot find alias $alias") - return aliasPath.replace(Regex("^\\./"), "") - .replace(Regex("\\*$"), alias.substringAfter("/")) - } - - private fun adaptFileContents(contents: String): String { - fun cleanAlias(alias: String): String { - return if (alias.startsWith("\$")) { - "\\$alias" // fixes Kotlin silently crashing when the replacement starts with $ with a regex - } else alias - } - - val config = getLocalConfig() - val newContents = contents.replace( - Regex("@/registry/[^/]+"), cleanAlias(config.aliases.components) - ).replace( - // Note: this does not prevent additional imports other than "cn" from being replaced, - // but I'm following what the original code does for parity - // (https://github.com/hngngn/shadcn-solid/blob/b808e0ecc9fd4689572d9fc0dfb7af81606a11f2/packages/cli/src/utils/transformers/transform-import.ts#L20-L29). - Regex(".*\\{.*[ ,\n\t]+cn[ ,].*}.*\"@/lib/cn"), config.aliases.utils - ) - - return if (!config.tailwind.cssVariables) { - /** - * Converts CSS variables to Tailwind utility classes. - * @param classes The classes to convert, an unquoted string of space-separated class names. - * @param lightColors The light colors map to use. - * @param darkColors The dark colors map to use. - * @return The converted classes. - */ - fun variablesToUtilities(classes: String, lightColors: Map, darkColors: Map): String { - // Note: this does not include `border` classes at the beginning or end of the string, - // but I'm once again following what the original code does for parity - // (https://github.com/shadcn-ui/ui/blob/fb614ac2921a84b916c56e9091aa0ae8e129c565/packages/cli/src/utils/transformers/transform-css-vars.ts#L142-L145). - val newClasses = classes.replace(" border ", " border border-border ") - - val prefixesToReplace = listOf("bg-", "text-", "border-", "ring-offset-", "ring-") - - /** - * Replaces a class with CSS variables with Tailwind utility classes. - * @param class The class to replace. - * @return The replaced class. - */ - fun replaceClass(`class`: String): String { - val prefix = prefixesToReplace.find { `class`.startsWith(it) } ?: return `class` - val color = `class`.substringAfter(prefix) - val lightColor = lightColors[color] - val darkColor = darkColors[color] - return if (lightColor != null && darkColor != null) { - "$prefix$lightColor dark:$prefix$darkColor" - } else `class` - } - - return newClasses - .split(" ") - .filterNot { it.isEmpty() } - .joinToString(" ") { - val className = it.trim().split(":") - if (className.size == 1) { - replaceClass(className[0]) - } else { - "${className.dropLast(1).joinToString(":")}:${replaceClass(className.last())}" - } - } - } - - fun handleClasses(classes: String): String { - val inlineColors = fetchColors().jsonObject["inlineColors"]?.jsonObject ?: throw Exception("Inline colors not found") - return variablesToUtilities( - classes, - inlineColors.jsonObject["light"]?.jsonObject?.let { lightColors -> - lightColors.keys.associateWith { lightColors[it]?.jsonPrimitive?.content ?: "" } - } ?: emptyMap(), - inlineColors.jsonObject["dark"]?.jsonObject?.let { darkColors -> - darkColors.keys.associateWith { darkColors[it]?.jsonPrimitive?.content ?: "" } - } ?: emptyMap() - ) - } - - Regex("className=(?:(?!>)[^\"'])*[\"']([^>]*)[\"']").replace(newContents) { result -> - // matches any className, and takes everything inside the first quote to the last quote found before the closing `>` - // if no quotes are found before the closing `>`, skips the match - val match = result.groupValues[0] - val group = result.groupValues[1] - match.replace( - group, - // if the group contains a quote, we assume the classes are the last quoted string in the group - if (group.contains("\"")) { - group.substringBeforeLast('"') + "\"" + handleClasses( - group.substringAfterLast('"') - ) - } else if (group.contains("'")) { - group.substringBeforeLast("'") + "'" + handleClasses( - group.substringAfterLast("'") - ) - } else handleClasses(group) - ) - } - } else newContents - } - - override fun fetchAllComponents(): List { - val response = RequestSender.sendRequest("$domain/registry/index.json") - return response.body?.let { - Json.decodeFromString>(it) - }?.map { ISPComponent(it.name) } ?: emptyList() - } - - override fun fetchAllStyles(): List { - val response = RequestSender.sendRequest("$domain/registry/styles/index.json") - return response.body?.let { Json.decodeFromString>(it) } ?: emptyList() - } - - override fun getInstalledComponents(): List { - return FileManager(project).getFileAtPath( - resolveAlias(getLocalConfig().aliases.components) + "/" + SolidTypes.ComponentKind.UI.name.lowercase() - )?.children?.map { it.name }?.sorted() ?: emptyList() - } - - override fun addComponent(componentName: String) { - val installedComponents = getInstalledComponents() - fun getRegistryDependencies(component: SolidTypes.ComponentWithContents): List { - return component.registryDependencies.filter { - !installedComponents.contains(it) - }.map { registryDependency -> - val dependency = fetchComponent(registryDependency) - listOf(dependency, *getRegistryDependencies(dependency).toTypedArray()) - }.flatten() - } - - // Install component - val component = fetchComponent(componentName) - val components = setOf(component, *getRegistryDependencies(fetchComponent(componentName)).toTypedArray()) - val config = getLocalConfig() - components.forEach { downloadedComponent -> - downloadedComponent.files.forEach { file -> - val path = "${resolveAlias(config.aliases.components)}/${component.type.name.lowercase()}/${downloadedComponent.name}" - val psiFile = PsiFileFactory.getInstance(project).createFileFromText( - file.name, - FileTypeManager.getInstance().getFileTypeByExtension(file.name.substringAfterLast('.')), - adaptFileContents(file.content) - ) - FileManager(project).saveFileAtPath(psiFile, path) - } - } - - // Install dependencies - val manager = DependencyManager(project) - val depsToInstall = component.dependencies.filter { dependency -> - !manager.isDependencyInstalled(dependency) - } - if (depsToInstall.isEmpty()) return - val dependenciesList = with(depsToInstall) { - if (size == 1) first() else { - "${dropLast(1).joinToString(", ")} and ${last()}" - } - } - Notifications.Bus.notify( - Notification( - "shadcn/ui", - "Installed ${component.name}", - "${component.name} requires $dependenciesList to be installed.", - NotificationType.INFORMATION - ).apply { - mapOf( - "Install" to DependencyManager.InstallationType.PROD, - "Install as dev" to DependencyManager.InstallationType.DEV - ).forEach { (label, installType) -> - addAction(NotificationAction.createSimple(label) { - runAsync { - manager.installDependencies(depsToInstall, installType) - }.then { - Notifications.Bus.notifyAndHide( - Notification( - "shadcn/ui", - "Installed $dependenciesList", - "Installed $dependenciesList for ${component.name}.", - NotificationType.INFORMATION - ), - project - ) - } - hideBalloon() - }) - } - }, - project - ) - } - - override fun isComponentUpToDate(componentName: String): Boolean { - val config = getLocalConfig() - val remoteComponent = fetchComponent(componentName) - return remoteComponent.files.all { file -> - FileManager(project).getFileContentsAtPath( - "${resolveAlias(config.aliases.components)}/${remoteComponent.type.name.lowercase()}/${remoteComponent.name}/${file.name}" - ) == adaptFileContents(file.content) - } - } - - override fun removeComponent(componentName: String) { - val remoteComponent = fetchComponent(componentName) - FileManager(project).deleteFileAtPath( - "${resolveAlias(getLocalConfig().aliases.components)}/${remoteComponent.type.name.lowercase()}/${remoteComponent.name}" - ) - } -} - -object SolidTypes { - /** - * The kind of component. - */ - @Suppress("PROVIDED_RUNTIME_TOO_LOW") - @Serializable - enum class ComponentKind { - @SerialName("components:ui") - UI, - @SerialName("components:component") - COMPONENT, - @SerialName("components:example") - EXAMPLE - } - - /** - * A shadcn-svelte component in the registry. - * @param name The name of the component. - * @param dependencies The npm dependencies of the component. - * @param registryDependencies The other components that this component depends on. - * @param files The files that make up the component. - * @param type The kind of component. - */ - @Suppress("PROVIDED_RUNTIME_TOO_LOW") - @Serializable - data class Component( - val name: String, - val dependencies: List, - val registryDependencies: List, - val files: List, - val type: ComponentKind - ) - - /** - * A shadcn-svelte component in the registry. - * @param name The name of the component. - * @param dependencies The npm dependencies of the component. - * @param registryDependencies The other components that this component depends on. - * @param files The files that make up the component. - * @param type The kind of component. - */ - @Suppress("PROVIDED_RUNTIME_TOO_LOW") - @Serializable - data class ComponentWithContents( - val name: String, - val dependencies: List, - val registryDependencies: List, - val files: List, - val type: ComponentKind - ) { - /** - * A component's file. - * @param name The name of the file. - * @param content The contents of the file. - */ - @Suppress("PROVIDED_RUNTIME_TOO_LOW") - @Serializable - data class File( - val name: String, - val content: String - ) - } - - /** - * A shadcn-svelte locally installed components.json file. - * @param `$schema` The schema URL for the file. - * @param style The library style used. - * @param tailwind The Tailwind configuration. - * @param rsc Whether to support React Server Components. - * @param tsx Whether to use TypeScript over JavaScript. - * @param aliases The aliases for the components and utils directories. - */ - @Suppress("PROVIDED_RUNTIME_TOO_LOW", "kotlin:S117") - @Serializable - data class Config( - val `$schema`: String, - val style: Styles, - val tailwind: Tailwind, - val aliases: Aliases - ) { - /** - * The library style used. - */ - @Suppress("PROVIDED_RUNTIME_TOO_LOW") - @Serializable - enum class Styles { - @SerialName("default") - DEFAULT, - @SerialName("new-york") - NEW_YORK - } - - /** - * The Tailwind configuration. - * @param config The relative path to the Tailwind config file. - * @param css The relative path of the Tailwind CSS file. - * @param baseColor The library's base color. - * @param cssVariables Whether to use CSS variables or utility classes. - * @param prefix The prefix to use for utility classes. - */ - @Suppress("PROVIDED_RUNTIME_TOO_LOW") - @Serializable - data class Tailwind( - val config: String, - val css: String, - val baseColor: String, - val cssVariables: Boolean - ) - - /** - * The aliases for the components and utils directories. - * @param components The alias for the components' directory. - * @param utils The alias for the utils directory. - * @param ui The alias for UI components. - */ - @Suppress("PROVIDED_RUNTIME_TOO_LOW") - @Serializable - data class Aliases( - val components: String, - val utils: String - ) - } -} diff --git a/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/impl/ISPSvelteSource.kt b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/impl/ISPSvelteSource.kt deleted file mode 100644 index a4cf9a5..0000000 --- a/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/impl/ISPSvelteSource.kt +++ /dev/null @@ -1,294 +0,0 @@ -package com.github.warningimhack3r.intellijshadcnplugin.backend.sources.impl - -import com.github.warningimhack3r.intellijshadcnplugin.backend.helpers.DependencyManager -import com.github.warningimhack3r.intellijshadcnplugin.backend.helpers.FileManager -import com.github.warningimhack3r.intellijshadcnplugin.backend.helpers.ShellRunner -import com.github.warningimhack3r.intellijshadcnplugin.backend.http.RequestSender -import com.github.warningimhack3r.intellijshadcnplugin.backend.sources.ISPComponent -import com.github.warningimhack3r.intellijshadcnplugin.backend.sources.ISPSource -import com.github.warningimhack3r.intellijshadcnplugin.backend.sources.ISPStyle -import com.intellij.notification.Notification -import com.intellij.notification.NotificationAction -import com.intellij.notification.NotificationType -import com.intellij.notification.Notifications -import com.intellij.openapi.fileTypes.FileTypeManager -import com.intellij.openapi.project.Project -import com.intellij.psi.PsiFileFactory -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import org.jetbrains.concurrency.runAsync -import java.nio.file.NoSuchFileException - -class ISPSvelteSource(private val project: Project): ISPSource { - override var domain = "https://www.shadcn-svelte.com" - override var language = "Svelte" - - private fun fetchComponent(componentName: String): SvelteTypes.ComponentWithContents { - val style = getLocalConfig().style - val response = RequestSender.sendRequest("$domain/registry/styles/$style/$componentName.json") - return response.body?.let { Json.decodeFromString(it) } ?: throw Exception("Component not found") - } - - private fun getLocalConfig(): SvelteTypes.Config { - return FileManager(project).getFileContentsAtPath("components.json")?.let { - Json.decodeFromString(it) - } ?: throw NoSuchFileException("components.json not found") - } - - private fun resolveAlias(alias: String): String { - if (!alias.startsWith("$") && !alias.startsWith("@")) return alias - var tsConfig = FileManager(project).getFileContentsAtPath(".svelte-kit/tsconfig.json") - if (tsConfig == null) { - ShellRunner(project).execute(arrayOf("npx", "svelte-kit", "sync")) - Thread.sleep(250) // wait for the sync to create the files - tsConfig = FileManager(project).getFileContentsAtPath(".svelte-kit/tsconfig.json") ?: throw NoSuchFileException("Cannot get or generate .svelte-kit/tsconfig.json") - } - val aliasPath = Json.parseToJsonElement(tsConfig) - .jsonObject["compilerOptions"] - ?.jsonObject?.get("paths") - ?.jsonObject?.get(alias.substringBefore("/")) - ?.jsonArray?.get(0) - ?.jsonPrimitive?.content ?: throw Exception("Cannot find alias $alias") - return "${aliasPath.replace(Regex("^\\./"), "")}/${alias.substringAfter("/")}" - } - - private fun replaceImports(contents: String): String { - val config = getLocalConfig() - return contents.replace( - Regex("@/registry/[^/]+"), if (config.aliases.components.startsWith("\$")) { - "\\${config.aliases.components}" // fixes Kotlin silently crashing when the replacement starts with $ with a regex - } else config.aliases.components - ).replace( - "\$lib/utils", config.aliases.utils - ) - } - - override fun fetchAllComponents(): List { - val response = RequestSender.sendRequest("$domain/registry/index.json") - return response.body?.let { - Json.decodeFromString>(it) - }?.map { ISPComponent(it.name) } ?: emptyList() - } - - override fun fetchAllStyles(): List { - val response = RequestSender.sendRequest("$domain/registry/styles/index.json") - return response.body?.let { Json.decodeFromString>(it) } ?: emptyList() - } - - override fun getInstalledComponents(): List { - return FileManager(project).getFileAtPath( - resolveAlias(getLocalConfig().aliases.components) + "/" + SvelteTypes.ComponentKind.UI.name.lowercase() - )?.children?.map { it.name }?.sorted() ?: emptyList() - } - - override fun addComponent(componentName: String) { - val installedComponents = getInstalledComponents() - fun getRegistryDependencies(component: SvelteTypes.ComponentWithContents): List { - return component.registryDependencies.filter { - !installedComponents.contains(it) - }.map { registryDependency -> - val dependency = fetchComponent(registryDependency) - listOf(dependency, *getRegistryDependencies(dependency).toTypedArray()) - }.flatten() - } - - // Install component - val component = fetchComponent(componentName) - val components = setOf(component, *getRegistryDependencies(fetchComponent(componentName)).toTypedArray()) - val config = getLocalConfig() - components.forEach { downloadedComponent -> - downloadedComponent.files.forEach { file -> - val path = "${resolveAlias(config.aliases.components)}/${component.type.name.lowercase()}/${downloadedComponent.name}" - val psiFile = PsiFileFactory.getInstance(project).createFileFromText( - file.name, - FileTypeManager.getInstance().getFileTypeByExtension(file.name.substringAfterLast('.')), - replaceImports(file.content) - ) - FileManager(project).saveFileAtPath(psiFile, path) - } - } - - // Install dependencies - val manager = DependencyManager(project) - val depsToInstall = component.dependencies.filter { dependency -> - !manager.isDependencyInstalled(dependency) - } - if (depsToInstall.isEmpty()) return - val dependenciesList = with(depsToInstall) { - if (size == 1) first() else { - "${dropLast(1).joinToString(", ")} and ${last()}" - } - } - Notifications.Bus.notify( - Notification( - "shadcn/ui", - "Installed ${component.name}", - "${component.name} requires $dependenciesList to be installed.", - NotificationType.INFORMATION - ).apply { - mapOf( - "Install" to DependencyManager.InstallationType.PROD, - "Install as dev" to DependencyManager.InstallationType.DEV - ).forEach { (label, installType) -> - addAction(NotificationAction.createSimple(label) { - runAsync { - manager.installDependencies(depsToInstall, installType) - }.then { - Notifications.Bus.notifyAndHide( - Notification( - "shadcn/ui", - "Installed $dependenciesList", - "Installed $dependenciesList for ${component.name}.", - NotificationType.INFORMATION - ), - project - ) - } - hideBalloon() - }) - } - }, - project - ) - } - - override fun isComponentUpToDate(componentName: String): Boolean { - val config = getLocalConfig() - val remoteComponent = fetchComponent(componentName) - return remoteComponent.files.all { file -> - FileManager(project).getFileContentsAtPath( - "${resolveAlias(config.aliases.components)}/${remoteComponent.type.name.lowercase()}/${remoteComponent.name}/${file.name}" - ) == replaceImports(file.content) - } - } - - override fun removeComponent(componentName: String) { - val remoteComponent = fetchComponent(componentName) - FileManager(project).deleteFileAtPath( - "${resolveAlias(getLocalConfig().aliases.components)}/${remoteComponent.type.name.lowercase()}/${remoteComponent.name}" - ) - } -} - -object SvelteTypes { - /** - * The kind of component. - */ - @Suppress("PROVIDED_RUNTIME_TOO_LOW") - @Serializable - enum class ComponentKind { - @SerialName("components:ui") - UI, - @SerialName("components:component") - COMPONENT, - @SerialName("components:example") - EXAMPLE - } - - /** - * A shadcn-svelte component in the registry. - * @param name The name of the component. - * @param dependencies The npm dependencies of the component. - * @param registryDependencies The other components that this component depends on. - * @param files The files that make up the component. - * @param type The kind of component. - */ - @Suppress("PROVIDED_RUNTIME_TOO_LOW") // https://github.com/Kotlin/kotlinx.serialization/issues/993#issuecomment-984742051 - @Serializable - data class Component( - val name: String, - val dependencies: List, - val registryDependencies: List, - val files: List, - val type: ComponentKind - ) - - /** - * A shadcn-svelte component in the registry. - * @param name The name of the component. - * @param dependencies The npm dependencies of the component. - * @param registryDependencies The other components that this component depends on. - * @param files The files that make up the component. - * @param type The kind of component. - */ - @Suppress("PROVIDED_RUNTIME_TOO_LOW") - @Serializable - data class ComponentWithContents( - val name: String, - val dependencies: List, - val registryDependencies: List, - val files: List, - val type: ComponentKind - ) { - /** - * A component's file. - * @param name The name of the file. - * @param content The contents of the file. - */ - @Suppress("PROVIDED_RUNTIME_TOO_LOW") - @Serializable - data class File( - val name: String, - val content: String - ) - } - - /** - * A shadcn-svelte locally installed components.json file. - * @param `$schema` The schema URL for the file. - * @param style The library style installed. - * @param tailwind The Tailwind configuration. - * @param aliases The aliases for the components and utils directories. - */ - @Suppress("PROVIDED_RUNTIME_TOO_LOW", "kotlin:S117") - @Serializable - data class Config( - val `$schema`: String, - val style: Styles, - val tailwind: Tailwind, - val aliases: Aliases - ) { - /** - * The library style used. - */ - @Suppress("PROVIDED_RUNTIME_TOO_LOW") - @Serializable - enum class Styles { - @SerialName("default") - DEFAULT, - @SerialName("new-york") - NEW_YORK - } - - /** - * The Tailwind configuration. - * @param config The relative path to the Tailwind config file. - * @param css The relative path of the Tailwind CSS file. - * @param baseColor The library's base color. - */ - @Suppress("PROVIDED_RUNTIME_TOO_LOW") - @Serializable - data class Tailwind( - val config: String, - val css: String, - val baseColor: String - ) - - /** - * The aliases for the components and utils directories. - * @param components The alias for the components directory. - * @param utils The alias for the utils directory. - */ - @Suppress("PROVIDED_RUNTIME_TOO_LOW") - @Serializable - data class Aliases( - val components: String, - val utils: String - ) - } -} diff --git a/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/impl/ISPVueSource.kt b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/impl/ISPVueSource.kt deleted file mode 100644 index f4946a7..0000000 --- a/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/impl/ISPVueSource.kt +++ /dev/null @@ -1,409 +0,0 @@ -package com.github.warningimhack3r.intellijshadcnplugin.backend.sources.impl - -import com.github.warningimhack3r.intellijshadcnplugin.backend.helpers.DependencyManager -import com.github.warningimhack3r.intellijshadcnplugin.backend.helpers.FileManager -import com.github.warningimhack3r.intellijshadcnplugin.backend.helpers.ShellRunner -import com.github.warningimhack3r.intellijshadcnplugin.backend.http.RequestSender -import com.github.warningimhack3r.intellijshadcnplugin.backend.sources.ISPComponent -import com.github.warningimhack3r.intellijshadcnplugin.backend.sources.ISPSource -import com.github.warningimhack3r.intellijshadcnplugin.backend.sources.ISPStyle -import com.intellij.notification.Notification -import com.intellij.notification.NotificationAction -import com.intellij.notification.NotificationType -import com.intellij.notification.Notifications -import com.intellij.openapi.fileTypes.FileTypeManager -import com.intellij.openapi.project.Project -import com.intellij.psi.PsiFileFactory -import com.intellij.util.applyIf -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.json.* -import org.jetbrains.concurrency.runAsync -import java.nio.file.NoSuchFileException - -class ISPVueSource(private val project: Project): ISPSource { - override var domain = "https://www.shadcn-vue.com" - override var language = "Vue" - - private fun fetchComponent(componentName: String): VueTypes.ComponentWithContents { - val style = getLocalConfig().style - val response = RequestSender.sendRequest("$domain/registry/styles/$style/$componentName.json") - return response.body?.let { Json.decodeFromString(it) } ?: throw Exception("Component not found") - } - - private fun fetchColors(): JsonElement { - val baseColor = getLocalConfig().tailwind.baseColor - val response = RequestSender.sendRequest("$domain/registry/colors/$baseColor.json") - return response.body?.let { Json.parseToJsonElement(it) } ?: throw Exception("Colors not found") - } - - private fun getLocalConfig(): VueTypes.Config { - return FileManager(project).getFileContentsAtPath("components.json")?.let { - Json.decodeFromString(it) - } ?: throw NoSuchFileException("components.json not found") - } - - private fun getConfigFileName(fileName: String): String { - return if (!getLocalConfig().typescript) { - fileName.replace( - Regex("\\.ts$"), - ".js" - ) - } else fileName - } - - private fun resolveAlias(alias: String): String { - if (!alias.startsWith("$") && !alias.startsWith("@")) return alias - val tsConfig = FileManager(project).getFileContentsAtPath("tsconfig.json") ?: throw NoSuchFileException("tsconfig.json not found") - val aliasPath = Json.parseToJsonElement(tsConfig) - .jsonObject["compilerOptions"] - ?.jsonObject?.get("paths") - ?.jsonObject?.get("${alias.substringBefore("/")}/*") - ?.jsonArray?.get(0) - ?.jsonPrimitive?.content ?: throw Exception("Cannot find alias $alias") - return aliasPath.replace(Regex("^\\./"), "") - .replace(Regex("\\*$"), alias.substringAfter("/")) - } - - private fun adaptFileContents(contents: String): String { - fun cleanAlias(alias: String): String { - return if (alias.startsWith("\$")) { - "\\$alias" // fixes Kotlin silently crashing when the replacement starts with $ with a regex - } else alias - } - - val config = getLocalConfig() - val newContents = contents.replace( - Regex("@/lib/registry/[^/]+"), cleanAlias(config.aliases.components) - ).replace( - // Note: this does not prevent additional imports other than "cn" from being replaced, - // but I'm once again following what the original code does for parity - // (https://github.com/radix-vue/shadcn-vue/blob/9d9a6f929ce0f281b4af36161af80ed2bbdc4a16/packages/cli/src/utils/transformers/transform-import.ts#L19-L29). - Regex(".*\\{.*[ ,\n\t]+cn[ ,].*}.*\"@/lib/utils"), - cleanAlias(config.aliases.utils) - ).applyIf(!config.typescript) { - // TODO: detype Vue file - this - } - return if (!config.tailwind.cssVariables) { - /** - * Converts CSS variables to Tailwind utility classes. - * @param classes The classes to convert, an unquoted string of space-separated class names. - * @param lightColors The light colors map to use. - * @param darkColors The dark colors map to use. - * @return The converted classes. - */ - fun variablesToUtilities(classes: String, lightColors: Map, darkColors: Map): String { - // Note: this does not include `border` classes at the beginning or end of the string, - // but I'm once again following what the original code does for parity - // (https://github.com/shadcn-ui/ui/blob/fb614ac2921a84b916c56e9091aa0ae8e129c565/packages/cli/src/utils/transformers/transform-css-vars.ts#L142-L145). - val newClasses = classes.replace(" border ", " border border-border ") - - val prefixesToReplace = listOf("bg-", "text-", "border-", "ring-offset-", "ring-") - - /** - * Replaces a class with CSS variables with Tailwind utility classes. - * @param class The class to replace. - * @return The replaced class. - */ - fun replaceClass(`class`: String): String { - val prefix = prefixesToReplace.find { `class`.startsWith(it) } ?: return `class` - val color = `class`.substringAfter(prefix) - val lightColor = lightColors[color] - val darkColor = darkColors[color] - return if (lightColor != null && darkColor != null) { - "$prefix$lightColor dark:$prefix$darkColor" - } else `class` - } - - return newClasses - .split(" ") - .filterNot { it.isEmpty() } - .joinToString(" ") { - val className = it.trim().split(":") - if (className.size == 1) { - replaceClass(className[0]) - } else { - "${className.dropLast(1).joinToString(":")}:${replaceClass(className.last())}" - } - } - } - - fun handleClasses(classes: String): String { - val inlineColors = fetchColors().jsonObject["inlineColors"]?.jsonObject ?: throw Exception("Inline colors not found") - return variablesToUtilities( - classes, - inlineColors.jsonObject["light"]?.jsonObject?.let { lightColors -> - lightColors.keys.associateWith { lightColors[it]?.jsonPrimitive?.content ?: "" } - } ?: emptyMap(), - inlineColors.jsonObject["dark"]?.jsonObject?.let { darkColors -> - darkColors.keys.associateWith { darkColors[it]?.jsonPrimitive?.content ?: "" } - } ?: emptyMap() - ) - } - - val notTemplateClasses = Regex("[^:]class=(?:(?!>)[^\"'])*[\"']([^\"']*)[\"']").replace(newContents) { result -> - result.groupValues[0].replace( - result.groupValues[1], - handleClasses(result.groupValues[1]) - ) - } - // Double quoted templates - Regex(":class=(?:(?!>)[^\"])*\"([^\"]*)\"").replace(notTemplateClasses) { result -> - val group = result.groupValues[1] - result.groupValues[0].replace( - group, - handleClasses(group - .replace("\n", " ") - .split(", ") - .map { it.trim() } - .last { it.startsWith("'") || it.endsWith("'") }) - ) - } - } else newContents - } - - override fun fetchAllComponents(): List { - val response = RequestSender.sendRequest("$domain/registry/index.json") - return response.body?.let { - Json.decodeFromString>(it) - }?.map { ISPComponent(it.name) } ?: emptyList() - } - - override fun fetchAllStyles(): List { - val response = RequestSender.sendRequest("$domain/registry/styles/index.json") - return response.body?.let { Json.decodeFromString>(it) } ?: emptyList() - } - - override fun getInstalledComponents(): List { - return FileManager(project).getFileAtPath( - resolveAlias(getLocalConfig().aliases.components) + "/" + VueTypes.ComponentKind.UI.name.lowercase() - )?.children?.map { it.name }?.sorted() ?: emptyList() - } - - override fun addComponent(componentName: String) { - val installedComponents = getInstalledComponents() - fun getRegistryDependencies(component: VueTypes.ComponentWithContents): List { - return component.registryDependencies.filter { - !installedComponents.contains(it) - }.map { registryDependency -> - val dependency = fetchComponent(registryDependency) - listOf(dependency, *getRegistryDependencies(dependency).toTypedArray()) - }.flatten() - } - - // Install component - val component = fetchComponent(componentName) - val components = setOf(component, *getRegistryDependencies(fetchComponent(componentName)).toTypedArray()) - val config = getLocalConfig() - components.forEach { downloadedComponent -> - downloadedComponent.files.forEach { file -> - val path = "${resolveAlias(config.aliases.components)}/${component.type.name.lowercase()}/${downloadedComponent.name}" - val psiFile = PsiFileFactory.getInstance(project).createFileFromText( - getConfigFileName(file.name), - FileTypeManager.getInstance().getFileTypeByExtension(getConfigFileName(file.name).substringAfterLast('.')), - adaptFileContents(file.content) - ) - FileManager(project).saveFileAtPath(psiFile, path) - } - } - - // Install dependencies - val manager = DependencyManager(project) - val depsToInstall = component.dependencies.filter { dependency -> - !manager.isDependencyInstalled(dependency) - } - if (depsToInstall.isEmpty()) return - val dependenciesList = with(depsToInstall) { - if (size == 1) first() else { - "${dropLast(1).joinToString(", ")} and ${last()}" - } - } - Notifications.Bus.notify( - Notification( - "shadcn/ui", - "Installed ${component.name}", - "${component.name} requires $dependenciesList to be installed.", - NotificationType.INFORMATION - ).apply { - mapOf( - "Install" to DependencyManager.InstallationType.PROD, - "Install as dev" to DependencyManager.InstallationType.DEV - ).forEach { (label, installType) -> - addAction(NotificationAction.createSimple(label) { - runAsync { - manager.installDependencies(depsToInstall, installType) - }.then { - Notifications.Bus.notifyAndHide( - Notification( - "shadcn/ui", - "Installed $dependenciesList", - "Installed $dependenciesList for ${component.name}.", - NotificationType.INFORMATION - ), - project - ) - } - hideBalloon() - }) - } - }, - project - ) - } - - override fun isComponentUpToDate(componentName: String): Boolean { - val config = getLocalConfig() - val remoteComponent = fetchComponent(componentName) - return remoteComponent.files.all { file -> - FileManager(project).getFileContentsAtPath( - "${resolveAlias(config.aliases.components)}/${remoteComponent.type.name.lowercase()}/${remoteComponent.name}/${getConfigFileName(file.name)}" - ) == adaptFileContents(file.content) - } - } - - override fun removeComponent(componentName: String) { - val remoteComponent = fetchComponent(componentName) - FileManager(project).deleteFileAtPath( - "${resolveAlias(getLocalConfig().aliases.components)}/${remoteComponent.type.name.lowercase()}/${remoteComponent.name}" - ) - } -} - -object VueTypes { - /** - * The kind of component. - */ - @Suppress("PROVIDED_RUNTIME_TOO_LOW") - @Serializable - enum class ComponentKind { - @SerialName("components:ui") - UI, - @SerialName("components:component") - COMPONENT, - @SerialName("components:example") - EXAMPLE - } - - /** - * A shadcn-svelte component in the registry. - * @param name The name of the component. - * @param dependencies The npm dependencies of the component. - * @param registryDependencies The other components that this component depends on. - * @param files The files that make up the component. - * @param type The kind of component. - */ - @Suppress("PROVIDED_RUNTIME_TOO_LOW") // https://github.com/Kotlin/kotlinx.serialization/issues/993#issuecomment-984742051 - @Serializable - data class Component( - val name: String, - val dependencies: List, - val registryDependencies: List, - val files: List, - val type: ComponentKind - ) - - /** - * A shadcn-svelte component in the registry. - * @param name The name of the component. - * @param dependencies The npm dependencies of the component. - * @param registryDependencies The other components that this component depends on. - * @param files The files that make up the component. - * @param type The kind of component. - */ - @Suppress("PROVIDED_RUNTIME_TOO_LOW") - @Serializable - data class ComponentWithContents( - val name: String, - val dependencies: List, - val registryDependencies: List, - val files: List, - val type: ComponentKind - ) { - /** - * A component's file. - * @param name The name of the file. - * @param content The contents of the file. - */ - @Suppress("PROVIDED_RUNTIME_TOO_LOW") - @Serializable - data class File( - val name: String, - val content: String - ) - } - - /** - * A shadcn-svelte locally installed components.json file. - * @param `$schema` The schema URL for the file. - * @param style The library style installed. - * @param tailwind The Tailwind configuration. - * @param aliases The aliases for the components and utils directories. - */ - @Suppress("PROVIDED_RUNTIME_TOO_LOW", "kotlin:S117") - @Serializable - data class Config( - val `$schema`: String, - val style: Styles, - val typescript: Boolean = true, - val tailwind: Tailwind, - val framework: Framework = Framework.VITE, - val aliases: Aliases - ) { - /** - * The library style used. - */ - @Suppress("PROVIDED_RUNTIME_TOO_LOW") - @Serializable - enum class Styles { - @SerialName("default") - DEFAULT, - @SerialName("new-york") - NEW_YORK - } - - /** - * The Tailwind configuration. - * @param config The relative path to the Tailwind config file. - * @param css The relative path of the Tailwind CSS file. - * @param baseColor The library's base color. - */ - @Suppress("PROVIDED_RUNTIME_TOO_LOW") - @Serializable - data class Tailwind( - val config: String, - val css: String, - val baseColor: String, - val cssVariables: Boolean = true - ) - - /** - * The framework used. - */ - @Suppress("PROVIDED_RUNTIME_TOO_LOW") - @Serializable - enum class Framework { - @SerialName("vite") - VITE, - @SerialName("nuxt") - NUXT, - @SerialName("laravel") - LARAVEL, - @SerialName("astro") - ASTRO - } - - /** - * The aliases for the components and utils directories. - * @param components The alias for the components directory. - * @param utils The alias for the utils directory. - */ - @Suppress("PROVIDED_RUNTIME_TOO_LOW") - @Serializable - data class Aliases( - val components: String, - val utils: String - ) - } -} diff --git a/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/impl/ReactSource.kt b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/impl/ReactSource.kt new file mode 100644 index 0000000..383cd35 --- /dev/null +++ b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/impl/ReactSource.kt @@ -0,0 +1,163 @@ +package com.github.warningimhack3r.intellijshadcnplugin.backend.sources.impl + +import com.github.warningimhack3r.intellijshadcnplugin.backend.helpers.FileManager +import com.github.warningimhack3r.intellijshadcnplugin.backend.sources.Source +import com.github.warningimhack3r.intellijshadcnplugin.backend.sources.config.ReactConfig +import com.intellij.openapi.project.Project +import com.intellij.util.applyIf +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.nio.file.NoSuchFileException + +class ReactSource(project: Project) : Source(project, ReactConfig.serializer()) { + override var framework = "React" + + override fun resolveAlias(alias: String): String { + if (!alias.startsWith("$") && !alias.startsWith("@")) return alias + val tsConfig = FileManager(project).getFileContentsAtPath("tsconfig.json") ?: throw NoSuchFileException("tsconfig.json not found") + val aliasPath = Json.parseToJsonElement(tsConfig) + .jsonObject["compilerOptions"] + ?.jsonObject?.get("paths") + ?.jsonObject?.get("${alias.substringBefore("/")}/*") + ?.jsonArray?.get(0) + ?.jsonPrimitive?.content ?: throw Exception("Cannot find alias $alias") // TODO: fallback to vite.config.(j|t)s for all + return aliasPath.replace(Regex("^\\./"), "") + .replace(Regex("\\*$"), alias.substringAfter("/")) + } + + override fun adaptFileExtensionToConfig(extension: String): String { + return if (!getLocalConfig().tsx) { + extension + .replace(Regex("\\.tsx$"), ".ts") + .replace(Regex("\\.jsx$"), ".js") + } else extension + } + + override fun adaptFileToConfig(contents: String): String { + val config = getLocalConfig() + // Note: this condition does not replace UI paths (= $components/$ui) by the components path + // if the UI alias is not set. + // For me, this is a bug, but I'm following what the original code does for parity + // (https://github.com/shadcn-ui/ui/blob/fb614ac2921a84b916c56e9091aa0ae8e129c565/packages/cli/src/utils/transformers/transform-import.ts#L10-L23). + var newContents = if (config.aliases.ui != null) { + contents.replace( + Regex("@/registry/[^/]+/ui"), cleanAlias(config.aliases.ui) + ) + } else contents.replace( + Regex("@/registry/[^/]+"), cleanAlias(config.aliases.components) + ) + newContents = newContents.replace( + // Note: this does not prevent additional imports other than "cn" from being replaced, + // but I'm once again following what the original code does for parity + // (https://github.com/shadcn-ui/ui/blob/fb614ac2921a84b916c56e9091aa0ae8e129c565/packages/cli/src/utils/transformers/transform-import.ts#L25-L35). + Regex(".*\\{.*[ ,\n\t]+cn[ ,].*}.*\"@/lib/utils"), config.aliases.utils + ).applyIf(config.rsc) { + replace( + Regex("\"use client\";*\n"), "" + ) + } + + /** + * Prepends `tw-` to all Tailwind classes. + * @param classes The classes to prefix, an unquoted string of space-separated class names. + * @param prefix The prefix to add to each class name. + * @return The prefixed classes. + */ + fun prefixClasses(classes: String, prefix: String): String = classes + .split(" ") + .filterNot { it.isEmpty() } + .joinToString(" ") { + val className = it.trim().split(":") + if (className.size == 1) { + "$prefix${className[0]}" + } else { + "${className.dropLast(1).joinToString(":")}:$prefix${className.last()}" + } + } + + /** + * Converts CSS variables to Tailwind utility classes. + * @param classes The classes to convert, an unquoted string of space-separated class names. + * @param lightColors The light colors map to use. + * @param darkColors The dark colors map to use. + * @return The converted classes. + */ + fun variablesToUtilities(classes: String, lightColors: Map, darkColors: Map): String { + // Note: this does not include `border` classes at the beginning or end of the string, + // but I'm once again following what the original code does for parity + // (https://github.com/shadcn-ui/ui/blob/fb614ac2921a84b916c56e9091aa0ae8e129c565/packages/cli/src/utils/transformers/transform-css-vars.ts#L142-L145). + val newClasses = classes.replace(" border ", " border border-border ") + + val prefixesToReplace = listOf("bg-", "text-", "border-", "ring-offset-", "ring-") + + /** + * Replaces a class with CSS variables with Tailwind utility classes. + * @param class The class to replace. + * @return The replaced class. + */ + fun replaceClass(`class`: String): String { + val prefix = prefixesToReplace.find { `class`.startsWith(it) } ?: return `class` + val color = `class`.substringAfter(prefix) + val lightColor = lightColors[color] + val darkColor = darkColors[color] + return if (lightColor != null && darkColor != null) { + "$prefix$lightColor dark:$prefix$darkColor" + } else `class` + } + + return newClasses + .split(" ") + .filterNot { it.isEmpty() } + .joinToString(" ") { + val className = it.trim().split(":") + if (className.size == 1) { + replaceClass(className[0]) + } else { + "${className.dropLast(1).joinToString(":")}:${replaceClass(className.last())}" + } + } + } + + fun handleClasses(classes: String): String { + var newClasses = classes + if (!config.tailwind.cssVariables) { + val inlineColors = fetchColors().jsonObject["inlineColors"]?.jsonObject ?: throw Exception("Inline colors not found") + newClasses = variablesToUtilities( + newClasses, + inlineColors.jsonObject["light"]?.jsonObject?.let { lightColors -> + lightColors.keys.associateWith { lightColors[it]?.jsonPrimitive?.content ?: "" } + } ?: emptyMap(), + inlineColors.jsonObject["dark"]?.jsonObject?.let { darkColors -> + darkColors.keys.associateWith { darkColors[it]?.jsonPrimitive?.content ?: "" } + } ?: emptyMap() + ) + } + if (config.tailwind.prefix.isNotEmpty()) { + newClasses = prefixClasses(newClasses, config.tailwind.prefix) + } + return newClasses + } + + return Regex("className=(?:(?!>)[^\"'])*[\"']([^>]*)[\"']").replace(newContents) { result -> + // matches any className, and takes everything inside the first quote to the last quote found before the closing `>` + // if no quotes are found before the closing `>`, skips the match + val match = result.groupValues[0] + val group = result.groupValues[1] + match.replace( + group, + // if the group contains a quote, we assume the classes are the last quoted string in the group + if (group.contains("\"")) { + group.substringBeforeLast('"') + "\"" + handleClasses( + group.substringAfterLast('"') + ) + } else if (group.contains("'")) { + group.substringBeforeLast("'") + "'" + handleClasses( + group.substringAfterLast("'") + ) + } else handleClasses(group) + ) + } + } +} diff --git a/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/impl/SolidSource.kt b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/impl/SolidSource.kt new file mode 100644 index 0000000..1d045ea --- /dev/null +++ b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/impl/SolidSource.kt @@ -0,0 +1,124 @@ +package com.github.warningimhack3r.intellijshadcnplugin.backend.sources.impl + +import com.github.warningimhack3r.intellijshadcnplugin.backend.helpers.FileManager +import com.github.warningimhack3r.intellijshadcnplugin.backend.sources.Source +import com.github.warningimhack3r.intellijshadcnplugin.backend.sources.config.SolidConfig +import com.intellij.openapi.project.Project +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.nio.file.NoSuchFileException + +class SolidSource(project: Project) : Source(project, SolidConfig.serializer()) { + override var framework = "Solid" + + override fun resolveAlias(alias: String): String { + if (!alias.startsWith("$") && !alias.startsWith("@")) return alias + val tsConfig = FileManager(project).getFileContentsAtPath("tsconfig.json") ?: throw NoSuchFileException("tsconfig.json not found") + val aliasPath = Json.parseToJsonElement(tsConfig) + .jsonObject["compilerOptions"] + ?.jsonObject?.get("paths") + ?.jsonObject?.get("${alias.substringBefore("/")}/*") + ?.jsonArray?.get(0) + ?.jsonPrimitive?.content ?: throw Exception("Cannot find alias $alias") + return aliasPath.replace(Regex("^\\./"), "") + .replace(Regex("\\*$"), alias.substringAfter("/")) + } + + override fun adaptFileToConfig(contents: String): String { + fun cleanAlias(alias: String): String { + return if (alias.startsWith("\$")) { + "\\$alias" // fixes Kotlin silently crashing when the replacement starts with $ with a regex + } else alias + } + + val config = getLocalConfig() + val newContents = contents.replace( + Regex("@/registry/[^/]+"), cleanAlias(config.aliases.components) + ).replace( + // Note: this does not prevent additional imports other than "cn" from being replaced, + // but I'm following what the original code does for parity + // (https://github.com/hngngn/shadcn-solid/blob/b808e0ecc9fd4689572d9fc0dfb7af81606a11f2/packages/cli/src/utils/transformers/transform-import.ts#L20-L29). + Regex(".*\\{.*[ ,\n\t]+cn[ ,].*}.*\"@/lib/cn"), config.aliases.utils + ) + + return if (!config.tailwind.cssVariables) { + /** + * Converts CSS variables to Tailwind utility classes. + * @param classes The classes to convert, an unquoted string of space-separated class names. + * @param lightColors The light colors map to use. + * @param darkColors The dark colors map to use. + * @return The converted classes. + */ + fun variablesToUtilities(classes: String, lightColors: Map, darkColors: Map): String { + // Note: this does not include `border` classes at the beginning or end of the string, + // but I'm once again following what the original code does for parity + // (https://github.com/shadcn-ui/ui/blob/fb614ac2921a84b916c56e9091aa0ae8e129c565/packages/cli/src/utils/transformers/transform-css-vars.ts#L142-L145). + val newClasses = classes.replace(" border ", " border border-border ") + + val prefixesToReplace = listOf("bg-", "text-", "border-", "ring-offset-", "ring-") + + /** + * Replaces a class with CSS variables with Tailwind utility classes. + * @param class The class to replace. + * @return The replaced class. + */ + fun replaceClass(`class`: String): String { + val prefix = prefixesToReplace.find { `class`.startsWith(it) } ?: return `class` + val color = `class`.substringAfter(prefix) + val lightColor = lightColors[color] + val darkColor = darkColors[color] + return if (lightColor != null && darkColor != null) { + "$prefix$lightColor dark:$prefix$darkColor" + } else `class` + } + + return newClasses + .split(" ") + .filterNot { it.isEmpty() } + .joinToString(" ") { + val className = it.trim().split(":") + if (className.size == 1) { + replaceClass(className[0]) + } else { + "${className.dropLast(1).joinToString(":")}:${replaceClass(className.last())}" + } + } + } + + fun handleClasses(classes: String): String { + val inlineColors = fetchColors().jsonObject["inlineColors"]?.jsonObject ?: throw Exception("Inline colors not found") + return variablesToUtilities( + classes, + inlineColors.jsonObject["light"]?.jsonObject?.let { lightColors -> + lightColors.keys.associateWith { lightColors[it]?.jsonPrimitive?.content ?: "" } + } ?: emptyMap(), + inlineColors.jsonObject["dark"]?.jsonObject?.let { darkColors -> + darkColors.keys.associateWith { darkColors[it]?.jsonPrimitive?.content ?: "" } + } ?: emptyMap() + ) + } + + Regex("className=(?:(?!>)[^\"'])*[\"']([^>]*)[\"']").replace(newContents) { result -> + // matches any className, and takes everything inside the first quote to the last quote found before the closing `>` + // if no quotes are found before the closing `>`, skips the match + val match = result.groupValues[0] + val group = result.groupValues[1] + match.replace( + group, + // if the group contains a quote, we assume the classes are the last quoted string in the group + if (group.contains("\"")) { + group.substringBeforeLast('"') + "\"" + handleClasses( + group.substringAfterLast('"') + ) + } else if (group.contains("'")) { + group.substringBeforeLast("'") + "'" + handleClasses( + group.substringAfterLast("'") + ) + } else handleClasses(group) + ) + } + } else newContents + } +} diff --git a/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/impl/SvelteSource.kt b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/impl/SvelteSource.kt new file mode 100644 index 0000000..f395a09 --- /dev/null +++ b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/impl/SvelteSource.kt @@ -0,0 +1,42 @@ +package com.github.warningimhack3r.intellijshadcnplugin.backend.sources.impl + +import com.github.warningimhack3r.intellijshadcnplugin.backend.helpers.FileManager +import com.github.warningimhack3r.intellijshadcnplugin.backend.helpers.ShellRunner +import com.github.warningimhack3r.intellijshadcnplugin.backend.sources.Source +import com.github.warningimhack3r.intellijshadcnplugin.backend.sources.config.SvelteConfig +import com.intellij.openapi.project.Project +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.nio.file.NoSuchFileException + +class SvelteSource(project: Project) : Source(project, SvelteConfig.serializer()) { + override var framework = "Svelte" + + override fun resolveAlias(alias: String): String { + if (!alias.startsWith("$") && !alias.startsWith("@")) return alias + var tsConfig = FileManager(project).getFileContentsAtPath(".svelte-kit/tsconfig.json") + if (tsConfig == null) { + ShellRunner(project).execute(arrayOf("npx", "svelte-kit", "sync")) + Thread.sleep(250) // wait for the sync to create the files + tsConfig = FileManager(project).getFileContentsAtPath(".svelte-kit/tsconfig.json") ?: throw NoSuchFileException("Cannot get or generate .svelte-kit/tsconfig.json") + } + val aliasPath = Json.parseToJsonElement(tsConfig) + .jsonObject["compilerOptions"] + ?.jsonObject?.get("paths") + ?.jsonObject?.get(alias.substringBefore("/")) + ?.jsonArray?.get(0) + ?.jsonPrimitive?.content ?: throw Exception("Cannot find alias $alias") + return "${aliasPath.replace(Regex("^\\.+/"), "")}/${alias.substringAfter("/")}" + } + + override fun adaptFileToConfig(contents: String): String { + val config = getLocalConfig() + return contents.replace( + Regex("@/registry/[^/]+"), cleanAlias(config.aliases.components) + ).replace( + "\$lib/utils", config.aliases.utils + ) + } +} diff --git a/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/impl/VueSource.kt b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/impl/VueSource.kt new file mode 100644 index 0000000..7005ab3 --- /dev/null +++ b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/impl/VueSource.kt @@ -0,0 +1,136 @@ +package com.github.warningimhack3r.intellijshadcnplugin.backend.sources.impl + +import com.github.warningimhack3r.intellijshadcnplugin.backend.helpers.FileManager +import com.github.warningimhack3r.intellijshadcnplugin.backend.sources.Source +import com.github.warningimhack3r.intellijshadcnplugin.backend.sources.config.VueConfig +import com.intellij.openapi.project.Project +import com.intellij.util.applyIf +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.nio.file.NoSuchFileException + +class VueSource(project: Project) : Source(project, VueConfig.serializer()) { + override var framework = "Vue" + + override fun adaptFileExtensionToConfig(extension: String): String { + return if (!getLocalConfig().typescript) { + extension.replace( + Regex("\\.ts$"), + ".js" + ) + } else extension + } + + override fun resolveAlias(alias: String): String { + if (!alias.startsWith("$") && !alias.startsWith("@")) return alias + val tsConfig = FileManager(project).getFileContentsAtPath("tsconfig.json") ?: throw NoSuchFileException("tsconfig.json not found") + val aliasPath = Json.parseToJsonElement(tsConfig) + .jsonObject["compilerOptions"] + ?.jsonObject?.get("paths") + ?.jsonObject?.get("${alias.substringBefore("/")}/*") + ?.jsonArray?.get(0) + ?.jsonPrimitive?.content ?: throw Exception("Cannot find alias $alias") + return aliasPath.replace(Regex("^\\./"), "") + .replace(Regex("\\*$"), alias.substringAfter("/")) + } + + override fun adaptFileToConfig(contents: String): String { + fun cleanAlias(alias: String): String { + return if (alias.startsWith("\$")) { + "\\$alias" // fixes Kotlin silently crashing when the replacement starts with $ with a regex + } else alias + } + + val config = getLocalConfig() + val newContents = contents.replace( + Regex("@/lib/registry/[^/]+"), cleanAlias(config.aliases.components) + ).replace( + // Note: this does not prevent additional imports other than "cn" from being replaced, + // but I'm once again following what the original code does for parity + // (https://github.com/radix-vue/shadcn-vue/blob/9d9a6f929ce0f281b4af36161af80ed2bbdc4a16/packages/cli/src/utils/transformers/transform-import.ts#L19-L29). + Regex(".*\\{.*[ ,\n\t]+cn[ ,].*}.*\"@/lib/utils"), + cleanAlias(config.aliases.utils) + ).applyIf(!config.typescript) { + // TODO: detype Vue file + this + } + return if (!config.tailwind.cssVariables) { + /** + * Converts CSS variables to Tailwind utility classes. + * @param classes The classes to convert, an unquoted string of space-separated class names. + * @param lightColors The light colors map to use. + * @param darkColors The dark colors map to use. + * @return The converted classes. + */ + fun variablesToUtilities(classes: String, lightColors: Map, darkColors: Map): String { + // Note: this does not include `border` classes at the beginning or end of the string, + // but I'm once again following what the original code does for parity + // (https://github.com/shadcn-ui/ui/blob/fb614ac2921a84b916c56e9091aa0ae8e129c565/packages/cli/src/utils/transformers/transform-css-vars.ts#L142-L145). + val newClasses = classes.replace(" border ", " border border-border ") + + val prefixesToReplace = listOf("bg-", "text-", "border-", "ring-offset-", "ring-") + + /** + * Replaces a class with CSS variables with Tailwind utility classes. + * @param class The class to replace. + * @return The replaced class. + */ + fun replaceClass(`class`: String): String { + val prefix = prefixesToReplace.find { `class`.startsWith(it) } ?: return `class` + val color = `class`.substringAfter(prefix) + val lightColor = lightColors[color] + val darkColor = darkColors[color] + return if (lightColor != null && darkColor != null) { + "$prefix$lightColor dark:$prefix$darkColor" + } else `class` + } + + return newClasses + .split(" ") + .filterNot { it.isEmpty() } + .joinToString(" ") { + val className = it.trim().split(":") + if (className.size == 1) { + replaceClass(className[0]) + } else { + "${className.dropLast(1).joinToString(":")}:${replaceClass(className.last())}" + } + } + } + + fun handleClasses(classes: String): String { + val inlineColors = fetchColors().jsonObject["inlineColors"]?.jsonObject ?: throw Exception("Inline colors not found") + return variablesToUtilities( + classes, + inlineColors.jsonObject["light"]?.jsonObject?.let { lightColors -> + lightColors.keys.associateWith { lightColors[it]?.jsonPrimitive?.content ?: "" } + } ?: emptyMap(), + inlineColors.jsonObject["dark"]?.jsonObject?.let { darkColors -> + darkColors.keys.associateWith { darkColors[it]?.jsonPrimitive?.content ?: "" } + } ?: emptyMap() + ) + } + + val notTemplateClasses = Regex("[^:]class=(?:(?!>)[^\"'])*[\"']([^\"']*)[\"']").replace(newContents) { result -> + result.groupValues[0].replace( + result.groupValues[1], + handleClasses(result.groupValues[1]) + ) + } + // Double quoted templates + Regex(":class=(?:(?!>)[^\"])*\"([^\"]*)\"").replace(notTemplateClasses) { result -> + val group = result.groupValues[1] + result.groupValues[0].replace( + group, + handleClasses(group + .replace("\n", " ") + .split(", ") + .map { it.trim() } + .last { it.startsWith("'") || it.endsWith("'") }) + ) + } + } else newContents + } +} diff --git a/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/remote/Component.kt b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/remote/Component.kt new file mode 100644 index 0000000..9dbc241 --- /dev/null +++ b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/remote/Component.kt @@ -0,0 +1,21 @@ +package com.github.warningimhack3r.intellijshadcnplugin.backend.sources.remote + +import kotlinx.serialization.Serializable + +/** + * A shadcn-svelte component in the registry. + * @param name The name of the component. + * @param dependencies The npm dependencies of the component. + * @param registryDependencies The other components that this component depends on. + * @param files The files that make up the component. + * @param type The kind of component. + */ +@Suppress("PROVIDED_RUNTIME_TOO_LOW") // https://github.com/Kotlin/kotlinx.serialization/issues/993#issuecomment-984742051 +@Serializable +data class Component( + val name: String, + val dependencies: List, + val registryDependencies: List, + val files: List, + val type: String +) diff --git a/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/remote/ComponentWithContents.kt b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/remote/ComponentWithContents.kt new file mode 100644 index 0000000..65af3bd --- /dev/null +++ b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/backend/sources/remote/ComponentWithContents.kt @@ -0,0 +1,32 @@ +package com.github.warningimhack3r.intellijshadcnplugin.backend.sources.remote + +import kotlinx.serialization.Serializable + +/** + * A shadcn-svelte component in the registry. + * @param name The name of the component. + * @param dependencies The npm dependencies of the component. + * @param registryDependencies The other components that this component depends on. + * @param files The files that make up the component. + * @param type The kind of component. + */ +@Suppress("PROVIDED_RUNTIME_TOO_LOW") +@Serializable +data class ComponentWithContents( + val name: String, + val dependencies: List, + val registryDependencies: List, + val files: List, + val type: String +) { + /** + * A component's file. + * @param name The name of the file. + * @param content The contents of the file. + */ + @Serializable + data class File( + val name: String, + val content: String + ) +} diff --git a/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/listeners/ISPToolWindowListener.kt b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/listeners/ToolWindowListener.kt similarity index 92% rename from src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/listeners/ISPToolWindowListener.kt rename to src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/listeners/ToolWindowListener.kt index bc81ce9..6664d39 100644 --- a/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/listeners/ISPToolWindowListener.kt +++ b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/listeners/ToolWindowListener.kt @@ -5,7 +5,7 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.wm.ToolWindowManager import com.intellij.openapi.wm.ex.ToolWindowManagerListener -class ISPToolWindowListener(private val project: Project) : ToolWindowManagerListener { +class ToolWindowListener(private val project: Project) : ToolWindowManagerListener { private val toolWindowId = "shadcn/ui" private var isToolWindowOpen: Boolean? = null diff --git a/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/notifications/NotificationManager.kt b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/notifications/NotificationManager.kt new file mode 100644 index 0000000..a769f96 --- /dev/null +++ b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/notifications/NotificationManager.kt @@ -0,0 +1,59 @@ +package com.github.warningimhack3r.intellijshadcnplugin.notifications + +import com.intellij.notification.Notification +import com.intellij.notification.NotificationAction +import com.intellij.notification.NotificationType +import com.intellij.notification.Notifications +import com.intellij.openapi.project.Project + +class NotificationManager(val project: Project? = null) { + + private fun createNotification( + title: String, + content: String, + type: NotificationType = NotificationType.INFORMATION, + actions: List = emptyList() + ) = Notification( + "shadcn/ui", + title, + content, + type + ).apply { + actions.forEach { addAction(it) } + } + + @Suppress("UnstableApiUsage") // notifyAndHide is still experimental + private fun sendNotification( + notification: Notification, + hide: Boolean = false + ) { + if (hide) { + project?.let { + Notifications.Bus.notifyAndHide(notification, it) + } ?: Notifications.Bus.notifyAndHide(notification) + } else { + project?.let { + Notifications.Bus.notify(notification, it) + } ?: Notifications.Bus.notify(notification) + } + } + + fun sendNotification( + title: String, + content: String, + type: NotificationType = NotificationType.INFORMATION, + actions: (Notification) -> List = { emptyList() } + ) = sendNotification( + createNotification(title, content, type).apply { actions(this).forEach { addAction(it) } }, + ) + + fun sendNotificationAndHide( + title: String, + content: String, + type: NotificationType = NotificationType.INFORMATION, + actions: (Notification) -> List = { emptyList() } + ) = sendNotification( + createNotification(title, content, type).apply { actions(this).forEach { addAction(it) } }, + true + ) +} diff --git a/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/ui/ISPWindowContents.kt b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/ui/ISPWindowContents.kt index fdedb9f..9fa62a4 100644 --- a/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/ui/ISPWindowContents.kt +++ b/src/main/kotlin/com/github/warningimhack3r/intellijshadcnplugin/ui/ISPWindowContents.kt @@ -1,6 +1,6 @@ package com.github.warningimhack3r.intellijshadcnplugin.ui -import com.github.warningimhack3r.intellijshadcnplugin.backend.ISPScanner +import com.github.warningimhack3r.intellijshadcnplugin.backend.SourceScanner import com.intellij.openapi.application.runReadAction import com.intellij.openapi.application.runWriteAction import com.intellij.openapi.project.Project @@ -51,16 +51,16 @@ class ISPWindowContents(private val project: Project) { // Add a component panel add(createPanel("Add a component") { GlobalScope.async { - val source = runReadAction { ISPScanner.findShadcnImplementation(project) } + val source = runReadAction { SourceScanner.findShadcnImplementation(project) } if (source == null) return@async emptyList() val installedComponents = runReadAction { source.getInstalledComponents() } - source.fetchAllComponents().map { component -> + runReadAction { source.fetchAllComponents() }.map { component -> Item( component.name, component.description ?: "${component.name.replace("-", " ") .replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() - }} component for ${source.language}", + }} component for ${source.framework}", listOf( LabeledAction("Add", CompletionAction.DISABLE_ROW) { runWriteAction { source.addComponent(component.name) } @@ -80,7 +80,7 @@ class ISPWindowContents(private val project: Project) { // Manage components panel add(createPanel("Manage components") { GlobalScope.async { - val source = runReadAction { ISPScanner.findShadcnImplementation(project) } + val source = runReadAction { SourceScanner.findShadcnImplementation(project) } if (source == null) return@async emptyList() runReadAction { source.getInstalledComponents() }.map { component -> Item( diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 65116e8..f8c11f0 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -8,7 +8,7 @@