OPENIDE config import: skip plugins bundled in the IDE, report download progress

This commit is contained in:
Nikita Iarychenko
2026-09-15 18:57:03 +04:00
parent 77b50643b6
commit 51844b48d2
6 changed files with 243 additions and 24 deletions
@@ -2670,6 +2670,7 @@ dialog.title.configuration.changed=Configuration Changed
dialog.message.component.could.not.be.reloaded=The component cannot be reloaded. Reload project?
dialog.title.migrating.plugins=Migrating Plugins
progress.text.migrating.plugins=Migrating plugins\u2026
progress.text.downloading.plugin.of=Downloading ''{0}'' ({1} of {2})
dialog.message.unknown.error=Unknown error
http.velocity={0}<hr> <font face="verdana" size="-1">\
<a href=''https://velocity.apache.org/engine/devel/user-guide.html#Velocity_Template_Language_VTL:_An_Introduction''>\nApache Velocity</a> \
@@ -7,6 +7,7 @@ import com.intellij.ide.BootstrapBundle;
import com.intellij.ide.ConfigImportOptions;
import com.intellij.ide.ConfigImportSettings;
import com.intellij.ide.GeneralSettings;
import com.intellij.ide.IdeBundle;
import com.intellij.ide.ImportOldConfigsUsagesCollector;
import com.intellij.ide.SpecialConfigFiles;
import com.intellij.ide.highlighter.ArchiveFileType;
@@ -23,6 +24,8 @@ import com.intellij.ide.plugins.PluginManagerCore;
import com.intellij.ide.plugins.PluginNode;
import com.intellij.ide.plugins.PluginVersionIsSuperseded;
import com.intellij.ide.plugins.PluginsDiscoveryResult;
import com.intellij.ide.plugins.PluginsSourceContext;
import com.intellij.ide.plugins.marketplace.MarketplacePluginDownloadService;
import com.intellij.ide.plugins.marketplace.MarketplaceRequests;
import com.intellij.ide.plugins.newui.PluginUiModel;
import com.intellij.ide.startup.StartupActionScriptManager;
@@ -74,6 +77,7 @@ import org.jetbrains.annotations.Unmodifiable;
import org.jetbrains.annotations.VisibleForTesting;
import javax.swing.SwingUtilities;
import javax.swing.WindowConstants;
import java.awt.Dialog;
import java.io.IOException;
import java.io.StringReader;
@@ -120,6 +124,8 @@ public final class ConfigImportHelper {
public static final Pattern SELECTOR_PATTERN = Pattern.compile("\\.?(\\D+)(\\d+(?:\\.\\d+)*)");
private static final String UPDATE_ONLY_INCOMPATIBLE_PLUGINS_PROPERTY = "idea.config.import.update.incompatible.plugins.only"; // if true, only incompatible will be updated
// OpenIDE: if true, plugins bundled with this IDE are migrated/updated from the previous installation as before
private static final String MIGRATE_BUNDLED_PLUGINS_PROPERTY = "idea.config.import.migrate.bundled.plugins";
private static final String CONFIG = "config";
private static final String BIN = "bin";
@@ -1060,7 +1066,7 @@ public final class ConfigImportHelper {
options.compatibleBuildNumber, Collections.emptySet(), Collections.emptySet(), brokenPluginVersions
);
var nonLoadablePlugins = new HashMap<PluginId, PluginMainDescriptor>();
var loadablePlugins = PluginInitContextSelectPluginsToLoadKt.selectPluginsToLoad(
var allLoadablePlugins = PluginInitContextSelectPluginsToLoadKt.selectPluginsToLoad(
initContext,
oldIdePlugins,
(plugin, reason) -> {
@@ -1074,13 +1080,21 @@ public final class ConfigImportHelper {
return Unit.INSTANCE;
}
).getPlugins();
// OpenIDE: a plugin that this IDE ships itself must be neither updated nor copied over during the import.
// The bundled build is the one matching the new platform, while the copy inherited from the previous version would
// shadow it — and downloading it again is pure waste when the very same build is already in the distribution.
var bundledPluginVersions = collectBundledPluginVersions(oldIdePlugins);
var loadablePlugins = dropPluginsBundledInThisIde(allLoadablePlugins, bundledPluginVersions, options.log);
var nonLoadableToProcess = dropPluginsBundledInThisIde(nonLoadablePlugins.values(), bundledPluginVersions, options.log);
// TODO 'plugin is broken' is already applied by 'selectPluginsToLoad'
if (Boolean.getBoolean(UPDATE_ONLY_INCOMPATIBLE_PLUGINS_PROPERTY)) {
partitionNonBundled(loadablePlugins, pluginsToDownload, pluginsToMigrate, descriptor -> {
var brokenVersions = brokenPluginVersions != null ? brokenPluginVersions.get(descriptor.getPluginId()) : null;
return brokenVersions != null && brokenVersions.contains(descriptor.getVersion());
});
partitionNonBundled(nonLoadablePlugins.values(), pluginsToDownload, pluginsToMigrate, _ -> true);
partitionNonBundled(nonLoadableToProcess, pluginsToDownload, pluginsToMigrate, _ -> true);
}
else {
// The first partition in the branch above puts only broken plugins to pluginsToDownload.
@@ -1088,7 +1102,7 @@ public final class ConfigImportHelper {
// So the only difference is that here we try to download more plugins.
var nonBundledPlugins = new ArrayList<IdeaPluginDescriptor>();
partitionNonBundled(loadablePlugins, nonBundledPlugins, pluginsToMigrate, _ -> true);
partitionNonBundled(nonLoadablePlugins.values(), nonBundledPlugins, pluginsToMigrate, _ -> true);
partitionNonBundled(nonLoadableToProcess, nonBundledPlugins, pluginsToMigrate, _ -> true);
var updates = fetchPluginUpdatesFromMarketplace(options, ContainerUtil.map2Set(nonBundledPlugins, d -> d.getPluginId()));
partitionNonBundled(loadablePlugins, pluginsToDownload, pluginsToMigrate, d -> {
if (updates != null && updates.containsKey(d.getPluginId()) && !updates.get(d.getPluginId()).getVersion().equals(d.getVersion())) {
@@ -1097,7 +1111,7 @@ public final class ConfigImportHelper {
var brokenVersions = brokenPluginVersions != null ? brokenPluginVersions.get(d.getPluginId()) : null;
return brokenVersions != null && brokenVersions.contains(d.getVersion());
});
partitionNonBundled(nonLoadablePlugins.values(), pluginsToDownload, pluginsToMigrate, _ -> true);
partitionNonBundled(nonLoadableToProcess, pluginsToDownload, pluginsToMigrate, _ -> true);
}
}
return true;
@@ -1153,6 +1167,57 @@ public final class ConfigImportHelper {
}
}
/// OpenIDE: plugin ids shipped with the IDE being started, mapped to the bundled version.
///
/// The descriptors of the previous installation are discovered together with the bundled plugins of *this* IDE (see the
/// `bundledPluginPath` note in [#collectPluginsToMigrate]), and only the [PluginsSourceContext.Custom] list comes from the
/// previous plugin directory — everything else describes the new distribution. Matching on `Custom` rather than on
/// `Bundled` matters: a real distribution carries a `plugin-classpath.txt` index, and the plugins read through it are
/// reported as [PluginsSourceContext.ClassPathProvided]; `Bundled` only appears on the fallback path used when there is no
/// such index (notably in tests).
private static Map<PluginId, String> collectBundledPluginVersions(PluginsDiscoveryResult discoveryResult) {
var result = new HashMap<PluginId, String>();
for (var pluginList : discoveryResult.getPluginLists()) {
if (pluginList.getSource() instanceof PluginsSourceContext.Custom) {
continue;
}
for (var plugin : pluginList.getPlugins()) {
result.putIfAbsent(plugin.getPluginId(), plugin.getVersion());
}
}
return result;
}
/// OpenIDE: drops the plugins inherited from the previous installation that the current IDE bundles itself.
///
/// Such a plugin needs neither a download (the distribution already carries the build matching this platform) nor a copy
/// (the inherited copy would shadow the bundled one, typically with a build made for the previous platform branch).
/// Set `-Didea.config.import.migrate.bundled.plugins=true` to restore the previous behaviour.
private static <T extends IdeaPluginDescriptor> List<T> dropPluginsBundledInThisIde(
Collection<? extends T> descriptors,
Map<PluginId, String> bundledPluginVersions,
Logger log
) {
if (Boolean.getBoolean(MIGRATE_BUNDLED_PLUGINS_PROPERTY)) {
return new ArrayList<>(descriptors);
}
var result = new ArrayList<T>(descriptors.size());
for (var descriptor : descriptors) {
var bundledVersion = bundledPluginVersions.get(descriptor.getPluginId());
if (bundledVersion != null && !descriptor.isBundled()) {
log.info(
"Skipping plugin '" + descriptor.getPluginId() + "' version: " + descriptor.getVersion() +
", because this IDE bundles it (bundled version: " + bundledVersion + ')'
);
}
else {
result.add(descriptor);
}
}
return result;
}
private static Predicate<IdeaPluginDescriptor> collectPendingPluginUpdates(List<ActionCommand> actionCommands, FileSystem fs, Logger log) {
var result = new LinkedHashSet<PluginId>();
for (var command : actionCommands) {
@@ -1229,9 +1294,15 @@ public final class ConfigImportHelper {
var dialog = new ConfigImportProgressDialog();
dialog.setModalityType(Dialog.ModalityType.TOOLKIT_MODAL);
dialog.setDefaultCloseOperation(WindowConstants.DO_NOTHING_ON_CLOSE);
AppUIUtil.updateAppWindowIcon(dialog);
SplashManagerKt.hideSplash();
runSynchronouslyInBackground(() -> {
// OpenIDE: the worker must not be joined before the dialog is shown. `runSynchronouslyInBackground` joins the thread,
// so the EDT used to stay blocked for the whole download and `setVisible(true)` was reached only once everything had
// been downloaded — the dialog never appeared, the splash was already hidden, and the IDE looked frozen with a blank
// screen for as long as the download took. Showing the modal dialog first is what pumps the EDT; the worker closes it.
var worker = new Thread(() -> {
try {
downloadUpdatesForPlugins(newPluginsDir, options, plugins, brokenPluginVersions, dialog.getIndicator());
}
@@ -1239,8 +1310,19 @@ public final class ConfigImportHelper {
options.log.info("Failed to download updates for plugins", e);
}
SwingUtilities.invokeLater(() -> dialog.setVisible(false));
});
}, "Plugin downloader");
worker.start();
// Returns once the worker has hidden the dialog. If the worker finished before we got here, its `setVisible(false)` is
// already queued and the modal event loop dispatches it right away.
dialog.setVisible(true);
try {
worker.join();
}
catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
@@ -1255,14 +1337,26 @@ public final class ConfigImportHelper {
ThreadingAssertions.assertBackgroundThread();
var log = options.log;
// OpenIDE: the default service is replaced by one that reports the transferred size and speed through the indicator,
// so that the import dialog can show what a multi-minute download is actually doing.
var downloadService = options.downloadService != null ? options.downloadService : new MarketplacePluginDownloadService(true);
var total = plugins.size();
var index = 0;
for (var iterator = plugins.iterator(); iterator.hasNext(); ) {
var descriptor = iterator.next();
var pluginId = descriptor.getPluginId();
// OpenIDE: name the plugin being downloaded and its place in the queue
index++;
var pluginName = descriptor.getName() != null ? descriptor.getName() : pluginId.getIdString();
indicator.setText(IdeBundle.message("progress.text.downloading.plugin.of", pluginName, index, total));
indicator.setText2("");
indicator.setFraction(0.0);
try {
var downloader = PluginDownloader.createDownloader(descriptor)
.withErrorsConsumer(_ -> {})
.withDownloadService(options.downloadService);
.withDownloadService(downloadService);
if (downloader.prepareToInstall(indicator)) {
PluginInstaller.unpackPlugin(downloader.getFilePath(), newPluginsDir);
@@ -3,61 +3,130 @@ package com.intellij.openapi.application;
import com.intellij.ide.IdeBundle;
import com.intellij.openapi.progress.util.AbstractProgressIndicatorBase;
import com.intellij.openapi.util.NlsSafe;
import com.intellij.util.ui.EDT;
import com.intellij.util.ui.GridBag;
import com.intellij.util.ui.JBUI;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.Nullable;
import javax.swing.JButton;
import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JProgressBar;
import javax.swing.SwingConstants;
import java.awt.Dimension;
import java.awt.EventQueue;
import java.awt.Frame;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
/// The dialog shown while the initial config import downloads plugin updates.
///
/// OpenIDE: the step used to be silent — a bare progress bar with no indication of what was being downloaded, while a single
/// large plugin can keep the IDE from starting for minutes. It now reports the plugin being downloaded, its position in the
/// queue, and the transferred/total size, so that waiting is at least explainable and the Cancel button is discoverable.
@ApiStatus.Internal
public final class ConfigImportProgressDialog extends JDialog {
private static final int WIDTH = 500;
private final ProgressIndicatorAdapter myIndicator = new ProgressIndicatorAdapter();
private final JLabel myProgressTextLabel = new JLabel(" ");
private final JLabel myPluginLabel = new JLabel(" ");
private final JLabel myDetailsLabel = new JLabel(" ");
private final JProgressBar myProgressBar = new JProgressBar(0, 100);
private boolean myCanceled;
private volatile boolean myCanceled;
public ConfigImportProgressDialog() {
super((Frame)null, IdeBundle.message("dialog.title.migrating.plugins"), true);
JPanel panel = new JPanel();
GridBag gridBag = new GridBag();
panel.setLayout(new GridBagLayout());
panel.add(new JLabel(IdeBundle.message("progress.text.migrating.plugins")), gridBag.nextLine().anchor(GridBagConstraints.WEST));
panel.add(myProgressTextLabel, gridBag.nextLine().insetBottom(20));
myProgressBar.setPreferredSize(new Dimension(JBUI.scale(500), myProgressBar.getPreferredSize().height));
panel.add(myProgressBar, gridBag.nextLine().fillCell().insetBottom(20));
myPluginLabel.setFont(JBUI.Fonts.label().asBold());
panel.add(fixedWidth(myPluginLabel), gridBag.nextLine().anchor(GridBagConstraints.WEST).insetTop(10));
myProgressBar.setPreferredSize(new Dimension(JBUI.scale(WIDTH), myProgressBar.getPreferredSize().height));
panel.add(myProgressBar, gridBag.nextLine().fillCell().insetTop(6));
myDetailsLabel.setForeground(JBUI.CurrentTheme.Label.disabledForeground());
// The label spans the width of the progress bar, so centering the text puts it right under the middle of the bar.
myDetailsLabel.setHorizontalAlignment(SwingConstants.CENTER);
panel.add(fixedWidth(myDetailsLabel), gridBag.nextLine().anchor(GridBagConstraints.CENTER).insetBottom(20));
JButton cancelButton = new JButton(IdeBundle.message("button.cancel.without.mnemonic"));
panel.add(cancelButton, gridBag.nextLine());
panel.setBorder(JBUI.Borders.empty(10, 20));
cancelButton.addActionListener((e) -> {
cancelButton.addActionListener(_ -> {
myCanceled = true;
cancelButton.setEnabled(false);
});
panel.add(cancelButton, gridBag.nextLine());
panel.setBorder(JBUI.Borders.empty(10, 20));
setContentPane(panel);
pack();
setLocationRelativeTo(null);
}
/// Keeps a long plugin name or a long details line from resizing the dialog: the label clips with an ellipsis instead.
private static JLabel fixedWidth(JLabel label) {
label.setPreferredSize(new Dimension(JBUI.scale(WIDTH), label.getPreferredSize().height));
return label;
}
ProgressIndicatorAdapter getIndicator() {
return myIndicator;
}
private void setText(@Nullable @NlsSafe String text) {
onEdt(() -> myPluginLabel.setText(blankIfEmpty(text)));
}
private void setDetails(@Nullable @NlsSafe String text) {
onEdt(() -> myDetailsLabel.setText(blankIfEmpty(text)));
}
private static @NlsSafe String blankIfEmpty(@Nullable @NlsSafe String text) {
return text == null || text.isEmpty() ? " " : text;
}
/// The indicator is driven from the download thread, while Swing state may only be touched on the EDT.
private static void onEdt(Runnable runnable) {
if (EDT.isCurrentThreadEdt()) {
runnable.run();
}
else {
EventQueue.invokeLater(runnable);
}
}
final class ProgressIndicatorAdapter extends AbstractProgressIndicatorBase {
@Override
public void setFraction(double fraction) {
myProgressBar.setValue((int)(fraction * 100));
onEdt(() -> myProgressBar.setValue((int)(fraction * 100)));
}
@Override
public void setIndeterminate(boolean indeterminate) {
super.setIndeterminate(indeterminate);
onEdt(() -> myProgressBar.setIndeterminate(indeterminate));
}
/// The plugin currently being downloaded and its place in the queue.
@Override
public void setText(String text) {
super.setText(text);
ConfigImportProgressDialog.this.setText(text);
}
/// Transferred/total size and speed, as reported by `NetUtils.copyStreamContent`.
@Override
public void setText2(String text) {
myProgressTextLabel.setText(text);
super.setText2(text);
ConfigImportProgressDialog.this.setDetails(text);
}
@Override
@@ -74,7 +143,9 @@ public final class ConfigImportProgressDialog extends JDialog {
@SuppressWarnings("HardCodedStringLiteral")
public static void main(String[] args) {
ConfigImportProgressDialog dialog = new ConfigImportProgressDialog();
dialog.getIndicator().setText2("Downloading plugin 'Scala'");
dialog.getIndicator().setText("Downloading 'Scala' (2 of 5)");
dialog.getIndicator().setText2("<html><code>42% · 276 MB</code></html>");
dialog.getIndicator().setFraction(0.42);
dialog.setVisible(true);
}
}
@@ -27,7 +27,9 @@ import kotlin.io.path.moveTo
import kotlin.io.path.outputStream
@ApiStatus.Internal
open class MarketplacePluginDownloadService {
// OpenIDE: `showDownloadDetails` makes the download report bytes and speed via `ProgressIndicator.setText2`
// (see `NetUtils.copyStreamContent`). Used by the initial config import, which downloads before any UI exists.
open class MarketplacePluginDownloadService @JvmOverloads constructor(private val showDownloadDetails: Boolean = false) {
companion object {
private val LOG = logger<MarketplacePluginDownloadService>()
@@ -61,7 +63,7 @@ open class MarketplacePluginDownloadService {
.gzip(false)
.productNameAsUserAgent()
.connect(HttpRequests.RequestProcessor { request ->
request.saveToFile(file, indicator)
request.saveToFile(file, indicator, showDownloadDetails)
val pluginFileUrl = getPluginFileUrl(request.connection)
if (pluginFileUrl.endsWith(".zip")) {
renameFileToZipRoot(file)
@@ -278,6 +278,48 @@ class ConfigImportHelperTest : ConfigImportHelperBaseTest() {
assertThat(newPluginsDir).doesNotExist()
}
// OpenIDE: a plugin bundled with the new IDE is neither downloaded nor copied even when the previous installation carried
// a higher version of it — the inherited copy would shadow the bundled build made for the current platform branch.
@Test fun `skip plugins bundled in this ide even if the inherited copy is newer`() {
val oldConfigDir = newTempDir("oldConfig")
val oldPluginsDir = oldConfigDir.resolve("plugins").createDirectories()
val oldBundledPluginsDir = newTempDir("oldBundled")
plugin("my-plugin") { dependsIntellijModulesLang(); version = "1.0" }.buildMainJar(oldBundledPluginsDir.resolve("my-plugin-bundled.jar"))
plugin("my-plugin") { dependsIntellijModulesLang(); version = "1.1" }.buildMainJar(oldPluginsDir.resolve("my-plugin.jar"))
val newConfigDir = newTempDir("newConfig")
val newPluginsDir = newConfigDir.resolve("plugins")
options.bundledPluginPath = oldBundledPluginsDir
options.downloadService = object : MarketplacePluginDownloadService() {
override fun downloadPlugin(pluginUrl: String, indicator: ProgressIndicator?): Path =
throw AssertionError("a bundled plugin must not be downloaded during the import")
}
doImport(oldConfigDir, newConfigDir, options)
assertThat(newPluginsDir).doesNotExist()
}
@Test fun `migrate plugins bundled in this ide when the escape hatch is set`() {
val oldConfigDir = newTempDir("oldConfig")
val oldPluginsDir = oldConfigDir.resolve("plugins").createDirectories()
val oldBundledPluginsDir = newTempDir("oldBundled")
plugin("my-plugin") { dependsIntellijModulesLang(); version = "1.0" }.buildMainJar(oldBundledPluginsDir.resolve("my-plugin-bundled.jar"))
plugin("my-plugin") { dependsIntellijModulesLang(); version = "1.1" }.buildMainJar(oldPluginsDir.resolve("my-plugin.jar"))
val newConfigDir = newTempDir("newConfig")
val newPluginsDir = newConfigDir.resolve("plugins")
options.bundledPluginPath = oldBundledPluginsDir
System.setProperty("idea.config.import.migrate.bundled.plugins", "true")
try {
doImport(oldConfigDir, newConfigDir, options)
}
finally {
System.clearProperty("idea.config.import.migrate.bundled.plugins")
}
assertThat(newPluginsDir).isDirectoryContaining { it.name == "my-plugin.jar" }
}
@Test fun `skip broken plugins`() {
val oldConfigDir = newTempDir("oldConfig")
val oldPluginsDir = oldConfigDir.resolve("plugins").createDirectories()
@@ -206,18 +206,27 @@ public final class NetUtils {
private static void updateIndicator(ProgressIndicator indicator, long downloadSpeed, long bytesDownloaded, long contentLength, boolean progressDescription) {
double fraction = (double)bytesDownloaded / contentLength;
if (progressDescription) {
// OpenIDE: this line is centered under the progress bar and updated many times a second, so every field is rendered at
// a constant width — a fixed fraction precision plus left padding to the widest form the field can take. Otherwise the
// whole line shifts sideways whenever a digit is gained or lost (`1,7 MB` -> `194,56 MB`, `9%` -> `100%`).
int rankForContentLength = StringUtil.rankForFileSize(contentLength);
String formattedDownloadSpeed = StringUtil.formatFileSize(downloadSpeed) + "⧸s";
String formattedContentLength = StringUtil.formatFileSize(contentLength, " ", rankForContentLength);
String formattedTotalProgress = StringUtil.formatFileSize(bytesDownloaded, " ", rankForContentLength);
String formattedDownloadSpeed = StringUtil.formatFileSize(downloadSpeed, " ", -1, true) + "⧸s";
String formattedContentLength = StringUtil.formatFileSize(contentLength, " ", rankForContentLength, true);
String formattedTotalProgress =
padToWidth(StringUtil.formatFileSize(bytesDownloaded, " ", rankForContentLength, true), formattedContentLength.length());
@NlsSafe String indicatorText = String.format(
"<html><code>%.0f%% · %s⧸%s · %s</code></html>", fraction * 100,
"<html><code>%s%% · %s⧸%s · %s</code></html>", padToWidth(String.format("%.0f", fraction * 100), 3),
formattedTotalProgress, formattedContentLength, formattedDownloadSpeed);
indicator.setText2(indicatorText);
}
indicator.setFraction(fraction);
}
/// Left-pads with non-breaking spaces, which HTML keeps as written — plain spaces would be collapsed away.
private static String padToWidth(String text, int width) {
return text.length() >= width ? text : "&nbsp;".repeat(width - text.length()) + text;
}
/** @deprecated use {@link #copyStreamContent(ProgressIndicator, InputStream, OutputStream, long)} instead */
@Deprecated(forRemoval = true)
public static int copyStreamContent(