Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 50 additions & 39 deletions HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadPage.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,8 @@
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Control;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.Skin;
import javafx.scene.control.SkinBase;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
Expand All @@ -54,26 +46,15 @@
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.SimpleMultimap;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.Nullable;

import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.List;
import java.util.Set;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;

Expand Down Expand Up @@ -313,25 +294,55 @@ protected ModDownloadPageSkin(DownloadPage control) {
}
}

for (String gameVersion : control.versions.keys().stream()
.sorted(Collections.reverseOrder(GameVersionNumber::compare))
.toList()) {
List<RemoteMod.Version> versions = control.versions.get(gameVersion);
if (versions == null || versions.isEmpty()) {
continue;
final class Versions {
static ComponentSublist createSublist(DownloadPage control, String title, List<String> target) {
var sublist = new ComponentSublist(() -> {
ArrayList<ModItem> items = new ArrayList<>();
for (String gv : target) {
List<RemoteMod.Version> versions = control.versions.get(gv);
if (versions != null) {
for (RemoteMod.Version v : versions) {
items.add(new ModItem(control.addon, v, control));
Comment thread
Glavo marked this conversation as resolved.
}
}
}
Comment on lines +299 to +308
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sorting of versions within each group (releases and snapshots) is not preserved. The target lists are built by iterating through control.versions.keys(), which may not maintain the original sort order. Consider sorting the releases and snapshots lists before creating ModItems to ensure versions are displayed in the correct order (newest first).

Copilot uses AI. Check for mistakes.
Comment on lines +301 to +308
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sublist aggregates multiple game-version buckets into one list, but sortVersions() stores the same RemoteMod.Version under every compatible game version. As a result, the same mod file can appear multiple times in this combined list (and ordering becomes dependent on key iteration order). Consider de-duplicating by a stable identifier (e.g., file URL/hash or getSelf()), then sorting the merged list by datePublished before creating ModItems.

Suggested change
for (String gv : target) {
List<RemoteMod.Version> versions = control.versions.get(gv);
if (versions != null) {
for (RemoteMod.Version v : versions) {
items.add(new ModItem(control.addon, v, control));
}
}
}
// Merge versions from all matching game-version buckets, de-duplicating
// by a stable identifier (e.g., the "self" URL), then sort by datePublished.
Map<Object, RemoteMod.Version> uniqueVersions = new LinkedHashMap<>();
for (String gv : target) {
List<RemoteMod.Version> versions = control.versions.get(gv);
if (versions != null) {
for (RemoteMod.Version v : versions) {
if (v == null) continue;
Object key = v.getSelf() != null ? v.getSelf() : v;
uniqueVersions.putIfAbsent(key, v);
}
}
}
List<RemoteMod.Version> sortedVersions = new ArrayList<>(uniqueVersions.values());
sortedVersions.sort(Comparator.comparing(
RemoteMod.Version::getDatePublished,
Comparator.nullsLast(Comparator.naturalOrder())
).reversed());
for (RemoteMod.Version v : sortedVersions) {
items.add(new ModItem(control.addon, v, control));
}

Copilot uses AI. Check for mistakes.
return items;
});

sublist.setTitle(title);
sublist.getStyleClass().add("no-padding");

return sublist;
}

var sublist = new ComponentSublist(() -> {
ArrayList<ModItem> items = new ArrayList<>(versions.size());
for (RemoteMod.Version v : versions) {
items.add(new ModItem(control.addon, v, control));
}
return items;
});
sublist.getStyleClass().add("no-padding");
sublist.setTitle("Minecraft " + gameVersion);
final List<String> releases = new ArrayList<>();
final List<String> snapshots = new ArrayList<>();
}

TreeMap<GameVersionNumber, Versions> map = new TreeMap<>(Collections.reverseOrder());
for (String version : control.versions.keys()) {
GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(version);
GameVersionNumber releaseOfSnapshot = GameVersionNumber.getReleaseOfSnapshot(gameVersion);
GameVersionNumber releaseVersion = Objects.requireNonNullElse(releaseOfSnapshot, gameVersion);

Versions lists = map.computeIfAbsent(releaseVersion, v -> new Versions());

if (releaseOfSnapshot == null) {
lists.releases.add(version);
} else {
lists.snapshots.add(version);
}
Comment thread
Glavo marked this conversation as resolved.
}
Comment thread
Glavo marked this conversation as resolved.

for (Map.Entry<GameVersionNumber, Versions> entry : map.entrySet()) {
GameVersionNumber gameVersion = entry.getKey();
Versions versions = entry.getValue();

if (!versions.releases.isEmpty())
list.getContent().add(Versions.createSublist(control, i18n("addon.download.title.release", gameVersion), versions.releases));

list.getContent().add(sublist);
if (!versions.snapshots.isEmpty())
list.getContent().add(Versions.createSublist(control, i18n("addon.download.title.snapshot", gameVersion), versions.snapshots));
}
});
}
Expand Down
3 changes: 3 additions & 0 deletions HMCL/src/main/resources/assets/lang/I18N.properties
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,9 @@ account.skin.upload.failed=Failed to upload skin.
account.skin.invalid_skin=Invalid skin file.
account.username=Username

addon.download.title.release=Minecraft %s
addon.download.title.snapshot=Minecraft %s (Snapshots)

archive.author=Author(s)
archive.date=Publish Date
archive.file.name=File Name
Expand Down
3 changes: 3 additions & 0 deletions HMCL/src/main/resources/assets/lang/I18N_zh.properties
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,9 @@ account.skin.upload.failed=外觀上傳失敗
account.skin.invalid_skin=無法識別的外觀檔案
account.username=使用者名稱

addon.download.title.release=Minecraft %s
addon.download.title.snapshot=Minecraft %s (快照)

archive.author=作者
archive.date=發布日期
archive.file.name=檔案名稱
Expand Down
3 changes: 3 additions & 0 deletions HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,9 @@ account.skin.upload.failed=皮肤上传失败
account.skin.invalid_skin=无法识别的皮肤文件
account.username=用户名

addon.download.title.release=Minecraft %s
addon.download.title.snapshot=Minecraft %s (快照)

archive.author=作者
archive.date=发布日期
archive.file.name=文件名
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

import org.jackhuang.hmcl.util.ToStringBuilder;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import java.io.BufferedReader;
import java.io.IOException;
Expand Down Expand Up @@ -189,6 +190,32 @@ public final String toDebugString() {
return buildDebugString().toString();
}

public static @Nullable GameVersionNumber getReleaseOfSnapshot(GameVersionNumber gameVersion) {
if (gameVersion instanceof Release release) {
if (release.getEaType() == Release.ReleaseType.GA) {
return null;
}
if (release.getPatch() > 0) {
return asGameVersion(release.getMajor() + "." + release.getMinor() + "." + release.getPatch());
} else {
return asGameVersion(release.getMajor() + "." + release.getMinor());
}
}

if (gameVersion instanceof LegacySnapshot snapshot) {
String[] defaultVersions = Versions.DEFAULT_GAME_VERSIONS;
for (int i = defaultVersions.length - 1; i >= 0; i--) {
Release gaRelease = (Release) asGameVersion(defaultVersions[i]);

if (gaRelease.compareToSnapshot(snapshot) > 0) {
return gaRelease;
}
}
}

Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method getReleaseOfSnapshot does not handle Special versions (e.g., April Fools versions like "20w14infinite"). These versions will fall through and return null, which is consistent with the test case on line 516. However, it would be beneficial to add a code comment explaining this behavior for maintainability.

Suggested change
// Special or non-standard versions (e.g. April Fools snapshots like "20w14infinite")
// are intentionally not mapped to a GA release and will fall through to return null.

Copilot uses AI. Check for mistakes.
return null;
}
Comment thread
Glavo marked this conversation as resolved.

public static final class Old extends GameVersionNumber {
static Old parse(String value) {
if (value.isEmpty())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import java.util.function.Supplier;

import static org.jackhuang.hmcl.util.versioning.GameVersionNumber.asGameVersion;
import static org.jackhuang.hmcl.util.versioning.GameVersionNumber.getReleaseOfSnapshot;
import static org.junit.jupiter.api.Assertions.*;

/**
Expand Down Expand Up @@ -507,4 +508,14 @@ public void isAtLeast() {
assertThrows(IllegalArgumentException.class, () -> asGameVersion("17w43a").isAtLeast("1.13", "22w13oneblockatatime", true));
assertThrows(IllegalArgumentException.class, () -> asGameVersion("17w43a").isAtLeast("1.13", "22w13oneblockatatime", false));
}

@Test
public void testGetReleaseOfSnapshot() {
assertEquals(asGameVersion("1.21.11"), getReleaseOfSnapshot(asGameVersion("25w45a")));
assertEquals(asGameVersion("1.21.11"), getReleaseOfSnapshot(asGameVersion("25w45a_unobfuscated")));
assertEquals(asGameVersion("1.21.11"), getReleaseOfSnapshot(asGameVersion("1.21.11-pre3")));
assertEquals(asGameVersion("26.1"), getReleaseOfSnapshot(asGameVersion("26.1-snapshot-9")));
assertNull(getReleaseOfSnapshot(asGameVersion("26.1")));
assertNull(getReleaseOfSnapshot(asGameVersion("20w14infinite")));
}
}