diff --git a/platform/platform-api/resources/messages/IdeBundle.properties b/platform/platform-api/resources/messages/IdeBundle.properties
index e58f66656b17..82dbfa448062 100644
--- a/platform/platform-api/resources/messages/IdeBundle.properties
+++ b/platform/platform-api/resources/messages/IdeBundle.properties
@@ -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}
\
\nApache Velocity \
diff --git a/platform/platform-impl/initial-config-import/src/com/intellij/openapi/application/ConfigImportHelper.java b/platform/platform-impl/initial-config-import/src/com/intellij/openapi/application/ConfigImportHelper.java
index 88449003a5f1..ac1020989776 100644
--- a/platform/platform-impl/initial-config-import/src/com/intellij/openapi/application/ConfigImportHelper.java
+++ b/platform/platform-impl/initial-config-import/src/com/intellij/openapi/application/ConfigImportHelper.java
@@ -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();
- 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();
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 collectBundledPluginVersions(PluginsDiscoveryResult discoveryResult) {
+ var result = new HashMap();
+ 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 List dropPluginsBundledInThisIde(
+ Collection extends T> descriptors,
+ Map bundledPluginVersions,
+ Logger log
+ ) {
+ if (Boolean.getBoolean(MIGRATE_BUNDLED_PLUGINS_PROPERTY)) {
+ return new ArrayList<>(descriptors);
+ }
+
+ var result = new ArrayList(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 collectPendingPluginUpdates(List actionCommands, FileSystem fs, Logger log) {
var result = new LinkedHashSet();
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);
diff --git a/platform/platform-impl/initial-config-import/src/com/intellij/openapi/application/ConfigImportProgressDialog.java b/platform/platform-impl/initial-config-import/src/com/intellij/openapi/application/ConfigImportProgressDialog.java
index 4f6049df0f6e..e20185b541e0 100644
--- a/platform/platform-impl/initial-config-import/src/com/intellij/openapi/application/ConfigImportProgressDialog.java
+++ b/platform/platform-impl/initial-config-import/src/com/intellij/openapi/application/ConfigImportProgressDialog.java
@@ -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("42% · 276 MB");
+ dialog.getIndicator().setFraction(0.42);
dialog.setVisible(true);
}
}
diff --git a/platform/platform-impl/src/com/intellij/ide/plugins/marketplace/MarketplacePluginDownloadService.kt b/platform/platform-impl/src/com/intellij/ide/plugins/marketplace/MarketplacePluginDownloadService.kt
index 95b933c45c33..20e0868d6f58 100644
--- a/platform/platform-impl/src/com/intellij/ide/plugins/marketplace/MarketplacePluginDownloadService.kt
+++ b/platform/platform-impl/src/com/intellij/ide/plugins/marketplace/MarketplacePluginDownloadService.kt
@@ -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()
@@ -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)
diff --git a/platform/platform-tests/testSrc/com/intellij/openapi/application/ConfigImportHelperTest.kt b/platform/platform-tests/testSrc/com/intellij/openapi/application/ConfigImportHelperTest.kt
index 4150bf594912..350cfb460f8b 100644
--- a/platform/platform-tests/testSrc/com/intellij/openapi/application/ConfigImportHelperTest.kt
+++ b/platform/platform-tests/testSrc/com/intellij/openapi/application/ConfigImportHelperTest.kt
@@ -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()
diff --git a/platform/platform-util-io/src/com/intellij/util/net/NetUtils.java b/platform/platform-util-io/src/com/intellij/util/net/NetUtils.java
index e7f245b07a35..06f045ca3139 100644
--- a/platform/platform-util-io/src/com/intellij/util/net/NetUtils.java
+++ b/platform/platform-util-io/src/com/intellij/util/net/NetUtils.java
@@ -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(
- "%.0f%% · %s⧸%s · %s", fraction * 100,
+ "%s%% · %s⧸%s · %s", 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 : " ".repeat(width - text.length()) + text;
+ }
+
/** @deprecated use {@link #copyStreamContent(ProgressIndicator, InputStream, OutputStream, long)} instead */
@Deprecated(forRemoval = true)
public static int copyStreamContent(