diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/AddonCheckUpdatesTask.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/AddonCheckUpdatesTask.java index 531bbaa94f..bada188589 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/AddonCheckUpdatesTask.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/AddonCheckUpdatesTask.java @@ -50,7 +50,7 @@ public AddonCheckUpdatesTask(DownloadProvider downloadProvider, String gameVersi continue; } - if (candidate == null || candidate.targetVersion().datePublished().isBefore(update.targetVersion().datePublished())) { + if (candidate == null || candidate.latestAvailableVersion().datePublished().isBefore(update.latestAvailableVersion().datePublished())) { candidate = update; } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/AddonUpdatesPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/AddonUpdatesPage.java index b48622eb98..9af8eeaea9 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/AddonUpdatesPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/AddonUpdatesPage.java @@ -19,6 +19,7 @@ import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXCheckBox; +import com.jfoenix.controls.JFXComboBox; import com.jfoenix.controls.JFXDialogLayout; import javafx.beans.property.*; import javafx.beans.value.ObservableValue; @@ -27,10 +28,7 @@ import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.control.*; -import javafx.scene.layout.BorderPane; -import javafx.scene.layout.HBox; -import javafx.scene.layout.Priority; -import javafx.scene.layout.VBox; +import javafx.scene.layout.*; import org.jackhuang.hmcl.addon.LocalAddonFile; import org.jackhuang.hmcl.addon.LocalAddonManager; import org.jackhuang.hmcl.addon.RemoteAddon; @@ -43,6 +41,7 @@ import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.construct.*; import org.jackhuang.hmcl.ui.decorator.DecoratorPage; +import org.jackhuang.hmcl.util.FXThread; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.TaskCancellationAction; import org.jackhuang.hmcl.util.io.CSVTable; @@ -55,6 +54,8 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; import java.util.stream.Collectors; @@ -99,7 +100,7 @@ public AddonUpdatesPage(LocalAddonManager localAddonManager, List sourceColumn = new TableColumn<>(i18n("addon.check_update.source")); setupCellValueFactory(sourceColumn, AddonUpdateObject::sourceProperty); - TableColumn changelogColumn = new TableColumn<>(i18n("addon.changelog")); + TableColumn changelogColumn = new TableColumn<>(i18n("addon.select_target_version")); { var oldCellFactory = changelogColumn.getCellFactory(); changelogColumn.setCellFactory(param -> { @@ -153,7 +154,6 @@ private void updateFiles() { localAddonManager.getDirectory(), objects.stream() .filter(AddonUpdateObject::isEnabled) - .map(AddonUpdateObject::getData) .toList() ); Controllers.taskDialog( @@ -186,10 +186,10 @@ private void exportList() { csvTable.set(3, 0, "Update Source"); for (int i = 0; i < objects.size(); i++) { - csvTable.set(0, i + 1, objects.get(i).fileName.get()); - csvTable.set(1, i + 1, objects.get(i).currentVersion.get()); - csvTable.set(2, i + 1, objects.get(i).targetVersion.get()); - csvTable.set(3, i + 1, objects.get(i).source.get()); + csvTable.set(0, i + 1, objects.get(i).getFileName()); + csvTable.set(1, i + 1, objects.get(i).getCurrentVersion()); + csvTable.set(2, i + 1, objects.get(i).getTargetVersion()); + csvTable.set(3, i + 1, objects.get(i).getSource()); } csvTable.write(path); @@ -211,26 +211,25 @@ public ReadOnlyObjectWrapper stateProperty() { private static final class AddonUpdateObject { final LocalAddonFile.AddonUpdate data; + final ObjectProperty targetVersionObject = new SimpleObjectProperty<>(); final BooleanProperty enabled = new SimpleBooleanProperty(); final StringProperty fileName = new SimpleStringProperty(); final StringProperty currentVersion = new SimpleStringProperty(); final StringProperty targetVersion = new SimpleStringProperty(); final StringProperty source = new SimpleStringProperty(); - String changelog = null; public AddonUpdateObject(LocalAddonFile.AddonUpdate data) { this.data = data; + this.targetVersionObject.set(data.latestAvailableVersion()); enabled.set(!data.localAddonFile().isDisabled()); fileName.set(data.localAddonFile().getFileName()); currentVersion.set(data.currentVersion().version()); - targetVersion.set(data.targetVersion().version()); + targetVersion.set(targetVersionObject.get().version()); + FXUtils.onChange(targetVersionObject, (value) -> targetVersion.set(value.version())); switch (data.currentVersion().self().getSource()) { - case CURSEFORGE: - source.set(i18n("addon.curseforge")); - break; - case MODRINTH: - source.set(i18n("addon.modrinth")); + case CURSEFORGE -> source.set(i18n("addon.curseforge")); + case MODRINTH -> source.set(i18n("addon.modrinth")); } } @@ -300,23 +299,57 @@ public void setSource(String source) { } private static final class AddonChangelog extends JFXDialogLayout { + private final Map changelogCache = new ConcurrentHashMap<>(); + /// ID of the most recently started changelog request. Only the result of this request may update the UI. + @FXThread + private int changelogRequestId = 0; + /// ID of the most recently started version page request. Only the result of this request may update the UI. + @FXThread + private int versionPageRequestId = 0; public AddonChangelog(AddonUpdateObject object) { - RemoteAddon.Version targetVersion = object.data.targetVersion(); + List availableVersions = object.data.availableVersions(); - this.setHeading(new HBox(new Label(i18n("addon.changelog") + " - " + targetVersion.name()))); + Label headingLabel = new Label(i18n("addon.select_target_version")); + this.setHeading(new HBox(8, headingLabel)); VBox box = new VBox(8); box.setPadding(new Insets(8)); + // Version selector ComboBox + JFXComboBox versionComboBox = new JFXComboBox<>(); + versionComboBox.getItems().setAll(availableVersions); + versionComboBox.getSelectionModel().select(object.targetVersionObject.get()); + FXUtils.onChange(versionComboBox.getSelectionModel().selectedItemProperty(), object.targetVersionObject::set); + versionComboBox.setConverter(FXUtils.stringConverter(RemoteAddon.Version::name)); + HBox.setHgrow(versionComboBox, Priority.ALWAYS); + + Label selectVersionLabel = new Label(i18n("addon.changelog.view_other_log")); + + HBox versionSelector = new HBox(8, selectVersionLabel, versionComboBox); + versionSelector.setAlignment(Pos.CENTER_LEFT); + versionSelector.setPadding(new Insets(0, 0, 4, 0)); + box.getChildren().add(versionSelector); + SpinnerPane spinnerPane = new SpinnerPane(); ScrollPane scrollPane = new ScrollPane(); scrollPane.setFitToWidth(true); scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); FXUtils.setOverflowHidden(scrollPane, 8); - loadChangelog(object, spinnerPane, scrollPane); - spinnerPane.setOnFailedAction(e -> loadChangelog(object, spinnerPane, scrollPane)); + loadChangelog(object, versionComboBox.getSelectionModel().getSelectedItem(), spinnerPane, scrollPane); + spinnerPane.setOnFailedAction(e -> loadChangelog(object, versionComboBox.getSelectionModel().getSelectedItem(), spinnerPane, scrollPane)); + + JFXHyperlink versionPageBtn = new JFXHyperlink(i18n("mods.url")); + versionPageBtn.setDisable(true); + loadVersionPageUrl(object, versionPageBtn, versionComboBox.getSelectionModel().getSelectedItem()); + + versionComboBox.getSelectionModel().selectedItemProperty().addListener((obs, oldVersion, newVersion) -> { + if (newVersion != null) { + loadChangelog(object, newVersion, spinnerPane, scrollPane); + loadVersionPageUrl(object, versionPageBtn, newVersion); + } + }); spinnerPane.setContent(scrollPane); box.getChildren().add(spinnerPane); @@ -324,10 +357,6 @@ public AddonChangelog(AddonUpdateObject object) { this.setBody(box); - JFXHyperlink versionPageBtn = new JFXHyperlink(i18n("mods.url")); - versionPageBtn.setDisable(true); - loadVersionPageUrl(object, versionPageBtn); - JFXButton closeButton = new JFXButton(i18n("button.ok")); closeButton.getStyleClass().add("dialog-accept"); closeButton.setOnAction(e -> fireEvent(new DialogCloseEvent())); @@ -340,23 +369,34 @@ public AddonChangelog(AddonUpdateObject object) { onEscPressed(this, closeButton::fire); } - private void loadChangelog(AddonUpdateObject object, SpinnerPane spinnerPane, ScrollPane scrollPane) { + private void loadChangelog(AddonUpdateObject object, RemoteAddon.Version version, SpinnerPane spinnerPane, ScrollPane scrollPane) { + if (version == null) return; + // will anyone actually change selected version 10000000+ times? + int requestId = ++changelogRequestId; spinnerPane.setLoading(true); RemoteAddonRepository repo = object.data.source().getRepoForType(object.data.repoType()); Task.supplyAsync(() -> { - if (object.changelog != null) { - return object.changelog; + String cached = changelogCache.get(version.versionId()); + if (cached != null) { + return cached; } - RemoteAddon.Version version = object.data.targetVersion(); if (repo == null) return null; return StringUtils.convertToHtml( repo.getAddonChangelog(DownloadProviders.getDownloadProvider(), version.projectId(), version.versionId()), - "238222".equals(object.data.targetVersion().projectId()) + "238222".equals(version.projectId()) ); }).whenComplete(Schedulers.javafx(), (result, exception) -> { + if (requestId != changelogRequestId) { + // A newer request has been started, discard this stale result + // NOTE: comparing version might not be enough: selecting A -> B -> A makes two + // requests for the same version, and the older one may finish last. + return; + } + if (exception == null) { - object.changelog = StringUtils.isNotBlank(result) ? result : i18n("addon.changelog.empty"); - scrollPane.setContent(FXUtils.renderAddonChangelog(object.changelog, repo == null ? "" : repo.getBaseUrl())); + String html = StringUtils.isNotBlank(result) ? result : i18n("addon.changelog.empty"); + changelogCache.put(version.versionId(), html); + scrollPane.setContent(FXUtils.renderAddonChangelog(html, repo == null ? "" : repo.getBaseUrl())); FXUtils.smoothScrolling(scrollPane); spinnerPane.setFailedReason(null); } else { @@ -366,15 +406,23 @@ private void loadChangelog(AddonUpdateObject object, SpinnerPane spinnerPane, Sc }).start(); } - private void loadVersionPageUrl(AddonUpdateObject object, JFXHyperlink button) { + private void loadVersionPageUrl(AddonUpdateObject object, JFXHyperlink button, RemoteAddon.Version version) { + button.setDisable(true); + int requestId = ++versionPageRequestId; Task.supplyAsync(() -> { RemoteAddonRepository repo = object.data.source().getRepoForType(object.data.repoType()); - return repo == null ? null : repo.getVersionPageUrl(object.data.targetVersion()); + return repo == null ? null : repo.getVersionPageUrl(version); }).whenComplete(Schedulers.javafx(), (result, exception) -> { + if (requestId != versionPageRequestId) { + // A newer request has been started, discard this stale result + return; + } + if (exception == null && StringUtils.isNotBlank(result)) { button.setExternalLink(result); button.setDisable(false); } else { + button.setDisable(true); LOG.warning("Failed to load addon version page url", exception); } }).start(); @@ -385,21 +433,21 @@ public static class AddonUpdateTask extends Task { private final Collection> dependents; private final List failedAddons = new ArrayList<>(); - AddonUpdateTask(Path addonDirectory, List addons) { + AddonUpdateTask(Path addonDirectory, List addons) { setStage("addon.check_update.confirm"); getProperties().put("total", addons.size()); this.dependents = new ArrayList<>(); - for (LocalAddonFile.AddonUpdate addon : addons) { - LocalAddonFile local = addon.localAddonFile(); - RemoteAddon.Version remote = addon.targetVersion(); + for (var addon : addons) { + LocalAddonFile local = addon.data.localAddonFile(); + RemoteAddon.Version remote = addon.targetVersionObject.get(); boolean isDisabled = local.isDisabled(); String originalFileName = local.getFile().getFileName().toString(); dependents.add(Task .runAsync(Schedulers.javafx(), () -> local.setOld(true)) .thenComposeAsync(() -> { - String fileName = addon.useRemoteFileName() ? remote.file().filename() : originalFileName; + String fileName = addon.data.useRemoteFileName() ? remote.file().filename() : originalFileName; if (isDisabled) fileName = StringUtils.addSuffix(fileName, LocalAddonManager.DISABLED_EXTENSION); diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 4e4a249359..1caf56b024 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -164,6 +164,8 @@ account.username=Username addon.category=Category addon.changelog=Changelog addon.changelog.empty=Currently no changelog +addon.changelog.view_other_log=Select version +addon.select_target_version=Select target version addon.channel.alpha=Alpha addon.channel.beta=Beta addon.channel.release=Release diff --git a/HMCL/src/main/resources/assets/lang/I18N_de.properties b/HMCL/src/main/resources/assets/lang/I18N_de.properties index 4268742475..335bf32e12 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_de.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_de.properties @@ -165,6 +165,8 @@ addon.broken_dependency.desc=Diese Abhängigkeit existierte vorher, ist aber nic addon.category=Kategorie addon.changelog=Änderungsprotokoll addon.changelog.empty=Derzeit kein Änderungsprotokoll +addon.changelog.view_other_log=Version auswählen +addon.select_target_version=Zielversion auswählen addon.channel.alpha=Alpha addon.channel.beta=Beta addon.channel.release=Veröffentlichung diff --git a/HMCL/src/main/resources/assets/lang/I18N_lzh.properties b/HMCL/src/main/resources/assets/lang/I18N_lzh.properties index 351766e2b2..e715b43499 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_lzh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_lzh.properties @@ -139,7 +139,9 @@ account.skin.invalid_skin=外觀之案有謬 account.username=戶名 addon.category=類 -addon.changelog=迭更誌 +addon.changelog=改囊之誌 +addon.changelog.view_other_log=擇版 +addon.select_target_version=擇標的之版 addon.channel.alpha=預版 addon.channel.beta=試版 addon.channel.release=當版 diff --git a/HMCL/src/main/resources/assets/lang/I18N_uk.properties b/HMCL/src/main/resources/assets/lang/I18N_uk.properties index 409f9e2710..08b4a03aef 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_uk.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_uk.properties @@ -170,6 +170,8 @@ account.username=Ім'я користувача addon.category=Категорія addon.changelog=Список змін addon.changelog.empty=Наразі немає списку змін +addon.changelog.view_other_log=Оберіть версію +addon.select_target_version=Оберіть цільову версію addon.channel.alpha=Альфа addon.channel.beta=Бета addon.channel.release=Реліз diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 5f1deab069..1f3ec08aae 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -162,6 +162,8 @@ account.username=使用者名稱 addon.category=類別 addon.changelog=更新日誌 addon.changelog.empty=暫無更新日誌 +addon.changelog.view_other_log=選擇版本 +addon.select_target_version=選擇目標版本 addon.channel.alpha=Alpha addon.channel.beta=Beta addon.channel.release=Release diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties index 890f9b9372..924d16f684 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -164,6 +164,8 @@ account.username=用户名 addon.category=类别 addon.changelog=更新日志 addon.changelog.empty=暂无更新日志 +addon.changelog.view_other_log=选择版本 +addon.select_target_version=选择目标版本 addon.channel.alpha=快照版本 addon.channel.beta=测试版本 addon.channel.release=稳定版本 diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/LocalAddonFile.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/LocalAddonFile.java index 0537e4a450..70e588a00d 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/LocalAddonFile.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/LocalAddonFile.java @@ -64,9 +64,12 @@ public record AddonUpdate( RemoteAddon.Type repoType, LocalAddonFile localAddonFile, RemoteAddon.Version currentVersion, - RemoteAddon.Version targetVersion, + List availableVersions, boolean useRemoteFileName ) { + public RemoteAddon.Version latestAvailableVersion() { + return availableVersions.get(0); + } } public static class Description { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/LocalModFile.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/LocalModFile.java index ab36482686..b727541023 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/LocalModFile.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/LocalModFile.java @@ -207,7 +207,7 @@ public AddonUpdate checkUpdates(DownloadProvider downloadProvider, String gameVe .sorted(Comparator.comparing(RemoteAddon.Version::datePublished).reversed()) .toList(); if (remoteVersions.isEmpty()) return null; - return new AddonUpdate(source, RemoteAddon.Type.MOD, this, currentVersion.get(), remoteVersions.get(0), true); + return new AddonUpdate(source, RemoteAddon.Type.MOD, this, currentVersion.get(), remoteVersions, true); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/resourcepack/ResourcePackZipFile.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/resourcepack/ResourcePackZipFile.java index 4b900350e1..b4267d9a07 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/resourcepack/ResourcePackZipFile.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/resourcepack/ResourcePackZipFile.java @@ -96,7 +96,7 @@ public AddonUpdate checkUpdates(DownloadProvider downloadProvider, String gameVe .sorted(Comparator.comparing(RemoteAddon.Version::datePublished).reversed()) .toList(); if (remoteVersions.isEmpty()) return null; - return new AddonUpdate(source, RemoteAddon.Type.RESOURCE_PACK, this, currentVersion.get(), remoteVersions.get(0), false); + return new AddonUpdate(source, RemoteAddon.Type.RESOURCE_PACK, this, currentVersion.get(), remoteVersions, false); } }