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 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(