Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
73b01ce
下载模组时展示更新日志
Calboot Nov 19, 2025
cffd3e0
添加模组更新界面的更新日志
Calboot Nov 19, 2025
5a39c98
代码格式
Calboot Nov 20, 2025
3d514fe
Merge branch 'HMCL-dev:main' into mod-changelog
Calboot Nov 20, 2025
5b3a705
中文支持&界面优化
Calboot Nov 20, 2025
404548d
多语言支持
Calboot Nov 21, 2025
d351523
update
Calboot Nov 23, 2025
8fed3b7
Merge branch 'HMCL-dev:main' into mod-changelog
Calboot Nov 23, 2025
36c6661
Merge remote-tracking branch 'upstream/main' into mod-changelog
Calboot Nov 30, 2025
254fa86
Merge remote-tracking branch 'upstream/main' into mod-changelog
Calboot Nov 30, 2025
cb3fc84
update
Calboot Nov 30, 2025
998c6d1
update
Calboot Nov 30, 2025
c9c7ea5
update
Calboot Dec 1, 2025
f501b79
Merge remote-tracking branch 'upstream/main' into mod-changelog
Calboot Dec 12, 2025
71b0aae
Merge remote-tracking branch 'upstream/main' into mod-changelog
Calboot Dec 12, 2025
0973c76
update
Calboot Dec 12, 2025
50a2aeb
HTML support & changelog cache
Calboot Dec 13, 2025
e3f05fb
Markdown
Calboot Dec 13, 2025
b35897b
update
Calboot Dec 19, 2025
1915cfd
Add deps
Calboot Dec 20, 2025
212d8be
update
Calboot Dec 20, 2025
e9cca3e
Merge branch 'HMCL-dev:main' into mod-changelog
Calboot Dec 20, 2025
853ef1f
Merge branch 'HMCL-dev:main' into mod-changelog
Calboot Dec 24, 2025
ce4d12a
Merge branch 'HMCL-dev:main' into mod-changelog
Calboot Jan 1, 2026
1005fcd
Merge branch 'main' into mod-changelog
Calboot Jan 2, 2026
9fd3efc
update
Calboot Jan 2, 2026
5f81a39
update
Calboot Jan 2, 2026
bdf6ef4
update
Calboot Jan 3, 2026
8ad29d1
update
Calboot Jan 3, 2026
95922f1
Merge branch 'main' into mod-changelog
Calboot Jan 4, 2026
1885faf
update
Calboot Jan 4, 2026
926ca53
update i18n
Calboot Jan 5, 2026
25123f3
update style
Calboot Jan 5, 2026
543984a
Merge branch 'main' into mod-changelog
Calboot Jan 5, 2026
5c4c724
add copy link button
Calboot Jan 12, 2026
cb3b7cb
Merge branch 'main' into mod-changelog
Calboot Jan 13, 2026
d09c365
update
Calboot Jan 13, 2026
ed89893
update
Calboot Jan 13, 2026
e51b677
update api
Calboot Jan 17, 2026
aa7c761
update
Calboot Jan 17, 2026
f2f91f6
Merge branch 'main' into mod-changelog
Calboot Jan 18, 2026
8d9b2df
update
Calboot Jan 18, 2026
e2a16b7
Merge branch 'main' into mod-changelog
Calboot Jan 19, 2026
c3e0384
update
Calboot Jan 20, 2026
4058ec1
拆分更新日志和依赖列表
Calboot Jan 20, 2026
2cda56d
update
Calboot Jan 20, 2026
49e96fe
update
Calboot Jan 20, 2026
6e15ca0
update
Calboot Jan 21, 2026
d9ed9eb
update
Calboot Jan 21, 2026
6f46bab
update
Calboot Jan 21, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -128,4 +128,9 @@ public RemoteMod.File getModFile(String modId, String fileId) throws IOException
public Stream<RemoteMod.Version> getRemoteVersionsById(String id) throws IOException {
return getBackedRemoteModRepository().getRemoteVersionsById(id);
}

@Override
public String getModChangelog(String modId, String versionId) throws IOException {
return getBackedRemoteModRepository().getModChangelog(modId, versionId);
}
}
10 changes: 10 additions & 0 deletions HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.SystemUtils;
import org.jetbrains.annotations.Nullable;
import org.jsoup.Jsoup;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
Expand Down Expand Up @@ -1600,4 +1601,13 @@ public static JFXPopup.PopupVPosition determineOptimalPopupPosition(Node root, J
? JFXPopup.PopupVPosition.BOTTOM // Show menu below the button, expanding downward
: JFXPopup.PopupVPosition.TOP; // Show menu above the button, expanding upward
}

public static TextFlow renderAddonChangelog(String changelogHTML) {
HTMLRenderer renderer = HTMLRenderer.openHyperlinkInBrowser();
renderer.appendNode(Jsoup.parse(changelogHTML));
renderer.mergeLineBreaks();
var textFlow = renderer.render();
textFlow.getStyleClass().add("addon-changelog");
return textFlow;
}
}
18 changes: 18 additions & 0 deletions HMCL/src/main/java/org/jackhuang/hmcl/ui/HTMLRenderer.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import javafx.scene.image.ImageView;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.util.StringUtils;
import org.jsoup.nodes.Node;
import org.jsoup.nodes.TextNode;
Expand All @@ -31,12 +32,14 @@
import java.util.List;
import java.util.function.Consumer;

import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;

/**
* @author Glavo
*/
public final class HTMLRenderer {

private static URI resolveLink(Node linkNode) {
String href = linkNode.absUrl("href");
if (href.isEmpty())
Expand All @@ -49,6 +52,21 @@ private static URI resolveLink(Node linkNode) {
}
}

public static HTMLRenderer openHyperlinkInBrowser() {
return new HTMLRenderer(uri -> {
var dialog =
new MessageDialogPane.Builder(
i18n("web.open_in_browser", uri),
i18n("message.confirm"),
MessageDialogPane.MessageType.QUESTION
)
.addAction(i18n("button.copy"), () -> FXUtils.copyText(uri.toString()))
.yesOrNo(() -> FXUtils.openLink(uri.toString()), null)
.build();
Controllers.dialog(dialog);
});
}

private final List<javafx.scene.Node> children = new ArrayList<>();
private final List<Node> stack = new ArrayList<>();

Expand Down
6 changes: 1 addition & 5 deletions HMCL/src/main/java/org/jackhuang/hmcl/ui/WebPage.java
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,7 @@ public WebPage(String title, String content) {

Task.supplyAsync(() -> {
Document document = Jsoup.parseBodyFragment(content);
HTMLRenderer renderer = new HTMLRenderer(uri -> {
Controllers.confirm(i18n("web.open_in_browser", uri), i18n("message.confirm"), () -> {
FXUtils.openLink(uri.toString());
}, null);
});
HTMLRenderer renderer = HTMLRenderer.openHyperlinkInBrowser();
renderer.appendNode(document);
renderer.mergeLineBreaks();
return renderer.render();
Expand Down
121 changes: 93 additions & 28 deletions HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadPage.java
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;

public class DownloadPage extends Control implements DecoratorPage {
private static final WeakHashMap<RemoteMod.Version, String> changelogCache = new WeakHashMap<>();

private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>();
private final BooleanProperty loaded = new SimpleBooleanProperty(false);
private final BooleanProperty loading = new SimpleBooleanProperty(false);
Expand Down Expand Up @@ -159,13 +161,13 @@ public void setFailed(boolean failed) {

public void download(RemoteMod mod, RemoteMod.Version file) {
if (this.callback == null) {
saveAs(mod, file);
saveAs(file);
} else {
this.callback.download(version.getProfile(), version.getVersion(), mod, file);
}
}

public void saveAs(RemoteMod mod, RemoteMod.Version file) {
public void saveAs(RemoteMod.Version file) {
String extension = StringUtils.substringAfterLast(file.getFile().getFilename(), '.');

FileChooser fileChooser = new FileChooser();
Expand Down Expand Up @@ -194,12 +196,12 @@ public ReadOnlyObjectProperty<State> stateProperty() {

@Override
protected Skin<?> createDefaultSkin() {
return new ModDownloadPageSkin(this);
return new DownloadPageSkin(this);
}

private static class ModDownloadPageSkin extends SkinBase<DownloadPage> {
private static class DownloadPageSkin extends SkinBase<DownloadPage> {

protected ModDownloadPageSkin(DownloadPage control) {
protected DownloadPageSkin(DownloadPage control) {
super(control);

VBox pane = new VBox(8);
Expand Down Expand Up @@ -287,7 +289,7 @@ protected ModDownloadPageSkin(DownloadPage control) {
if (targetLoaders.contains(loader)) {
list.getContent().addAll(
ComponentList.createComponentListTitle(i18n("mods.download.recommend", gameVersion)),
new ModItem(control.addon, modVersion, control)
new AddonItem(control.addon, modVersion, control)
);
break resolve;
}
Expand All @@ -299,16 +301,15 @@ protected ModDownloadPageSkin(DownloadPage control) {

for (String gameVersion : control.versions.keys().stream()
.sorted(Collections.reverseOrder(GameVersionNumber::compare))
.collect(Collectors.toList())) {
.toList()) {
List<RemoteMod.Version> versions = control.versions.get(gameVersion);
if (versions == null || versions.isEmpty()) {
continue;
}

ComponentList sublist = new ComponentList(() -> {
ArrayList<ModItem> items = new ArrayList<>(versions.size());
ArrayList<AddonItem> items = new ArrayList<>(versions.size());
for (RemoteMod.Version v : versions) {
items.add(new ModItem(control.addon, v, control));
items.add(new AddonItem(control.addon, v, control));
}
return items;
});
Expand All @@ -324,7 +325,7 @@ protected ModDownloadPageSkin(DownloadPage control) {
}
}

private static final class DependencyModItem extends StackPane {
private static final class DependencyAddonItem extends StackPane {
public static final EnumMap<RemoteMod.DependencyType, String> I18N_KEY = new EnumMap<>(Lang.mapOf(
Pair.pair(RemoteMod.DependencyType.EMBEDDED, "mods.dependency.embedded"),
Pair.pair(RemoteMod.DependencyType.OPTIONAL, "mods.dependency.optional"),
Expand All @@ -335,7 +336,7 @@ private static final class DependencyModItem extends StackPane {
Pair.pair(RemoteMod.DependencyType.BROKEN, "mods.dependency.broken")
));

DependencyModItem(DownloadListPage page, RemoteMod addon, Profile.ProfileVersion version, DownloadCallback callback) {
DependencyAddonItem(DownloadListPage page, RemoteMod addon, Profile.ProfileVersion version, DownloadCallback callback) {
HBox pane = new HBox(8);
pane.setPadding(new Insets(0, 8, 0, 8));
pane.setAlignment(Pos.CENTER_LEFT);
Expand Down Expand Up @@ -372,9 +373,9 @@ private static final class DependencyModItem extends StackPane {
}
}

private static final class ModItem extends StackPane {
private static final class AddonItem extends StackPane {

ModItem(RemoteMod mod, RemoteMod.Version dataItem, DownloadPage selfPage) {
AddonItem(RemoteMod mod, RemoteMod.Version dataItem, DownloadPage selfPage) {
VBox pane = new VBox(8);
pane.setPadding(new Insets(8, 0, 8, 0));

Expand Down Expand Up @@ -436,16 +437,17 @@ private static final class ModItem extends StackPane {
}

RipplerContainer container = new RipplerContainer(pane);
FXUtils.onClicked(container, () -> Controllers.dialog(new ModVersion(mod, dataItem, selfPage)));
FXUtils.onClicked(container, () -> Controllers.dialog(new AddonVersion(mod, dataItem, selfPage)));
getChildren().setAll(container);

// Workaround for https://github.com/HMCL-dev/HMCL/issues/2129
this.setMinHeight(50);
}
}

private static final class ModVersion extends JFXDialogLayout {
public ModVersion(RemoteMod mod, RemoteMod.Version version, DownloadPage selfPage) {
private static final class AddonVersion extends JFXDialogLayout {

public AddonVersion(RemoteMod mod, RemoteMod.Version version, DownloadPage selfPage) {
RemoteModRepository.Type type = selfPage.repository.getType();

String title = switch (type) {
Expand All @@ -455,17 +457,21 @@ public ModVersion(RemoteMod mod, RemoteMod.Version version, DownloadPage selfPag
case SHADER_PACK -> "shaderpack.download.title";
default -> "mods.download.title";
};
this.setHeading(new HBox(new Label(i18n(title, version.getName()))));
this.setHeading(new HBox(new Label(I18n.i18n(title, version.getName()))));

VBox box = new VBox(8);
box.setPadding(new Insets(8));
ModItem modItem = new ModItem(mod, version, selfPage);
modItem.setMouseTransparent(true); // Item is displayed for info, clicking shouldn't open the dialog again
box.getChildren().setAll(modItem);
var addonItem = new AddonItem(mod, version, selfPage);
addonItem.setMouseTransparent(true); // Item is displayed for info, clicking shouldn't open the dialog again
box.getChildren().setAll(addonItem);

Button changelogButton = new JFXButton(i18n("mods.changelog"));
changelogButton.getStyleClass().add("dialog-accept");
SpinnerPane spinnerPane = new SpinnerPane();
ScrollPane scrollPane = new ScrollPane();
ComponentList dependenciesList = new ComponentList(Lang::immutableListOf);
loadDependencies(version, selfPage, spinnerPane, dependenciesList);
loadChangelog(version, selfPage, changelogButton);
spinnerPane.setOnFailedAction(e -> loadDependencies(version, selfPage, spinnerPane, dependenciesList));

scrollPane.setContent(dependenciesList);
Expand Down Expand Up @@ -495,17 +501,17 @@ public ModVersion(RemoteMod mod, RemoteMod.Version version, DownloadPage selfPag
if (!spinnerPane.isLoading() && spinnerPane.getFailedReason() == null) {
fireEvent(new DialogCloseEvent());
}
selfPage.saveAs(mod, version);
selfPage.saveAs(version);
});

JFXButton cancelButton = new JFXButton(i18n("button.cancel"));
cancelButton.getStyleClass().add("dialog-cancel");
cancelButton.setOnAction(e -> fireEvent(new DialogCloseEvent()));

if (downloadButton == null) {
this.setActions(saveAsButton, cancelButton);
this.setActions(changelogButton, saveAsButton, cancelButton);
} else {
this.setActions(downloadButton, saveAsButton, cancelButton);
this.setActions(changelogButton, downloadButton, saveAsButton, cancelButton);
}

this.prefWidthProperty().bind(BindingMapping.of(Controllers.getStage().widthProperty()).map(w -> w.doubleValue() * 0.7));
Expand All @@ -525,27 +531,86 @@ private void loadDependencies(RemoteMod.Version version, DownloadPage selfPage,

if (!dependencies.containsKey(dependency.getType())) {
List<Node> list = new ArrayList<>();
Label title = new Label(i18n(DependencyModItem.I18N_KEY.get(dependency.getType())));
Label title = new Label(i18n(DependencyAddonItem.I18N_KEY.get(dependency.getType())));
title.setPadding(new Insets(0, 8, 0, 8));
list.add(title);
dependencies.put(dependency.getType(), list);
}
DependencyModItem dependencyModItem = new DependencyModItem(selfPage.page, dependency.load(), selfPage.version, selfPage.callback);
dependencies.get(dependency.getType()).add(dependencyModItem);
DependencyAddonItem dependencyAddonItem = new DependencyAddonItem(selfPage.page, dependency.load(), selfPage.version, selfPage.callback);
dependencies.get(dependency.getType()).add(dependencyAddonItem);
}

return dependencies.values().stream().flatMap(Collection::stream).collect(Collectors.toList());
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
spinnerPane.setLoading(false);
if (exception == null) {
dependenciesList.getContent().setAll(result);
spinnerPane.setFailedReason(null);
} else {
dependenciesList.getContent().setAll();
spinnerPane.setFailedReason(i18n("download.failed.refresh"));
}
spinnerPane.setLoading(false);
}).start();
}

private void loadChangelog(RemoteMod.Version version, DownloadPage selfPage, Button changelogButton) {
changelogButton.setDisable(true);
Task.supplyAsync(() -> {
if (changelogCache.containsKey(version)) {
return Optional.ofNullable(changelogCache.get(version));
} else if (version.getChangelog() != null) {
return StringUtils.nullIfBlank(version.getChangelog());
} else {
return StringUtils.nullIfBlank(selfPage.repository.getModChangelog(version.getModid(), version.getVersionId()));
}
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
if (result.isPresent()) {
String s = StringUtils.markdownToHTML(result.get());
changelogCache.put(version, s);
changelogButton.setDisable(false);
changelogButton.setOnAction(e -> Controllers.dialog(new AddonChangelog(version, s)));
} else {
changelogCache.put(version, null);
changelogButton.setOnAction(null);
}
} else {
changelogButton.setOnAction(null);
}
}).start();
}
}

private static final class AddonChangelog extends JFXDialogLayout {

public AddonChangelog(RemoteMod.Version version, String changelog) {
setHeading(new HBox(new Label(i18n("mods.changelog") + " - " + version.getName())));

VBox box = new VBox(8);
box.setPadding(new Insets(8));

SpinnerPane spinnerPane = new SpinnerPane();
ScrollPane scrollPane = new ScrollPane();
scrollPane.setFitToWidth(true);
scrollPane.setContent(FXUtils.renderAddonChangelog(changelog));

spinnerPane.setContent(scrollPane);
box.getChildren().add(spinnerPane);
VBox.setVgrow(spinnerPane, Priority.SOMETIMES);

this.setBody(box);

JFXButton closeButton = new JFXButton(i18n("button.ok"));
closeButton.getStyleClass().add("dialog-accept");
closeButton.setOnAction(e -> fireEvent(new DialogCloseEvent()));

setActions(closeButton);

this.prefWidthProperty().bind(BindingMapping.of(Controllers.getStage().widthProperty()).map(w -> w.doubleValue() * 0.7));
this.prefHeightProperty().bind(BindingMapping.of(Controllers.getStage().heightProperty()).map(w -> w.doubleValue() * 0.7));

onEscPressed(this, closeButton::fire);
}
}

public interface DownloadCallback {
Expand Down
Loading