diff --git a/java/idea-ui/BUILD.bazel b/java/idea-ui/BUILD.bazel
index aaa12f898b89..d1e140ae3bf9 100644
--- a/java/idea-ui/BUILD.bazel
+++ b/java/idea-ui/BUILD.bazel
@@ -62,6 +62,8 @@ jvm_library(
"@lib//:slf4j-api",
"//xml/xml-psi-api:psi",
"@lib//:asm",
+ "@lib//:jackson",
+ "@lib//:jackson-databind",
],
runtime_deps = [":ui_resources"]
)
diff --git a/java/idea-ui/intellij.java.ui.iml b/java/idea-ui/intellij.java.ui.iml
index ab9073bd4c9c..70f3e485e673 100644
--- a/java/idea-ui/intellij.java.ui.iml
+++ b/java/idea-ui/intellij.java.ui.iml
@@ -58,5 +58,7 @@
+
+
\ No newline at end of file
diff --git a/java/idea-ui/src/com/intellij/ide/starters/remote/WebStarterContext.kt b/java/idea-ui/src/com/intellij/ide/starters/remote/WebStarterContext.kt
new file mode 100644
index 000000000000..26ed63d941ee
--- /dev/null
+++ b/java/idea-ui/src/com/intellij/ide/starters/remote/WebStarterContext.kt
@@ -0,0 +1,21 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.intellij.ide.starters.remote
+
+import com.intellij.ide.starters.shared.CommonStarterContext
+import com.intellij.ide.starters.shared.DEFAULT_PACKAGE_NAME
+import com.intellij.ide.starters.shared.StarterAppPackaging
+import com.intellij.ide.starters.shared.StarterLanguageLevel
+
+class WebStarterContext : CommonStarterContext() {
+ lateinit var serverUrl: String
+ lateinit var serverOptions: WebStarterServerOptions
+
+ var frameworkVersion: WebStarterFrameworkVersion? = null
+ var packageName: String = DEFAULT_PACKAGE_NAME
+ var languageLevel: StarterLanguageLevel? = null
+ var packaging: StarterAppPackaging? = null
+
+ val dependencies: MutableSet = HashSet()
+
+ var result: DownloadResult? = null
+}
\ No newline at end of file
diff --git a/java/idea-ui/src/com/intellij/ide/starters/remote/WebStarterModuleBuilder.kt b/java/idea-ui/src/com/intellij/ide/starters/remote/WebStarterModuleBuilder.kt
new file mode 100644
index 000000000000..80680769705c
--- /dev/null
+++ b/java/idea-ui/src/com/intellij/ide/starters/remote/WebStarterModuleBuilder.kt
@@ -0,0 +1,439 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.intellij.ide.starters.remote
+
+import com.fasterxml.jackson.databind.JsonNode
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.intellij.codeInsight.actions.ReformatCodeProcessor
+import com.intellij.ide.IdeBundle
+import com.intellij.ide.plugins.PluginManagerCore
+import com.intellij.ide.projectWizard.ProjectSettingsStep
+import com.intellij.ide.starters.JavaStartersBundle
+import com.intellij.ide.starters.local.StarterModuleBuilder
+import com.intellij.ide.starters.local.StarterModuleBuilder.Companion.importModule
+import com.intellij.ide.starters.local.StarterModuleBuilder.Companion.openSampleFiles
+import com.intellij.ide.starters.local.StarterModuleBuilder.Companion.preprocessModuleCreated
+import com.intellij.ide.starters.local.StarterModuleBuilder.Companion.preprocessModuleOpened
+import com.intellij.ide.starters.remote.wizard.WebStarterInitialStep
+import com.intellij.ide.starters.remote.wizard.WebStarterLibrariesStep
+import com.intellij.ide.starters.shared.*
+import com.intellij.ide.util.projectWizard.ModuleBuilder
+import com.intellij.ide.util.projectWizard.ModuleWizardStep
+import com.intellij.ide.util.projectWizard.SettingsStep
+import com.intellij.ide.util.projectWizard.WizardContext
+import com.intellij.notification.NotificationAction
+import com.intellij.notification.NotificationType
+import com.intellij.openapi.Disposable
+import com.intellij.openapi.GitRepositoryInitializer
+import com.intellij.openapi.application.ApplicationInfo
+import com.intellij.openapi.application.ApplicationManager
+import com.intellij.openapi.application.ApplicationNamesInfo
+import com.intellij.openapi.application.ModalityState
+import com.intellij.openapi.diagnostic.thisLogger
+import com.intellij.openapi.extensions.PluginId
+import com.intellij.openapi.externalSystem.util.ExternalSystemUtil
+import com.intellij.openapi.module.JavaModuleType
+import com.intellij.openapi.module.Module
+import com.intellij.openapi.module.ModuleType
+import com.intellij.openapi.options.ConfigurationException
+import com.intellij.openapi.progress.ProgressIndicator
+import com.intellij.openapi.progress.runBackgroundableTask
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.projectRoots.JavaSdk
+import com.intellij.openapi.projectRoots.JavaSdkType
+import com.intellij.openapi.projectRoots.JavaSdkVersion
+import com.intellij.openapi.projectRoots.SdkTypeId
+import com.intellij.openapi.roots.LanguageLevelModuleExtension
+import com.intellij.openapi.roots.ModifiableRootModel
+import com.intellij.openapi.roots.ui.configuration.ModulesProvider
+import com.intellij.openapi.roots.ui.configuration.setupNewModuleJdk
+import com.intellij.openapi.startup.StartupManager
+import com.intellij.openapi.ui.Messages
+import com.intellij.openapi.updateSettings.impl.pluginsAdvertisement.getPluginSuggestionNotificationGroup
+import com.intellij.openapi.updateSettings.impl.pluginsAdvertisement.installAndEnable
+import com.intellij.openapi.util.NlsSafe
+import com.intellij.openapi.util.io.FileUtil
+import com.intellij.openapi.util.text.StringUtil
+import com.intellij.openapi.vfs.LocalFileSystem
+import com.intellij.util.Url
+import com.intellij.util.concurrency.EdtExecutorService
+import com.intellij.util.concurrency.annotations.RequiresBackgroundThread
+import com.intellij.util.io.HttpRequests
+import com.intellij.util.io.HttpRequests.RequestProcessor
+import java.io.File
+import java.io.IOException
+import java.net.URLConnection
+import java.net.UnknownHostException
+import java.util.concurrent.TimeUnit
+import javax.swing.Icon
+
+abstract class WebStarterModuleBuilder : ModuleBuilder() {
+ protected val starterContext: WebStarterContext = WebStarterContext()
+ protected val starterSettings: StarterWizardSettings by lazy { createSettings() }
+
+ protected val objectMapper: ObjectMapper by lazy { ObjectMapper() }
+
+ override fun getModuleType(): ModuleType<*> = JavaModuleType.getModuleType()
+ override fun getWeight(): Int = JVM_WEIGHT
+ open fun getHelpId(): String? = null
+
+ // Required settings
+
+ abstract override fun getBuilderId(): String
+ abstract override fun getNodeIcon(): Icon?
+ abstract override fun getPresentableName(): String
+ abstract override fun getDescription(): String
+
+ abstract fun getDefaultServerUrl(): String
+
+ protected abstract fun getLanguages(): List
+ protected abstract fun getProjectTypes(): List
+
+ // Optional settings
+
+ protected open fun getDefaultVersion(): String = DEFAULT_MODULE_VERSION
+ protected open fun isPackageNameEditable(): Boolean = false
+ protected open fun isExampleCodeProvided(): Boolean = false
+ protected open fun getTestFrameworks(): List = emptyList()
+ protected open fun getLanguageLevels(): List = emptyList()
+ protected open fun getDefaultLanguageLevel(): StarterLanguageLevel? = null
+ protected open fun getApplicationTypes(): List = emptyList()
+ protected open fun getPackagingTypes(): List = emptyList()
+
+ protected open fun getFilePathsToOpen(): List = emptyList()
+
+ protected open fun isShowProjectTypes(): Boolean = true
+
+ override fun isSuitableSdkType(sdkType: SdkTypeId): Boolean {
+ return sdkType is JavaSdkType && !sdkType.isDependent
+ }
+
+ private fun createSettings(): StarterWizardSettings {
+ return StarterWizardSettings(
+ getProjectTypes(),
+ getLanguages(),
+ isExampleCodeProvided(),
+ isPackageNameEditable(),
+ getLanguageLevels(),
+ getDefaultLanguageLevel(),
+ getPackagingTypes(),
+ getApplicationTypes(),
+ getTestFrameworks(),
+ getCustomizedMessages(),
+ isShowProjectTypes()
+ )
+ }
+
+ override fun getCustomOptionsStep(context: WizardContext, parentDisposable: Disposable): ModuleWizardStep? {
+ starterContext.serverUrl = getDefaultServerUrl()
+ starterContext.version = getDefaultVersion()
+ starterContext.language = starterSettings.languages.first()
+ starterContext.projectType = starterSettings.projectTypes.firstOrNull()
+ starterContext.isCreatingNewProject = context.isCreatingNewProject
+
+ starterContext.applicationType = starterSettings.applicationTypes.firstOrNull()
+ starterContext.languageLevel = starterSettings.defaultLanguageLevel ?: starterSettings.languageLevels.firstOrNull()
+ starterContext.packaging = starterSettings.packagingTypes.firstOrNull()
+ starterContext.testFramework = starterSettings.testFrameworks.firstOrNull()
+
+ return createOptionsStep(WebStarterContextProvider(this, context, starterContext, starterSettings, parentDisposable))
+ }
+
+ override fun createWizardSteps(context: WizardContext, modulesProvider: ModulesProvider): Array {
+ return arrayOf(createLibrariesStep(WebStarterContextProvider(this, context, starterContext, starterSettings, context.disposable)))
+ }
+
+ override fun getIgnoredSteps(): List> {
+ return listOf(ProjectSettingsStep::class.java)
+ }
+
+ override fun modifyProjectTypeStep(settingsStep: SettingsStep): ModuleWizardStep? {
+ // do not add standard SDK selector at the top
+ return null
+ }
+
+ protected open fun createOptionsStep(contextProvider: WebStarterContextProvider): WebStarterInitialStep {
+ return WebStarterInitialStep(contextProvider)
+ }
+
+ protected open fun createLibrariesStep(contextProvider: WebStarterContextProvider): WebStarterLibrariesStep {
+ return WebStarterLibrariesStep(contextProvider)
+ }
+
+ private fun getUserAgentInternal(): String? = getUserAgent()
+
+ protected open fun getUserAgent(): String? {
+ return ApplicationNamesInfo.getInstance().fullProductName + "/" + ApplicationInfo.getInstance().fullVersion
+ }
+
+ protected open fun getCustomizedMessages(): CustomizedMessages? = null
+
+ @RequiresBackgroundThread
+ fun getServerOptions(serverUrl: String): WebStarterServerOptions = loadServerOptions(serverUrl)
+
+ @RequiresBackgroundThread
+ protected abstract fun loadServerOptions(serverUrl: String): WebStarterServerOptions
+
+ internal fun getDependencyStateInternal(frameworkVersion: WebStarterFrameworkVersion, dependency: WebStarterDependency): DependencyState {
+ return getDependencyState(frameworkVersion, dependency)
+ }
+
+ internal fun isVersionAvailableInternal(frameworkVersion: WebStarterFrameworkVersion): Boolean {
+ return isVersionAvailable(frameworkVersion)
+ }
+
+ protected open fun getDependencyState(frameworkVersion: WebStarterFrameworkVersion, dependency: WebStarterDependency): DependencyState {
+ return DependencyAvailable
+ }
+
+ protected open fun isVersionAvailable(frameworkVersion: WebStarterFrameworkVersion): Boolean {
+ return true
+ }
+
+ private fun getGeneratorUrlInternal(serverUrl: String, starterContext: WebStarterContext): Url {
+ return composeGeneratorUrl(serverUrl, starterContext)
+ }
+
+ protected abstract fun composeGeneratorUrl(serverUrl: String, starterContext: WebStarterContext): Url
+
+ protected abstract fun extractGeneratorResult(tempZipFile: File, contentEntryDir: File)
+
+ protected open fun getPluginRecommendations(): List = emptyList()
+
+ protected open fun isReformatAfterCreation(project: Project): Boolean = true
+
+ override fun createProject(name: String, path: String): Project? {
+ val project = super.createProject(name, path)
+ project?.let { StarterModuleBuilder.setupProject(it) }
+ return project
+ }
+
+ @Throws(ConfigurationException::class)
+ override fun setupModule(module: Module) {
+ super.setupModule(module)
+
+ val isMavenProject = starterContext.projectType?.id?.contains("Maven", ignoreCase = true) ?: false
+ ExternalSystemUtil.configureNewModule(module, starterContext.isCreatingNewProject, isMavenProject)
+
+ val project = module.project
+
+ try {
+ extractTemplate() // should be quite fast, only extracts single small ZIP file
+ }
+ catch (e: Exception) {
+ thisLogger().info(e)
+
+ StartupManager.getInstance(project).runAfterOpened {
+ EdtExecutorService.getScheduledExecutorInstance().schedule(
+ {
+ var message = JavaStartersBundle.message("error.text.with.error.content", e.message)
+ message = StringUtil.shortenTextWithEllipsis(message, 1024, 0) // exactly 1024 because why not
+ Messages.showErrorDialog(message, presentableName)
+ },
+ 3, TimeUnit.SECONDS)
+ }
+ return
+ }
+
+ preprocessModuleCreated(module, this, starterContext.frameworkVersion?.id)
+
+ StartupManager.getInstance(project).runAfterOpened {
+ ApplicationManager.getApplication().invokeLater({ runImport(module) },
+ ModalityState.nonModal(), module.disposed)
+ }
+ }
+
+ override fun setupRootModel(modifiableRootModel: ModifiableRootModel) {
+ val sdk = setupNewModuleJdk(modifiableRootModel, moduleJdk, starterContext.isCreatingNewProject)
+ val moduleExt = modifiableRootModel.getModuleExtension(LanguageLevelModuleExtension::class.java)
+ if (moduleExt != null && sdk != null) {
+ val languageLevel = starterContext.languageLevel
+ if (languageLevel != null) {
+ val selectedVersion = JavaSdkVersion.fromVersionString(languageLevel.id)
+ val sdkVersion = JavaSdk.getInstance().getVersion(sdk)
+ if (selectedVersion != null && sdkVersion != null && sdkVersion.isAtLeast(selectedVersion)) {
+ moduleExt.languageLevel = selectedVersion.maxLanguageLevel
+ }
+ }
+ }
+ doAddContentEntry(modifiableRootModel)
+ }
+
+ private fun runImport(module: Module) {
+ LocalFileSystem.getInstance().refresh(false) // to avoid IDEA-232806
+
+ preprocessModuleOpened(module, this, starterContext.frameworkVersion?.id)
+
+ if (isReformatAfterCreation(module.project)) {
+ ReformatCodeProcessor(module.project, module, false).run()
+ }
+
+ openSampleFiles(module, getFilePathsToOpen())
+
+ if (starterContext.gitIntegration && starterContext.isCreatingNewProject) {
+ val moduleContentRoot = LocalFileSystem.getInstance().refreshAndFindFileByPath(contentEntryPath!!.replace("\\", "/"))
+ ?: throw IllegalStateException("Module root not found")
+
+ runBackgroundableTask(IdeBundle.message("progress.title.creating.git.repository"), module.project) {
+ GitRepositoryInitializer.getInstance()?.initRepository(module.project, moduleContentRoot, true)
+ }
+ }
+
+ importModule(module)
+
+ verifyIdePlugins(module.project)
+ }
+
+ private fun extractTemplate() {
+ val downloadResult = starterContext.result!!
+ val tempFile = downloadResult.tempFile
+
+ val path: String = contentEntryPath!!
+ val contentEntryDir = File(path)
+
+ if (downloadResult.isZip) {
+ extractGeneratorResult(tempFile, contentEntryDir)
+ fixExecutableFlag(contentEntryDir, "gradlew")
+ fixExecutableFlag(contentEntryDir, "mvnw")
+ }
+ else {
+ FileUtil.copy(tempFile, File(contentEntryDir, downloadResult.filename))
+ }
+ }
+
+ private fun verifyIdePlugins(project: Project) {
+ val selectedDependenciesIds = starterContext.dependencies.map { it.id }.toSet()
+
+ val requiredPluginIds: MutableSet = HashSet()
+ for (pluginRecommendation in getPluginRecommendations()) {
+ for (dependencyId in pluginRecommendation.dependencyIds) {
+ if (selectedDependenciesIds.contains(dependencyId)) {
+ requiredPluginIds.add(PluginId.getId(pluginRecommendation.pluginId))
+ break
+ }
+ }
+ }
+
+ val toInstallOrEnable: MutableSet = HashSet()
+ for (pluginId in requiredPluginIds) {
+ val ideaPluginDescriptor = PluginManagerCore.getPlugin(pluginId)
+ if (ideaPluginDescriptor == null || !ideaPluginDescriptor.isEnabled) {
+ toInstallOrEnable.add(pluginId)
+ }
+ }
+
+ if (toInstallOrEnable.isEmpty()) return
+
+ getPluginSuggestionNotificationGroup()
+ .createNotification(IdeBundle.message("plugins.advertiser.plugins.suggestions.title"),
+ IdeBundle.message("plugins.advertiser.plugins.suggestions.text"), NotificationType.INFORMATION)
+ .addAction(NotificationAction.create(IdeBundle.message("plugins.advertiser.action.enable.plugins")) { _, notification ->
+ installAndEnable(project, toInstallOrEnable) { notification.expire() }
+ })
+ .notify(project)
+ }
+
+ private fun fixExecutableFlag(containingDir: File, relativePath: String) {
+ val toFix = File(containingDir, relativePath)
+ if (toFix.exists()) {
+ toFix.setExecutable(true, false)
+ }
+ }
+
+ @RequiresBackgroundThread
+ protected fun loadJsonData(url: String, accept: String? = null): JsonNode {
+ return HttpRequests.request(url)
+ .userAgent(getUserAgent())
+ .accept(accept)
+ .connectTimeout(10000)
+ .connect(RequestProcessor { request ->
+ val reader = try {
+ request.reader
+ }
+ catch (e: IOException) {
+ thisLogger().info("IOException loading JSON response from " + request.url, e)
+ throw IOException(HttpRequests.createErrorMessage(e, request, false), e)
+ }
+
+ val jsonRootElement = try {
+ objectMapper.readTree(reader)
+ }
+ catch (e: Throwable) {
+ thisLogger().info("Unable to read JSON response from " + request.url, e)
+ throw IOException("Error parsing JSON response", e)
+ }
+ jsonRootElement ?: throw IOException("Error parsing JSON response: empty document")
+ })
+ }
+
+ @RequiresBackgroundThread
+ internal fun downloadResultInternal(progressIndicator: ProgressIndicator): DownloadResult {
+ val tempFile = FileUtil.createTempFile(builderId, ".tmp", true)
+ return downloadResult(progressIndicator, tempFile)
+ }
+
+ @RequiresBackgroundThread
+ protected open fun downloadResult(progressIndicator: ProgressIndicator, tempFile: File): DownloadResult {
+ val url = getGeneratorUrlInternal(starterContext.serverUrl, starterContext).toExternalForm()
+ thisLogger().info("Loading project from ${url}")
+
+ return HttpRequests
+ .request(url)
+ .userAgent(getUserAgentInternal())
+ .connectTimeout(10000)
+ .isReadResponseOnError(true)
+ .connect(RequestProcessor { request ->
+ handleDownloadResponse(request, tempFile, progressIndicator)
+ })
+ }
+
+ protected fun handleDownloadResponse(request: HttpRequests.Request,
+ tempFile: File,
+ progressIndicator: ProgressIndicator): DownloadResult {
+ val connection: URLConnection = try {
+ request.connection
+ }
+ catch (e: IOException) {
+ thisLogger().warn("Can't download project. Message (with headers info): "
+ + HttpRequests.createErrorMessage(e, request, true))
+ throw IOException(HttpRequests.createErrorMessage(e, request, false), e)
+ }
+ catch (he: UnknownHostException) {
+ thisLogger().warn("Can't download project: " + he.message)
+ throw IOException(HttpRequests.createErrorMessage(he, request, false), he)
+ }
+
+ val contentType = connection.contentType
+ val contentDisposition = connection.getHeaderField("Content-Disposition")
+ val filename = getFilename(contentDisposition)
+ val isZip = StringUtil.isNotEmpty(contentType) && contentType.startsWith("application/zip")
+ || filename.endsWith(".zip")
+ // Micronaut has broken content-type (it's "text") but zip-file as attachment
+ // (https://github.com/micronaut-projects/micronaut-starter/issues/268)
+
+ request.saveToFile(tempFile, progressIndicator)
+
+ return DownloadResult(isZip, tempFile, filename)
+ }
+
+ @NlsSafe
+ private fun getFilename(contentDisposition: String?): String {
+ val filenameField = "filename="
+ if (StringUtil.isEmpty(contentDisposition)) return "unknown"
+
+ val startIdx = contentDisposition!!.indexOf(filenameField)
+ val endIdx = contentDisposition.indexOf(';', startIdx)
+ var fileName = contentDisposition.substring(startIdx + filenameField.length, if (endIdx > 0) endIdx else contentDisposition.length)
+ if (StringUtil.startsWithChar(fileName, '\"') && StringUtil.endsWithChar(fileName, '\"')) {
+ fileName = fileName.substring(1, fileName.length - 1)
+ }
+ return fileName
+ }
+
+ fun JsonNode.getNullable(field: String): JsonNode? {
+ val element = this.get(field) ?: return null
+ if (element.isNull) {
+ return null
+ }
+ return element
+ }
+}
diff --git a/java/idea-ui/src/com/intellij/ide/starters/remote/WebStarterSettings.kt b/java/idea-ui/src/com/intellij/ide/starters/remote/WebStarterSettings.kt
new file mode 100644
index 000000000000..42f7a89d34f1
--- /dev/null
+++ b/java/idea-ui/src/com/intellij/ide/starters/remote/WebStarterSettings.kt
@@ -0,0 +1,122 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+@file:JvmName("WebStarterSettings")
+
+package com.intellij.ide.starters.remote
+
+import com.intellij.ide.starters.shared.*
+import com.intellij.ide.util.projectWizard.WizardContext
+import com.intellij.openapi.Disposable
+import com.intellij.openapi.util.Key
+import com.intellij.openapi.util.NlsSafe
+import com.intellij.openapi.util.UserDataHolderBase
+import com.intellij.util.io.Decompressor
+import java.io.File
+import java.io.IOException
+import java.nio.file.Paths
+import java.util.zip.ZipFile
+
+open class WebStarterServerOptions(
+ val frameworkVersions: List,
+ val dependencyCategories: List,
+) : UserDataHolderBase() {
+
+ fun extractOption(key: Key, handler: (T) -> Unit) {
+ val value = getUserData(key)
+ if (value != null) {
+ handler.invoke(value)
+ }
+ }
+}
+
+val SERVER_NAME_KEY: Key = Key.create("name")
+val SERVER_GROUP_KEY: Key = Key.create("group")
+val SERVER_ARTIFACT_KEY: Key = Key.create("artifact")
+val SERVER_VERSION_KEY: Key = Key.create("version")
+val SERVER_PACKAGE_NAME_KEY: Key = Key.create("packageName")
+val SERVER_LANGUAGES: Key> = Key.create("languages")
+val SERVER_LANGUAGE_LEVEL_KEY: Key = Key.create("languageLevel")
+val SERVER_LANGUAGE_LEVELS_KEY: Key> = Key.create("languageLevels")
+val SERVER_PROJECT_TYPES: Key> = Key.create("projectTypes")
+val SERVER_APPLICATION_TYPES: Key> = Key.create("appTypes")
+val SERVER_PACKAGING_TYPES: Key> = Key.create("packagingTypes")
+
+open class WebStarterFrameworkVersion(
+ val id: String,
+ @NlsSafe val title: String,
+ val isDefault: Boolean
+) {
+ override fun toString(): String {
+ return "WebStarterFrameworkVersion(version='$id', isDefault=$isDefault)"
+ }
+}
+
+open class WebStarterDependencyCategory(
+ @NlsSafe
+ val title: String,
+ val dependencies: List
+) {
+ open fun isAvailable(starterContext: WebStarterContext): Boolean = true
+
+ override fun toString(): String {
+ return "WebStarterDependencyCategory(title='$title')"
+ }
+}
+
+open class WebStarterDependency(
+ val id: String,
+ @NlsSafe
+ override val title: String,
+ override val description: String? = null,
+ override val links: List = emptyList(),
+ override val isDefault: Boolean = false,
+ override val isRequired: Boolean = false
+) : LibraryInfo {
+ override fun toString(): String {
+ return "WebStarterDependency(id='$id', title='$title')"
+ }
+}
+
+class WebStarterContextProvider(
+ val moduleBuilder: WebStarterModuleBuilder,
+ val wizardContext: WizardContext,
+ val starterContext: WebStarterContext,
+ val settings: StarterWizardSettings,
+ val parentDisposable: Disposable
+)
+
+@Throws(IOException::class)
+fun unzipSubfolder(tempZipFile: File, contentEntryDir: File) {
+ var rootFolderName: String
+
+ ZipFile(tempZipFile).use { jar ->
+ val entries = jar.entries()
+ if (!entries.hasMoreElements()) {
+ throw UnexpectedArchiveStructureException("The archive is empty")
+ }
+ val rootFolders = HashSet()
+ while (entries.hasMoreElements()) {
+ val entry = entries.nextElement()
+ val path = Paths.get(entry.name)
+ if (path.nameCount > 0) {
+ rootFolders.add(path.normalize().getName(0).toString())
+ }
+ }
+ if (rootFolders.size != 1) {
+ throw UnexpectedArchiveStructureException(
+ "The archive should have 1 subdirectory, but has: " + rootFolders.joinToString(","))
+ }
+ rootFolderName = rootFolders.iterator().next()
+ }
+
+ Decompressor.Zip(tempZipFile)
+ .removePrefixPath(rootFolderName)
+ .extract(contentEntryDir)
+}
+
+class DownloadResult(
+ val isZip: Boolean,
+ val tempFile: File,
+ val filename: String
+)
+
+internal class UnexpectedArchiveStructureException(message: String) : IOException(message)
\ No newline at end of file
diff --git a/java/idea-ui/src/com/intellij/ide/starters/remote/wizard/WebStarterInitialStep.kt b/java/idea-ui/src/com/intellij/ide/starters/remote/wizard/WebStarterInitialStep.kt
new file mode 100644
index 000000000000..18df46660e33
--- /dev/null
+++ b/java/idea-ui/src/com/intellij/ide/starters/remote/wizard/WebStarterInitialStep.kt
@@ -0,0 +1,518 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.intellij.ide.starters.remote.wizard
+
+import com.intellij.icons.AllIcons
+import com.intellij.ide.BrowserUtil
+import com.intellij.ide.starters.JavaStartersBundle
+import com.intellij.ide.starters.local.StarterModuleBuilder
+import com.intellij.ide.starters.remote.*
+import com.intellij.ide.starters.shared.*
+import com.intellij.ide.starters.shared.ValidationFunctions.*
+import com.intellij.ide.util.PropertiesComponent
+import com.intellij.ide.wizard.AbstractWizard
+import com.intellij.ide.wizard.withVisualPadding
+import com.intellij.openapi.Disposable
+import com.intellij.openapi.application.ApplicationManager
+import com.intellij.openapi.application.ModalityState
+import com.intellij.openapi.diagnostic.logger
+import com.intellij.openapi.observable.properties.GraphProperty
+import com.intellij.openapi.observable.util.trim
+import com.intellij.openapi.progress.ProgressManager
+import com.intellij.openapi.roots.ui.configuration.validateJdkIntentVersion
+import com.intellij.openapi.ui.DialogPanel
+import com.intellij.openapi.ui.InputValidator
+import com.intellij.openapi.ui.Messages
+import com.intellij.openapi.ui.popup.IconButton
+import com.intellij.openapi.util.Condition
+import com.intellij.openapi.util.Disposer
+import com.intellij.openapi.util.NlsSafe
+import com.intellij.openapi.util.Ref
+import com.intellij.openapi.util.io.FileUtil
+import com.intellij.ui.InplaceButton
+import com.intellij.ui.SimpleListCellRenderer
+import com.intellij.ui.components.ActionLink
+import com.intellij.ui.dsl.builder.*
+import com.intellij.util.concurrency.Semaphore
+import com.intellij.util.ui.AsyncProcessIcon
+import com.intellij.util.ui.UIUtil
+import java.awt.event.ActionListener
+import java.io.IOException
+import java.net.MalformedURLException
+import java.net.URL
+import java.util.concurrent.Future
+import javax.swing.DefaultComboBoxModel
+import javax.swing.JComponent
+import javax.swing.SwingUtilities
+
+open class WebStarterInitialStep(contextProvider: WebStarterContextProvider) : CommonStarterInitialStep(
+ contextProvider.wizardContext,
+ contextProvider.starterContext,
+ contextProvider.moduleBuilder,
+ contextProvider.parentDisposable,
+ contextProvider.settings
+) {
+ protected val moduleBuilder: WebStarterModuleBuilder = contextProvider.moduleBuilder
+ private val starterContext: WebStarterContext = contextProvider.starterContext
+
+ private val packageNameProperty: GraphProperty = propertyGraph.lazyProperty { starterContext.packageName }
+
+ private val packagingProperty: GraphProperty = propertyGraph.lazyProperty {
+ starterContext.packaging ?: StarterAppPackaging("unknown", "")
+ }
+ private val languageLevelProperty: GraphProperty = propertyGraph.lazyProperty {
+ starterContext.languageLevel ?: StarterLanguageLevel("unknown", "", "")
+ }
+
+ private var languageLevel: StarterLanguageLevel by languageLevelProperty
+ private var packageName: String by packageNameProperty.trim()
+
+ private val contentPanel: DialogPanel by lazy { createComponent() }
+ private val progressIcon: AsyncProcessIcon by lazy { AsyncProcessIcon(moduleBuilder.builderId + "ServerOptions") }
+ private val serverUrlLink: ActionLink by lazy { createServerUrlLink() }
+ private val retryButton: InplaceButton by lazy { createRetryButton() }
+
+ private val languageLevelsModel: DefaultComboBoxModel = DefaultComboBoxModel()
+ private val applicationTypesModel: DefaultComboBoxModel = DefaultComboBoxModel()
+
+ private lateinit var projectTypesSelector: SegmentedButton
+ private lateinit var packagingTypesSelector: SegmentedButton
+ private lateinit var languagesSelector: SegmentedButton
+
+ private var languages: List = starterSettings.languages
+ private var applicationTypes: List = starterSettings.applicationTypes
+ private var projectTypes: List = starterSettings.projectTypes
+ private var packagingTypes: List = starterSettings.packagingTypes
+
+ @Volatile
+ private var serverOptions: WebStarterServerOptions? = null
+
+ @Volatile
+ private var currentRequest: Future<*>? = null
+
+ @Volatile
+ private var isDisposed: Boolean = false
+
+ private val serverOptionsLoadingSemaphore: Semaphore = Semaphore()
+ private val serverSettingsButton: InplaceButton = InplaceButton(
+ IconButton(JavaStartersBundle.message("button.tooltip.configure"), AllIcons.General.Gear, AllIcons.General.GearHover),
+ ActionListener {
+ configureServer()
+ }
+ )
+
+ init {
+ Disposer.register(parentDisposable, Disposable {
+ isDisposed = true
+ currentRequest?.cancel(true)
+ })
+ }
+
+ override fun getComponent(): JComponent = contentPanel
+
+ override fun getHelpId(): String? = moduleBuilder.getHelpId()
+
+ override fun updateDataModel() {
+ starterContext.serverOptions = this.serverOptions!!
+
+ starterContext.projectType = projectTypeProperty.get()
+ starterContext.language = languageProperty.get()
+ starterContext.group = groupId
+ starterContext.artifact = artifactId
+ starterContext.name = entityName
+ starterContext.packageName = packageName
+ starterContext.packaging = packagingProperty.get()
+ starterContext.languageLevel = languageLevel
+ starterContext.testFramework = testFrameworkProperty.get()
+ starterContext.applicationType = applicationTypeProperty.get()
+ starterContext.includeExamples = exampleCodeProperty.get()
+ starterContext.gitIntegration = gitProperty.get()
+
+ wizardContext.projectName = entityName
+ wizardContext.setProjectFileDirectory(FileUtil.join(location, entityName))
+
+ val sdk = jdkIntentProperty.get().prepareJdk()
+ if (wizardContext.project == null) {
+ wizardContext.projectJdk = sdk
+ }
+ else {
+ moduleBuilder.moduleJdk = sdk
+ }
+ }
+
+ private fun suggestPackageName(): String {
+ return StarterModuleBuilder.suggestPackageName(groupId, artifactId)
+ }
+
+ private fun createComponent(): DialogPanel {
+ entityNameProperty.dependsOn(artifactIdProperty) { artifactId }
+ artifactIdProperty.dependsOn(entityNameProperty) { entityName }
+
+ packageNameProperty.dependsOn(artifactIdProperty, ::suggestPackageName)
+ packageNameProperty.dependsOn(groupIdProperty, ::suggestPackageName)
+
+ progressIcon.toolTipText = JavaStartersBundle.message("message.state.connecting.and.retrieving.options")
+
+ return panel {
+ row {
+ label(JavaStartersBundle.message("title.project.server.url.label"))
+
+ cell(serverUrlLink)
+ cell(serverSettingsButton)
+ cell(retryButton)
+ cell(progressIcon)
+ }.bottomGap(BottomGap.SMALL)
+
+ addProjectLocationUi()
+
+ addFieldsBefore(this)
+
+ if (starterSettings.languages.size > 1) {
+ row(JavaStartersBundle.message("title.project.language.label")) {
+ languagesSelector = segmentedButton(starterSettings.languages) { text = it.title }
+ .bind(languageProperty)
+
+ bottomGap(BottomGap.SMALL)
+ }
+ }
+
+ if (starterSettings.projectTypes.isNotEmpty() && starterSettings.showProjectTypes) {
+ val messages = starterSettings.customizedMessages
+ row(messages?.projectTypeLabel ?: JavaStartersBundle.message("title.project.type.label")) {
+ projectTypesSelector = segmentedButton(starterSettings.projectTypes) {
+ text = it.title
+ toolTipText = it.description
+ }.bind(projectTypeProperty)
+
+ bottomGap(BottomGap.SMALL)
+ }
+ }
+
+ if (starterSettings.testFrameworks.isNotEmpty()) {
+ row(JavaStartersBundle.message("title.project.test.framework.label")) {
+ segmentedButton(starterSettings.testFrameworks) { text = it.title }
+ .bind(testFrameworkProperty)
+ }.bottomGap(BottomGap.SMALL)
+ }
+
+ addGroupArtifactUi()
+
+ if (starterSettings.isPackageNameEditable) {
+ row(JavaStartersBundle.message("title.project.package.label")) {
+ textField()
+ .accessibleName(JavaStartersBundle.message("title.project.package.label"))
+ .bindText(packageNameProperty)
+ .columns(COLUMNS_MEDIUM)
+ .withSpecialValidation(CHECK_NOT_EMPTY, CHECK_NO_WHITESPACES, CHECK_NO_RESERVED_WORDS, CHECK_PACKAGE_NAME)
+ }.bottomGap(BottomGap.SMALL)
+ }
+
+ if (starterSettings.applicationTypes.isNotEmpty()) {
+ row(JavaStartersBundle.message("title.project.app.type.label")) {
+ applicationTypesModel.addAll(starterSettings.applicationTypes)
+ comboBox(applicationTypesModel, SimpleListCellRenderer.create("") { it?.title ?: "" })
+ .bindItem(applicationTypeProperty)
+ .columns(COLUMNS_MEDIUM)
+ }.bottomGap(BottomGap.SMALL)
+ }
+
+ addSdkUi()
+
+ if (starterSettings.languageLevels.isNotEmpty()) {
+ row(JavaStartersBundle.message("title.project.java.version.label")) {
+ languageLevelsModel.addAll(starterSettings.languageLevels)
+ comboBox(languageLevelsModel, SimpleListCellRenderer.create("") { it?.title ?: "" })
+ .bindItem(languageLevelProperty)
+ }.bottomGap(BottomGap.SMALL)
+ }
+
+ if (starterSettings.packagingTypes.isNotEmpty()) {
+ row(JavaStartersBundle.message("title.project.packaging.label")) {
+ packagingTypesSelector = segmentedButton(starterSettings.packagingTypes) { text = it.title }
+ .bind(packagingProperty)
+ }.bottomGap(BottomGap.SMALL)
+ }
+
+ addSampleCodeUi()
+
+ addFieldsAfter(this)
+ }.withVisualPadding()
+ }
+
+ private fun createServerUrlLink(): ActionLink {
+ val result = ActionLink(urlPreview(starterContext.serverUrl)) {
+ BrowserUtil.browse(starterContext.serverUrl)
+ }
+ UIUtil.applyStyle(UIUtil.ComponentStyle.REGULAR, result)
+ return result
+ }
+
+ private fun createRetryButton(): InplaceButton {
+ return InplaceButton(IconButton(JavaStartersBundle.message("button.tooltip.retry"),
+ AllIcons.Nodes.ErrorIntroduction, AllIcons.Actions.ForceRefresh), ActionListener {
+ requestServerOptions()
+ }).apply {
+ isVisible = false
+ }
+ }
+
+ @NlsSafe
+ private fun urlPreview(serverUrl: String): String {
+ val url = serverUrl
+ .removePrefix("https://")
+ .removeSuffix("/")
+
+ if (url.length > 35) {
+ return url.take(30) + "..."
+ }
+ return url
+ }
+
+ override fun getPreferredFocusedComponent(): JComponent? {
+ return contentPanel.preferredFocusedComponent
+ }
+
+ override fun validate(): Boolean {
+ if (!validateFormFields(component, contentPanel, validatedTextComponents)) {
+ return false
+ }
+ val passTechnologyName = if (starterSettings.languageLevels.size > 1) null else moduleBuilder.presentableName
+ if (languageLevel.javaVersion.isNotBlank() &&
+ !validateJdkIntentVersion(jdkIntentProperty, languageLevel.javaVersion, passTechnologyName)) {
+ return false
+ }
+
+ return checkServerOptionsLoaded()
+ }
+
+ private fun checkServerOptionsLoaded(): Boolean {
+ val request = currentRequest
+ if (serverOptions != null && request == null) {
+ return true
+ }
+
+ if (request == null) {
+ // failure? retry server options loading
+ requestServerOptions()
+ }
+
+ val newOptionsRef: Ref = Ref.create()
+
+ ProgressManager.getInstance().runProcessWithProgressSynchronously(Runnable {
+ val progressIndicator = ProgressManager.getInstance().progressIndicator
+ progressIndicator.isIndeterminate = true
+
+ for (i in 0 until 30) {
+ progressIndicator.checkCanceled()
+ if (serverOptionsLoadingSemaphore.waitFor(500)) {
+ serverOptions?.let {
+ newOptionsRef.set(it)
+ }
+ return@Runnable
+ }
+ }
+ }, JavaStartersBundle.message("message.state.connecting.and.retrieving.options"), true, wizardContext.project)
+
+ if (!newOptionsRef.isNull) {
+ updatePropertiesWithServerOptions(newOptionsRef.get())
+ }
+
+ return serverOptions != null
+ }
+
+ override fun _init() {
+ super._init()
+
+ if (serverOptions == null && currentRequest == null) {
+ val serverUrlFromSettings = PropertiesComponent.getInstance().getValue(getServerUrlPropertyName())
+ if (serverUrlFromSettings != null) {
+ setServerUrl(serverUrlFromSettings)
+ }
+
+ // required on dialog opening to get correct modality state
+ SwingUtilities.invokeLater(this::requestServerOptions)
+ }
+ }
+
+ private fun setServerUrl(@NlsSafe url: String) {
+ starterContext.serverUrl = url
+ serverUrlLink.text = urlPreview(url)
+ serverUrlLink.toolTipText = url
+ }
+
+ private fun requestServerOptions() {
+ progressIcon.isVisible = true
+ retryButton.isVisible = false
+ progressIcon.resume()
+
+ serverOptionsLoadingSemaphore.down()
+
+ currentRequest = ApplicationManager.getApplication().executeOnPooledThread {
+ val readyServerOptions = try {
+ moduleBuilder.getServerOptions(starterContext.serverUrl)
+ }
+ catch (e: Exception) {
+ if (e is IOException || e is IllegalStateException) {
+ logger().info("Unable to get server options for " + moduleBuilder.builderId, e)
+ }
+ else {
+ logger().error("Unable to get server options for " + moduleBuilder.builderId, e)
+ }
+
+ ApplicationManager.getApplication().invokeLater(
+ {
+ if (component.isShowing) {
+ // only if the wizard is visible
+ Messages.showErrorDialog(
+ JavaStartersBundle.message("message.no.connection.with.error.content", starterContext.serverUrl, e.message),
+ JavaStartersBundle.message("message.title.error"))
+ }
+ }, getModalityState())
+
+ null
+ }
+
+ setServerOptions(readyServerOptions)
+ }
+ }
+
+ private fun setServerOptions(serverOptions: WebStarterServerOptions?) {
+ this.serverOptions = serverOptions
+ this.currentRequest = null
+ this.serverOptionsLoadingSemaphore.up()
+
+ ApplicationManager.getApplication().invokeLater(Runnable {
+ progressIcon.suspend()
+ progressIcon.isVisible = false
+ retryButton.isVisible = serverOptions == null
+
+ if (serverOptions != null) {
+ updatePropertiesWithServerOptions(serverOptions)
+ }
+ }, getModalityState(), getDisposed())
+ }
+
+ private fun getModalityState(): ModalityState {
+ return ModalityState.stateForComponent(wizardContext.getUserData(AbstractWizard.KEY)!!.contentComponent)
+ }
+
+ private fun getDisposed(): Condition = Condition { isDisposed }
+
+ private fun configureServer() {
+ val currentServerUrl = starterContext.serverUrl
+ val serverUrlTitle = starterSettings.customizedMessages?.serverUrlDialogTitle
+ ?: JavaStartersBundle.message("title.server.url.dialog")
+
+ val newUrl = Messages.showInputDialog(component, null, serverUrlTitle, null, currentServerUrl, object : InputValidator {
+ override fun canClose(inputString: String?): Boolean = checkInput(inputString)
+
+ override fun checkInput(inputString: String?): Boolean {
+ try {
+ URL(inputString)
+ }
+ catch (e: MalformedURLException) {
+ return false
+ }
+ return true
+ }
+ })
+
+ // update
+ if (newUrl != null && starterContext.serverUrl != newUrl) {
+ setServerUrl(newUrl)
+
+ PropertiesComponent.getInstance().setValue(getServerUrlPropertyName(), newUrl)
+
+ requestServerOptions()
+ }
+ }
+
+ private fun getServerUrlPropertyName(): String {
+ return moduleBuilder.builderId + ".service.url.last"
+ }
+
+ protected open fun updatePropertiesWithServerOptions(serverOptions: WebStarterServerOptions) {
+ starterContext.frameworkVersion = serverOptions.frameworkVersions.find { it.isDefault }
+ ?: serverOptions.frameworkVersions.firstOrNull()
+
+ val currentPackageName = packageName // remember package name before applying group and artifact
+
+ serverOptions.extractOption(SERVER_NAME_KEY) {
+ if (entityName == suggestName(DEFAULT_MODULE_NAME)) {
+ val newName = suggestName(it)
+ if (entityName != newName) {
+ entityNameProperty.set(newName)
+ }
+ }
+ }
+ serverOptions.extractOption(SERVER_GROUP_KEY) {
+ if (groupId == DEFAULT_MODULE_GROUP && groupId != it) {
+ groupIdProperty.set(it)
+ }
+ }
+ serverOptions.extractOption(SERVER_ARTIFACT_KEY) {
+ if (artifactId == DEFAULT_MODULE_ARTIFACT && artifactId != it) {
+ artifactIdProperty.set(it)
+ }
+ }
+ serverOptions.extractOption(SERVER_PACKAGE_NAME_KEY) {
+ if (currentPackageName == DEFAULT_PACKAGE_NAME && currentPackageName != it) {
+ packageNameProperty.set(it)
+ }
+ }
+ serverOptions.extractOption(SERVER_VERSION_KEY) {
+ starterContext.version = it
+ }
+ serverOptions.extractOption(SERVER_LANGUAGE_LEVELS_KEY) { levels ->
+ val selectedItem = languageLevelsModel.selectedItem
+ languageLevelsModel.removeAllElements()
+ languageLevelsModel.addAll(levels)
+
+ if (levels.contains(selectedItem)) {
+ languageLevelsModel.selectedItem = selectedItem
+ }
+ else {
+ languageLevel = levels.firstOrNull() ?: StarterLanguageLevel("unknown", "", "")
+ languageLevelsModel.selectedItem = languageLevel
+ }
+ }
+ serverOptions.extractOption(SERVER_LANGUAGE_LEVEL_KEY) { level ->
+ if (languageLevel == starterSettings.defaultLanguageLevel && languageLevel != level) {
+ languageLevelProperty.set(level)
+ }
+ }
+ serverOptions.extractOption(SERVER_PROJECT_TYPES) { types ->
+ if (types.isNotEmpty() && types != this.projectTypes && ::projectTypesSelector.isInitialized) {
+ val correspondingOption = types.find { it.id == projectTypeProperty.get().id }
+ projectTypeProperty.set(correspondingOption ?: types.first())
+ projectTypesSelector.items = types
+ this.projectTypes = types
+ }
+ }
+ serverOptions.extractOption(SERVER_APPLICATION_TYPES) { types ->
+ if (types.isNotEmpty() && types != applicationTypes) {
+ applicationTypesModel.removeAllElements()
+ applicationTypesModel.addAll(types)
+ applicationTypesModel.selectedItem = types.firstOrNull()
+ this.applicationTypes = types
+ }
+ }
+ serverOptions.extractOption(SERVER_PACKAGING_TYPES) { types ->
+ if (types.isNotEmpty() && types != this.packagingTypes && ::packagingTypesSelector.isInitialized) {
+ val correspondingOption = types.find { it.id == packagingProperty.get().id }
+ packagingProperty.set(correspondingOption ?: types.first())
+ packagingTypesSelector.items = types
+ this.packagingTypes = types
+ }
+ }
+ serverOptions.extractOption(SERVER_LANGUAGES) { languages ->
+ if (languages.isNotEmpty() && languages != this.languages && ::languagesSelector.isInitialized) {
+ val correspondingOption = languages.find { it.id == languageProperty.get().id }
+ languageProperty.set(correspondingOption ?: languages.first())
+ languagesSelector.items = languages
+ this.languages = languages
+ }
+ }
+
+ contentPanel.revalidate()
+ }
+}
diff --git a/java/idea-ui/src/com/intellij/ide/starters/remote/wizard/WebStarterLibrariesStep.kt b/java/idea-ui/src/com/intellij/ide/starters/remote/wizard/WebStarterLibrariesStep.kt
new file mode 100644
index 000000000000..0e12fe78100e
--- /dev/null
+++ b/java/idea-ui/src/com/intellij/ide/starters/remote/wizard/WebStarterLibrariesStep.kt
@@ -0,0 +1,488 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.intellij.ide.starters.remote.wizard
+
+import com.intellij.ide.IdeBundle
+import com.intellij.ide.starters.JavaStartersBundle
+import com.intellij.ide.starters.remote.*
+import com.intellij.ide.starters.shared.*
+import com.intellij.ide.starters.shared.ui.LibrariesSearchTextField
+import com.intellij.ide.starters.shared.ui.LibraryDescriptionPanel
+import com.intellij.ide.starters.shared.ui.SelectedLibrariesPanel
+import com.intellij.ide.util.projectWizard.ModuleWizardStep
+import com.intellij.ide.util.projectWizard.WizardContext
+import com.intellij.ide.wizard.AbstractWizard
+import com.intellij.ide.wizard.withVisualPadding
+import com.intellij.openapi.Disposable
+import com.intellij.openapi.application.ModalityState
+import com.intellij.openapi.diagnostic.logger
+import com.intellij.openapi.observable.properties.GraphProperty
+import com.intellij.openapi.observable.properties.PropertyGraph
+import com.intellij.openapi.progress.ProgressIndicator
+import com.intellij.openapi.progress.ProgressManager
+import com.intellij.openapi.ui.DialogPanel
+import com.intellij.openapi.ui.Messages
+import com.intellij.openapi.util.Condition
+import com.intellij.openapi.util.Disposer
+import com.intellij.openapi.util.text.StringUtil
+import com.intellij.ui.*
+import com.intellij.ui.components.JBLabel
+import com.intellij.ui.dsl.builder.*
+import com.intellij.util.ModalityUiUtil
+import com.intellij.util.concurrency.EdtExecutorService
+import com.intellij.util.concurrency.annotations.RequiresBackgroundThread
+import com.intellij.util.ui.JBUI
+import com.intellij.util.ui.UIUtil.DEFAULT_HGAP
+import com.intellij.util.ui.UIUtil.DEFAULT_VGAP
+import com.intellij.util.ui.components.BorderLayoutPanel
+import com.intellij.util.ui.tree.TreeUtil
+import com.intellij.util.ui.update.MergingUpdateQueue
+import com.intellij.util.ui.update.Update
+import java.awt.BorderLayout
+import java.awt.Dimension
+import java.awt.GridBagLayout
+import java.util.concurrent.TimeUnit
+import javax.swing.DefaultComboBoxModel
+import javax.swing.JComponent
+import javax.swing.JPanel
+import javax.swing.JTree
+import javax.swing.event.DocumentEvent
+import javax.swing.event.ListDataEvent
+import javax.swing.event.ListDataListener
+import javax.swing.event.TreeSelectionListener
+import javax.swing.tree.DefaultMutableTreeNode
+import javax.swing.tree.DefaultTreeModel
+import javax.swing.tree.TreeSelectionModel
+
+open class WebStarterLibrariesStep(contextProvider: WebStarterContextProvider) : ModuleWizardStep() {
+ protected val moduleBuilder: WebStarterModuleBuilder = contextProvider.moduleBuilder
+ private val wizardContext: WizardContext = contextProvider.wizardContext
+ protected val starterContext: WebStarterContext = contextProvider.starterContext
+ private val starterSettings: StarterWizardSettings = contextProvider.settings
+ protected val parentDisposable: Disposable = contextProvider.parentDisposable
+
+ private val topLevelPanel: JPanel = BorderLayoutPanel()
+ private val contentPanel: DialogPanel by lazy { createComponent() }
+
+ private val librariesList: CheckboxTreeBase by lazy { createLibrariesList() }
+ private val librariesSearchField: LibrariesSearchTextField by lazy { createLibrariesFilter() }
+ private val libraryDescriptionPanel: LibraryDescriptionPanel by lazy { LibraryDescriptionPanel() }
+ private val selectedLibrariesPanel: SelectedLibrariesPanel by lazy { createSelectedLibrariesPanel() }
+ private val frameworkVersionsModel: DefaultComboBoxModel = DefaultComboBoxModel()
+
+ protected val propertyGraph: PropertyGraph = PropertyGraph()
+ private val frameworkVersionProperty: GraphProperty = propertyGraph.property(null)
+ private val selectedDependencies: MutableSet = mutableSetOf()
+
+ private var currentSearchString: String = ""
+ private val searchMergingUpdateQueue: MergingUpdateQueue by lazy {
+ MergingUpdateQueue("SearchLibs_" + moduleBuilder.builderId, 250, true, topLevelPanel, parentDisposable)
+ }
+
+ override fun getComponent(): JComponent = topLevelPanel
+
+ override fun getHelpId(): String? = moduleBuilder.getHelpId()
+
+ override fun getPreferredFocusedComponent(): JComponent? = librariesSearchField
+
+ override fun updateDataModel() {
+ starterContext.frameworkVersion = frameworkVersionProperty.get()
+ starterContext.dependencies.clear()
+ starterContext.dependencies.addAll(selectedDependencies)
+ }
+
+ override fun onStepLeaving() {
+ super.onStepLeaving()
+
+ updateDataModel()
+ }
+
+ override fun _init() {
+ super._init()
+
+ if (topLevelPanel.componentCount == 0) {
+ topLevelPanel.add(contentPanel, BorderLayout.CENTER)
+ }
+
+ if (topLevelPanel.isDisplayable && topLevelPanel.isShowing) {
+ // called after unsuccessful validation of step
+ return
+ }
+
+ // libraries list may depend on options specified on the first step
+ loadLibrariesList()
+ loadFrameworkVersions()
+
+ updateAvailableDependencies()
+ getLibrariesRoot()?.let {
+ selectFirstDependency(it)
+ }
+ }
+
+ final override fun validate(): Boolean {
+ val unavailable = selectedDependencies.filter { getDependencyState(it) is DependencyUnavailable }
+ if (unavailable.isNotEmpty()) {
+ val dependencyInfo = unavailable.joinToString { it.title }
+ val version = frameworkVersionProperty.get()?.title ?: ""
+ Messages.showErrorDialog(
+ JavaStartersBundle.message("message.unavailable.dependencies", dependencyInfo, version),
+ JavaStartersBundle.message("message.title.error"))
+ return false
+ }
+
+ if (!validateFields()) {
+ return false
+ }
+
+ if (starterContext.result == null) {
+ // commit selected dependencies to starterContext
+ updateDataModel()
+
+ // try to validate and download result
+ requestWebService()
+
+ if (starterContext.result == null) {
+ return false
+ }
+ }
+
+ return true
+ }
+
+ protected open fun validateFields(): Boolean {
+ return true
+ }
+
+ @RequiresBackgroundThread
+ protected open fun validateWithServer(progressIndicator: ProgressIndicator): Boolean {
+ return true
+ }
+
+ private fun requestWebService() {
+ ProgressManager.getInstance().runProcessWithProgressSynchronously(
+ {
+ val progressIndicator = ProgressManager.getInstance().progressIndicator
+
+ if (!validateWithServer(progressIndicator)) {
+ return@runProcessWithProgressSynchronously
+ }
+
+ progressIndicator.checkCanceled()
+
+ progressIndicator.text = JavaStartersBundle.message("message.state.downloading.template", moduleBuilder.presentableName)
+
+ val downloadResult: DownloadResult? = try {
+ moduleBuilder.downloadResultInternal(progressIndicator)
+ }
+ catch (e: Exception) {
+ logger().info(e)
+
+ EdtExecutorService.getScheduledExecutorInstance().schedule(
+ {
+ var message = JavaStartersBundle.message("error.text.with.error.content", e.message)
+ message = StringUtil.shortenTextWithEllipsis(message, 1024, 0) // exactly 1024 because why not
+ Messages.showErrorDialog(message, moduleBuilder.presentableName)
+ },
+ 3, TimeUnit.SECONDS)
+
+ null
+ }
+
+ starterContext.result = downloadResult
+ }, JavaStartersBundle.message("message.state.preparing.template"), true, wizardContext.project)
+ }
+
+ private fun loadFrameworkVersions() {
+ val availableFrameworkVersions = getAvailableFrameworkVersions()
+ frameworkVersionsModel.removeAllElements()
+ frameworkVersionsModel.addAll(availableFrameworkVersions)
+ val defaultVersion = starterContext.frameworkVersion ?: availableFrameworkVersions.firstOrNull()
+ if (availableFrameworkVersions.contains(defaultVersion)) {
+ frameworkVersionProperty.set(defaultVersion)
+ }
+ else {
+ frameworkVersionProperty.set(availableFrameworkVersions.firstOrNull())
+ }
+ }
+
+ protected open fun Panel.addFieldsAfter() {}
+
+ private fun createComponent(): DialogPanel {
+ val messages = starterSettings.customizedMessages
+ selectedLibrariesPanel.emptyText.text = messages?.noDependenciesSelectedLabel
+ ?: JavaStartersBundle.message("hint.dependencies.not.selected")
+
+ return panel {
+ val frameworkVersions = getAvailableFrameworkVersions()
+ if (frameworkVersions.isNotEmpty()) {
+ row(messages?.frameworkVersionLabel ?: JavaStartersBundle.message("title.project.version.label")) {
+ if (frameworkVersions.size == 1) {
+ label(frameworkVersions[0].title)
+ }
+ else {
+ frameworkVersionsModel.addListDataListener(object : ListDataListener {
+ override fun intervalAdded(e: ListDataEvent?) = updateAvailableDependencies()
+ override fun intervalRemoved(e: ListDataEvent?) = updateAvailableDependencies()
+ override fun contentsChanged(e: ListDataEvent?) = updateAvailableDependencies()
+ })
+ comboBox(frameworkVersionsModel, SimpleListCellRenderer.create("") { it?.title ?: "" })
+ .bindItem(frameworkVersionProperty)
+ }
+ }.bottomGap(BottomGap.SMALL)
+ }
+
+ addFieldsAfter()
+
+ row {
+ label(messages?.dependenciesLabel ?: JavaStartersBundle.message("title.project.dependencies.label"))
+ .applyToComponent { labelFor = librariesList }
+ }
+
+ row {
+ cell(JPanel(GridBagLayout()).apply {
+ add(BorderLayoutPanel().apply {
+ preferredSize = Dimension(0, 0)
+
+ addToTop(librariesSearchField)
+ addToCenter(ScrollPaneFactory.createScrollPane(librariesList))
+ }, gridConstraint(0, 0))
+
+ add(JPanel(GridBagLayout()).apply {
+ border = JBUI.Borders.emptyLeft(DEFAULT_HGAP * 2)
+ preferredSize = Dimension(0, 0)
+
+ add(libraryDescriptionPanel.apply {
+ preferredSize = Dimension(0, 0)
+ }, gridConstraint(0, 0))
+ add(BorderLayoutPanel().apply {
+ preferredSize = Dimension(0, 0)
+
+ addToTop(JBLabel(messages?.selectedDependenciesLabel
+ ?: JavaStartersBundle.message("title.project.dependencies.selected.label")).apply {
+ border = JBUI.Borders.emptyBottom(DEFAULT_VGAP * 2)
+ })
+ addToCenter(selectedLibrariesPanel)
+ }, gridConstraint(0, 1))
+ }, gridConstraint(1, 0))
+ }).resizableColumn()
+ .align(Align.FILL)
+ }.resizableRow()
+ }.withVisualPadding()
+ }
+
+ private fun getAvailableFrameworkVersions(): List {
+ return starterContext.serverOptions.frameworkVersions.filter {
+ moduleBuilder.isVersionAvailableInternal(it)
+ }
+ }
+
+ private fun createLibrariesList(): CheckboxTreeBase {
+ val list = CheckboxTreeBase(object : CheckboxTree.CheckboxTreeCellRenderer() {
+ override fun customizeRenderer(
+ tree: JTree,
+ value: Any,
+ selected: Boolean,
+ expanded: Boolean,
+ leaf: Boolean,
+ row: Int,
+ hasFocus: Boolean,
+ ) {
+ if (value !is DefaultMutableTreeNode) return
+
+ this.border = JBUI.Borders.empty(2, 0)
+ when (val item = value.userObject) {
+ is WebStarterDependencyCategory -> textRenderer.append(item.title)
+ is WebStarterDependency -> {
+ val enabled = (value as CheckedTreeNode).isEnabled
+ val attributes = if (enabled) SimpleTextAttributes.REGULAR_ATTRIBUTES else SimpleTextAttributes.GRAYED_ATTRIBUTES
+ textRenderer.append(item.title, attributes)
+ }
+ }
+ }
+ }, null)
+ list.emptyText.text = IdeBundle.message("empty.text.nothing.found")
+
+ enableEnterKeyHandling(list)
+
+ list.rowHeight = 0
+ list.isRootVisible = false
+ list.selectionModel.selectionMode = TreeSelectionModel.SINGLE_TREE_SELECTION
+
+ list.addCheckboxTreeListener(object : CheckboxTreeListener {
+ override fun nodeStateChanged(node: CheckedTreeNode) {
+ val dependency = node.userObject as? WebStarterDependency ?: return
+ if (node.isChecked) {
+ selectedDependencies.add(dependency)
+ }
+ else {
+ selectedDependencies.remove(dependency)
+ }
+ librariesList.repaint()
+ selectedLibrariesPanel.update(selectedDependencies)
+ }
+ })
+ list.selectionModel.addTreeSelectionListener(TreeSelectionListener { e ->
+ val path = e.path
+ if (path != null && e.isAddedPath) {
+ when (val item = (path.lastPathComponent as? DefaultMutableTreeNode)?.userObject) {
+ is WebStarterDependency -> {
+ updateSelectedLibraryInfo(item)
+ }
+ is WebStarterDependencyCategory -> libraryDescriptionPanel.update(item.title, null)
+ }
+ }
+ else {
+ libraryDescriptionPanel.reset()
+ }
+ })
+ librariesSearchField.list = list
+
+ return list
+ }
+
+ private fun isDependencyMatched(item: WebStarterDependency, search: String): Boolean {
+ return item.title.contains(search, true)
+ || (item.description ?: "").contains(search, true)
+ || item.id.contains(search, true)
+ }
+
+ private fun createLibrariesFilter(): LibrariesSearchTextField {
+ val textField = LibrariesSearchTextField()
+ textField.addDocumentListener(object : DocumentAdapter() {
+ override fun textChanged(e: DocumentEvent) {
+ searchMergingUpdateQueue.queue(Update.create("", Runnable {
+ ModalityUiUtil.invokeLaterIfNeeded(getModalityState(), Runnable {
+ currentSearchString = textField.text
+ loadLibrariesList()
+ librariesList.repaint()
+ })
+ }))
+ }
+ })
+ return textField
+ }
+
+ protected open fun getModalityState(): ModalityState {
+ return ModalityState.stateForComponent(wizardContext.getUserData(AbstractWizard.KEY)!!.contentComponent)
+ }
+
+ protected fun getDisposed(): Condition = Condition { Disposer.isDisposed(parentDisposable) }
+
+ protected open fun createSelectedLibrariesPanel(): SelectedLibrariesPanel {
+ val panel = SelectedLibrariesPanel()
+ val messages = starterSettings.customizedMessages
+ panel.emptyText.text = messages?.noDependenciesSelectedLabel ?: JavaStartersBundle.message("hint.dependencies.not.selected")
+ panel.libraryRemoveListener = { libraryInfo ->
+ selectedDependencies.remove(libraryInfo)
+ walkCheckedTree(getLibrariesRoot()) {
+ if (it.userObject == libraryInfo) {
+ librariesList.setNodeState(it, false)
+ }
+ }
+ selectedLibrariesPanel.update(selectedDependencies)
+ }
+ if (starterContext.frameworkVersion != null) {
+ panel.dependencyStateFunction = { libraryInfo ->
+ getDependencyState(libraryInfo)
+ }
+ }
+
+ return panel
+ }
+
+ private fun getLibrariesRoot(): CheckedTreeNode? {
+ return librariesList.model.root as? CheckedTreeNode
+ }
+
+ private fun getDependencyState(libraryInfo: LibraryInfo): DependencyState {
+ val frameworkVersion = frameworkVersionProperty.get() ?: return DependencyAvailable
+ return moduleBuilder.getDependencyStateInternal(frameworkVersion, libraryInfo as WebStarterDependency)
+ }
+
+ private fun loadLibrariesList() {
+ val librariesRoot = CheckedTreeNode()
+ val search = currentSearchString.trim()
+
+ val dependencyCategories = starterContext.serverOptions.dependencyCategories
+ for (category in dependencyCategories) {
+ if (!category.isAvailable(starterContext)) continue
+
+ val categoryNode = DefaultMutableTreeNode(category, true)
+
+ for (dependency in category.dependencies) {
+ if (search.isBlank() || isDependencyMatched(dependency, search)) {
+ val libraryNode = CheckedTreeNode(dependency)
+ if (dependency.isDefault) {
+ selectedDependencies.add(dependency)
+ }
+
+ libraryNode.isChecked = selectedDependencies.contains(dependency)
+
+ val state = getDependencyState(dependency)
+ libraryNode.isEnabled = !dependency.isDefault && state is DependencyAvailable
+
+ if (dependencyCategories.size > 1) {
+ categoryNode.add(libraryNode)
+ }
+ else {
+ librariesRoot.add(libraryNode)
+ }
+ }
+ }
+
+ if (dependencyCategories.size > 1) {
+ if (categoryNode.childCount > 0) {
+ librariesRoot.add(categoryNode)
+ }
+ }
+ }
+ librariesList.model = DefaultTreeModel(librariesRoot)
+
+ if (search.isNotBlank()) {
+ for (category in librariesRoot.children()) {
+ librariesList.expandPath(TreeUtil.getPath(librariesRoot, category))
+ }
+ selectFirstDependency(librariesRoot)
+ }
+ }
+
+ private fun selectFirstDependency(librariesRoot: CheckedTreeNode) {
+ if (librariesRoot.childCount > 0) {
+ val firstNode = librariesRoot.getChildAt(0)
+ if (firstNode is CheckedTreeNode) {
+ librariesList.selectionModel.addSelectionPath(TreeUtil.getPath(librariesRoot, firstNode))
+ }
+ else {
+ librariesList.expandPath(TreeUtil.getPath(librariesRoot, firstNode))
+ if (firstNode.childCount > 0) {
+ librariesList.selectionModel.addSelectionPath(TreeUtil.getPath(librariesRoot, firstNode.getChildAt(0)))
+ }
+ }
+ }
+ }
+
+ private fun updateSelectedLibraryInfo(item: WebStarterDependency) {
+ val dependencyState = getDependencyState(item)
+ val versionInfo = if (dependencyState is DependencyUnavailable) dependencyState.hint else null
+ libraryDescriptionPanel.update(item, versionInfo)
+ }
+
+ private fun updateAvailableDependencies() {
+ selectedLibrariesPanel.update(selectedDependencies)
+
+ val root = getLibrariesRoot() ?: return
+
+ walkCheckedTree(root) {
+ val dependency = it.userObject as? WebStarterDependency
+ if (dependency != null) {
+ val state = getDependencyState(dependency)
+ it.isEnabled = !dependency.isDefault && state is DependencyAvailable
+ }
+ }
+ librariesList.repaint()
+
+ val selectedDependency = (librariesList.selectionPath?.lastPathComponent as? CheckedTreeNode)?.userObject
+ if (selectedDependency is WebStarterDependency) {
+ updateSelectedLibraryInfo(selectedDependency)
+ }
+ }
+}