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) + } + } +}