From ab5c053b834b7fe5c2a998b76e32c1e9188101c2 Mon Sep 17 00:00:00 2001 From: lokins Date: Sat, 27 Jun 2026 00:42:36 +0800 Subject: [PATCH 1/8] =?UTF-8?q?[Feature]=20=E8=AE=A9HMCL=E5=8F=AF=E4=BB=A5?= =?UTF-8?q?=E8=AF=86=E5=88=AB=E5=88=B0JIJ=E7=9A=84=E6=89=93=E5=8C=85?= =?UTF-8?q?=E5=BD=A2=E5=BC=8F=E5=B9=B6=E4=BB=A5=E4=B8=8B=E6=8B=89=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E7=9A=84=E5=BD=A2=E5=BC=8F=E6=98=BE=E7=A4=BA=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E6=94=AF=E6=8C=81=E6=90=9C=E7=B4=A2=E5=92=8C=E5=AF=BC?= =?UTF-8?q?=E5=87=BAJIJ=E4=BF=A1=E6=81=AF=EF=BC=8C=E4=B8=94=E5=9C=A8?= =?UTF-8?q?=E5=B4=A9=E6=BA=83=E6=97=B6=E8=8B=A5=E6=9C=89JIJ=E4=BF=A1?= =?UTF-8?q?=E6=81=AF=E5=88=99=E4=B8=80=E5=B9=B6=E6=89=93=E5=8C=85=E8=87=B3?= =?UTF-8?q?=E5=8E=8B=E7=BC=A9=E5=8C=85=E5=86=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/jackhuang/hmcl/game/LogExporter.java | 56 ++++++++++++++ .../main/java/org/jackhuang/hmcl/ui/SVG.java | 1 + .../hmcl/ui/versions/ModListPageSkin.java | 75 +++++++++++++++++-- HMCL/src/main/resources/assets/css/root.css | 17 +++++ .../resources/assets/lang/I18N.properties | 1 + .../resources/assets/lang/I18N_zh.properties | 1 + .../assets/lang/I18N_zh_CN.properties | 1 + .../hmcl/addon/meta/FabricModMetadata.java | 24 +++++- .../hmcl/addon/meta/ForgeNewModMetadata.java | 17 ++++- .../hmcl/addon/meta/QuiltModMetadata.java | 28 ++++++- .../hmcl/addon/mod/LocalModFile.java | 14 ++++ 11 files changed, 222 insertions(+), 13 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java index 3ace4df057d..4ecfa9f21cd 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java @@ -17,6 +17,8 @@ */ package org.jackhuang.hmcl.game; +import org.jackhuang.hmcl.addon.mod.LocalModFile; +import org.jackhuang.hmcl.addon.mod.ModManager; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.io.IOUtils; import org.jackhuang.hmcl.util.io.Zipper; @@ -74,6 +76,60 @@ public static CompletableFuture exportLogs( zipper.putTextFile(logs, "minecraft.log"); zipper.putTextFile(Logger.filterForbiddenToken(launchScript), OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS ? "launch.bat" : "launch.sh"); + try { + ModManager modManager = gameRepository.getModManager(versionId); + modManager.refresh(); + + StringBuilder infoBuilder = new StringBuilder(); + infoBuilder.append("=== Mod List ===").append(System.lineSeparator()); + infoBuilder.append("Filesystem structure of: ").append(runDirectory.resolve("mods")).append(System.lineSeparator()); + infoBuilder.append("|-> mods").append(System.lineSeparator()); + + modManager.getLocalFiles().stream() + .filter(LocalModFile::isActive) + .sorted((m1, m2) -> String.CASE_INSENSITIVE_ORDER.compare(m1.getName(), m2.getName())) + .forEach(mod -> { + infoBuilder.append("| |-> ").append(mod.getName()); + if (StringUtils.isNotBlank(mod.getVersion()) && !"${version}".equals(mod.getVersion())) { + infoBuilder.append(" (").append(mod.getVersion()).append(")"); + } + if (!mod.getName().equals(mod.getFileName())) { + infoBuilder.append(" [").append(mod.getFileName()).append("]"); + } + infoBuilder.append(System.lineSeparator()); + }); + + infoBuilder.append(System.lineSeparator()) + .append("----------------------------").append(System.lineSeparator()) + .append(System.lineSeparator()) + .append("=== Jar-in-Jar Info List (active mods only) ===").append(System.lineSeparator()); + + boolean hasJij = false; + for (LocalModFile mod : modManager.getLocalFiles()) { + if (mod.isActive() && mod.hasBundledMods()) { + hasJij = true; + infoBuilder.append(mod.getName()); + if (!mod.getName().equals(mod.getFileName())) { + infoBuilder.append(" [").append(mod.getFileName()).append("]"); + } + infoBuilder.append(System.lineSeparator()); + for (String bundled : mod.getBundledMods()) { + String name = bundled.contains("/") ? bundled.substring(bundled.lastIndexOf('/') + 1) : bundled; + infoBuilder.append("\t|-> ").append(name).append(System.lineSeparator()); + } + infoBuilder.append(System.lineSeparator()); + } + } + + if (!hasJij) { + infoBuilder.append("No Jar-in-Jar info found").append(System.lineSeparator()); + } + + zipper.putTextFile(infoBuilder.toString(), "mods_info.txt"); + } catch (Exception e) { + LOG.warning("Failed to export mod info to crash report package", e); + } + for (String id : versions) { Path versionJson = baseDirectory.resolve("versions").resolve(id).resolve(id + ".json"); if (Files.exists(versionJson)) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java index fb2c3b99858..5c7f4bf93e6 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java @@ -113,6 +113,7 @@ public enum SVG { SELECT_ALL("M7 17V7H17V17H7ZM9 15H15V9H9V15ZM5 19V21Q4.175 21 3.5875 20.4125T3 19H5ZM3 17V15H5V17H3ZM3 13V11H5V13H3ZM3 9V7H5V9H3ZM5 5H3Q3 4.175 3.5875 3.5875T5 3V5ZM7 21V19H9V21H7ZM7 5V3H9V5H7ZM11 21V19H13V21H11ZM11 5V3H13V5H11ZM15 21V19H17V21H15ZM15 5V3H17V5H15ZM19 21V19H21Q21 19.825 20.4125 20.4125T19 21ZM19 17V15H21V17H19ZM19 13V11H21V13H19ZM19 9V7H21V9H19ZM19 5V3Q19.825 3 20.4125 3.5875T21 5H19Z"), SETTINGS("M19.43 12.98C19.47 12.66 19.5 12.34 19.5 12 19.5 11.66 19.47 11.34 19.43 11.02L21.54 9.37C21.73 9.22 21.78 8.95 21.66 8.73L19.66 5.27C19.57 5.11 19.4 5.02 19.22 5.02 19.16 5.02 19.1 5.03 19.05 5.05L16.56 6.05C16.04 5.65 15.48 5.32 14.87 5.07L14.49 2.42C14.46 2.18 14.25 2 14 2H10C9.75 2 9.54 2.18 9.51 2.42L9.13 5.07C8.52 5.32 7.96 5.66 7.44 6.05L4.95 5.05C4.89 5.03 4.83 5.02 4.77 5.02 4.6 5.02 4.43 5.11 4.34 5.27L2.34 8.73C2.21 8.95 2.27 9.22 2.46 9.37L4.57 11.02C4.53 11.34 4.5 11.67 4.5 12 4.5 12.33 4.53 12.66 4.57 12.98L2.46 14.63C2.27 14.78 2.22 15.05 2.34 15.27L4.34 18.73C4.43 18.89 4.6 18.98 4.78 18.98 4.84 18.98 4.9 18.97 4.95 18.95L7.44 17.95C7.96 18.35 8.52 18.68 9.13 18.93L9.51 21.58C9.54 21.82 9.75 22 10 22H14C14.25 22 14.46 21.82 14.49 21.58L14.87 18.93C15.48 18.68 16.04 18.34 16.56 17.95L19.05 18.95C19.11 18.97 19.17 18.98 19.23 18.98 19.4 18.98 19.57 18.89 19.66 18.73L21.66 15.27C21.78 15.05 21.73 14.78 21.54 14.63L19.43 12.98ZM17.45 11.27C17.49 11.58 17.5 11.79 17.5 12 17.5 12.21 17.48 12.43 17.45 12.73L17.31 13.86 18.2 14.56 19.28 15.4 18.58 16.61 17.31 16.1 16.27 15.68 15.37 16.36C14.94 16.68 14.53 16.92 14.12 17.09L13.06 17.52 12.9 18.65 12.7 20H11.3L11.11 18.65 10.95 17.52 9.89 17.09C9.46 16.91 9.06 16.68 8.66 16.38L7.75 15.68 6.69 16.11 5.42 16.62 4.72 15.41 5.8 14.57 6.69 13.87 6.55 12.74C6.52 12.43 6.5 12.2 6.5 12S6.52 11.57 6.55 11.27L6.69 10.14 5.8 9.44 4.72 8.6 5.42 7.39 6.69 7.9 7.73 8.32 8.63 7.64C9.06 7.32 9.47 7.08 9.88 6.91L10.94 6.48 11.1 5.35 11.3 4H12.69L12.88 5.35 13.04 6.48 14.1 6.91C14.53 7.09 14.93 7.32 15.33 7.62L16.24 8.32 17.3 7.89 18.57 7.38 19.27 8.59 18.2 9.44 17.31 10.14 17.45 11.27ZM12 8C9.79 8 8 9.79 8 12S9.79 16 12 16 16 14.21 16 12 14.21 8 12 8ZM12 14C10.9 14 10 13.1 10 12S10.9 10 12 10 14 10.9 14 12 13.1 14 12 14Z"), // Material Icons SETTINGS_FILL("M9.25 22l-.4-3.2q-.325-.125-.6125-.3t-.5625-.375L4.7 19.375l-2.75-4.75 2.575-1.95Q4.5 12.5 4.5 12.3375v-.675q0-.1625.025-.3375L1.95 9.375 4.7 4.625l2.975 1.25q.275-.2.575-.375t.6-.3L9.25 2h5.5l.4 3.2q.325.125.6125.3t.5625.375L19.3 4.625l2.75 4.75-2.575 1.95q.025.175.025.3375v.675q0 .1625-.05.3375l2.575 1.95-2.75 4.75-2.95-1.25q-.275.2-.575.375t-.6.3l-.4 3.2H9.25Zm2.8-6.5q1.45 0 2.475-1.025T15.55 12 14.525 9.525 12.05 8.5q-1.475 0-2.4875 1.025T8.55 12q0 1.45 1.0125 2.475T12.05 15.5Z"), // Material Icons + STACKS("M11.99 18.54l-7.37-5.73L3 14.07l9 7 9-7-1.63-1.27-7.38 5.74zM12 16l7.36-5.73L21 9l-9-7-9 7 1.63 1.27L12 16z"), STADIA_CONTROLLER("M4.725 20Q3.225 20 2.1625 18.925T1.05 16.325Q1.05 16.1 1.075 15.875T1.15 15.425L3.25 7.025Q3.6 5.675 4.675 4.8375T7.125 4H16.875Q18.25 4 19.325 4.8375T20.75 7.025L22.85 15.425Q22.9 15.65 22.9375 15.8875T22.975 16.35Q22.975 17.875 21.8875 18.9375T19.275 20Q18.225 20 17.325 19.45T15.975 17.95L15.275 16.5Q15.15 16.25 14.9 16.125T14.375 16H9.625Q9.35 16 9.1 16.125T8.725 16.5L8.025 17.95Q7.575 18.9 6.675 19.45T4.725 20ZM4.8 18Q5.275 18 5.6625 17.75T6.25 17.075L6.95 15.65Q7.325 14.875 8.05 14.4375T9.625 14H14.375Q15.225 14 15.95 14.45T17.075 15.65L17.775 17.075Q17.975 17.5 18.3625 17.75T19.225 18Q19.925 18 20.425 17.5375T20.95 16.375Q20.95 16.4 20.9 15.9L18.8 7.525Q18.625 6.85 18.1 6.425T16.875 6H7.125Q6.425 6 5.8875 6.425T5.2 7.525L3.1 15.9Q3.05 16.05 3.05 16.35 3.05 17.05 3.5625 17.525T4.8 18ZM13.5 11Q13.925 11 14.2125 10.7125T14.5 10Q14.5 9.575 14.2125 9.2875T13.5 9Q13.075 9 12.7875 9.2875T12.5 10Q12.5 10.425 12.7875 10.7125T13.5 11ZM15.5 9Q15.925 9 16.2125 8.7125T16.5 8Q16.5 7.575 16.2125 7.2875T15.5 7Q15.075 7 14.7875 7.2875T14.5 8Q14.5 8.425 14.7875 8.7125T15.5 9ZM15.5 13Q15.925 13 16.2125 12.7125T16.5 12Q16.5 11.575 16.2125 11.2875T15.5 11Q15.075 11 14.7875 11.2875T14.5 12Q14.5 12.425 14.7875 12.7125T15.5 13ZM17.5 11Q17.925 11 18.2125 10.7125T18.5 10Q18.5 9.575 18.2125 9.2875T17.5 9Q17.075 9 16.7875 9.2875T16.5 10Q16.5 10.425 16.7875 10.7125T17.5 11ZM8.5 12.5Q8.825 12.5 9.0375 12.2875T9.25 11.75V10.75H10.25Q10.575 10.75 10.7875 10.5375T11 10Q11 9.675 10.7875 9.4625T10.25 9.25H9.25V8.25Q9.25 7.925 9.0375 7.7125T8.5 7.5Q8.175 7.5 7.9625 7.7125T7.75 8.25V9.25H6.75Q6.425 9.25 6.2125 9.4625T6 10Q6 10.325 6.2125 10.5375T6.75 10.75H7.75V11.75Q7.75 12.075 7.9625 12.2875T8.5 12.5ZM12 12Z"), STADIA_CONTROLLER_FILL("M4.725 20q-1.5 0-2.5625-1.075T1.05 16.325q0-.225.025-.45t.075-.45l2.1-8.4q.35-1.35 1.425-2.1875T7.125 4h9.75q1.375 0 2.45.8375T20.75 7.025l2.1 8.4q.05.225.0875.4625t.0375.4625q0 1.525-1.0875 2.5875T19.275 20q-1.05 0-1.95-.55t-1.35-1.5l-.7-1.45q-.125-.25-.375-.375T14.375 16H9.625q-.275 0-.525.125t-.375.375l-.7 1.45q-.45.95-1.35 1.5T4.725 20ZM13.5 11q.425 0 .7125-.2875T14.5 10t-.2875-.7125T13.5 9t-.7125.2875T12.5 10t.2875.7125T13.5 11Zm2-2q.425 0 .7125-.2875T16.5 8q0-.425-.2875-.7125T15.5 7q-.425 0-.7125.2875T14.5 8t.2875.7125T15.5 9Zm0 4q.425 0 .7125-.2875T16.5 12q0-.425-.2875-.7125T15.5 11q-.425 0-.7125.2875T14.5 12t.2875.7125T15.5 13Zm2-2q.425 0 .7125-.2875T18.5 10q0-.425-.2875-.7125T17.5 9q-.425 0-.7125.2875T16.5 10q0 .425.2875.7125T17.5 11Zm-9 1.5q.325 0 .5375-.2125T9.25 11.75v-1h1q.325 0 .5375-.2125T11 10t-.2125-.5375T10.25 9.25h-1v-1q0-.325-.2125-.5375T8.5 7.5q-.325 0-.5375.2125T7.75 8.25v1h-1q-.325 0-.5375.2125T6 10q0 .325.2125.5375T6.75 10.75h1v1q0 .325.2125.5375T8.5 12.5Z"), STYLE("M3.975 19.8 3.125 19.45Q2.35 19.125 2.0875 18.325T2.175 16.75L3.975 12.85V19.8ZM7.975 22Q7.15 22 6.5625 21.4125T5.975 20V14L8.625 21.35Q8.7 21.525 8.775 21.6875T8.975 22H7.975ZM13.125 21.9Q12.325 22.2 11.575 21.825T10.525 20.65L6.075 8.45Q5.775 7.65 6.125 6.8875T7.275 5.85L14.825 3.1Q15.625 2.8 16.375 3.175T17.425 4.35L21.875 16.55Q22.175 17.35 21.825 18.1125T20.675 19.15L13.125 21.9ZM10.975 10Q11.4 10 11.6875 9.7125T11.975 9Q11.975 8.575 11.6875 8.2875T10.975 8Q10.55 8 10.2625 8.2875T9.975 9Q9.975 9.425 10.2625 9.7125T10.975 10ZM12.425 20 19.975 17.25 15.525 5 7.975 7.75 12.425 20ZM7.975 7.75 15.525 5 7.975 7.75Z"), diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java index 1a4ecf3aa6a..d4411f4a33b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java @@ -23,6 +23,7 @@ import javafx.beans.binding.Bindings; import javafx.beans.property.BooleanProperty; import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleBooleanProperty; import javafx.collections.ListChangeListener; import javafx.css.PseudoClass; import javafx.geometry.Insets; @@ -32,9 +33,11 @@ import javafx.scene.image.Image; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyEvent; +import javafx.scene.input.MouseEvent; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; import javafx.stage.Stage; import javafx.util.Duration; import org.jackhuang.hmcl.addon.*; @@ -291,7 +294,8 @@ private void search() { || predicate.test(modInfo.getGameVersion()) || predicate.test(modInfo.getId()) || predicate.test(Objects.toString(modInfo.getModLoaderType())) - || predicate.test((item.getModTranslations() != null ? item.getModTranslations().getDisplayName() : null))) { + || predicate.test((item.getModTranslations() != null ? item.getModTranslations().getDisplayName() : null)) + || modInfo.getBundledMods().stream().anyMatch(predicate)) { listView.getItems().add(item); } } @@ -300,6 +304,9 @@ private void search() { static final class ModInfoObject { private final BooleanProperty active; + // Whether the nested (Jar-in-Jar) mods of this entry are expanded in the list. + // Stored on the data object so the state survives ListCell recycling. + private final BooleanProperty expanded = new SimpleBooleanProperty(this, "expanded", false); private final LocalModFile localModFile; private final @Nullable ModTranslations.Mod modTranslations; @@ -316,6 +323,10 @@ LocalModFile getModInfo() { return localModFile; } + BooleanProperty expandedProperty() { + return expanded; + } + public @Nullable ModTranslations.Mod getModTranslations() { return modTranslations; } @@ -567,7 +578,13 @@ final class ModInfoListCell extends MDListCell { JFXButton restoreButton = FXUtils.newToggleButton4(SVG.RESTORE); JFXButton infoButton = FXUtils.newToggleButton4(SVG.INFO); JFXButton revealButton = FXUtils.newToggleButton4(SVG.FOLDER); + JFXButton expandButton = FXUtils.newToggleButton4(SVG.KEYBOARD_ARROW_DOWN); + VBox nestedBox = new VBox(); BooleanProperty booleanProperty; + // Mirrors the bound ModInfoObject's expanded state, so the chevron and the + // nested list react to it while this cell is showing that item. + private final BooleanProperty expanded = new SimpleBooleanProperty(this, "expanded", false); + private BooleanProperty expandedBinding; Tooltip warningTooltip; @@ -576,9 +593,9 @@ final class ModInfoListCell extends MDListCell { this.getStyleClass().add("mod-info-list-cell"); - HBox container = new HBox(8); - container.setPickOnBounds(false); - container.setAlignment(Pos.CENTER_LEFT); + HBox header = new HBox(8); + header.setPickOnBounds(false); + header.setAlignment(Pos.CENTER_LEFT); HBox.setHgrow(content, Priority.ALWAYS); content.setMouseTransparent(true); setSelectable(); @@ -586,13 +603,43 @@ final class ModInfoListCell extends MDListCell { imageContainer.setImage(VersionIconType.COMMAND.getIcon()); FXUtils.installFastTooltip(restoreButton, i18n("mods.restore")); + FXUtils.installFastTooltip(expandButton, i18n("addon.bundled")); + + expandButton.getGraphic().rotateProperty().bind( + Bindings.when(expanded).then(180.0).otherwise(0.0)); + // Toggle on press and consume the event so it never reaches the ListView's + // selection handler — expanding the nested list should not select the row. + expandButton.addEventFilter(MouseEvent.MOUSE_PRESSED, e -> { + expanded.set(!expanded.get()); + e.consume(); + }); - container.getChildren().setAll(checkBox, imageContainer, content, restoreButton, revealButton, infoButton); + nestedBox.getStyleClass().add("mod-nested-list"); + nestedBox.visibleProperty().bind(expanded); + nestedBox.managedProperty().bind(nestedBox.visibleProperty()); + header.getChildren().setAll(checkBox, imageContainer, content, restoreButton, revealButton, infoButton, expandButton); + + VBox container = new VBox(header, nestedBox); + container.setPickOnBounds(false); StackPane.setMargin(container, new Insets(8)); getContainer().getChildren().setAll(container); } + private Node createNestedRow(String path) { + String name = path.contains("/") ? path.substring(path.lastIndexOf('/') + 1) : path; + + Label label = new Label(name); + HBox.setHgrow(label, Priority.ALWAYS); + if (!name.equals(path)) + label.setTooltip(new Tooltip(path)); + + HBox row = new HBox(8, SVG.STACKS.createIcon(16), label); + row.getStyleClass().add("mod-nested-item"); + row.setAlignment(Pos.CENTER_LEFT); + return row; + } + @Override protected void updateControl(ModInfoObject dataItem, boolean empty) { pseudoClassStateChanged(WARNING, false); @@ -682,6 +729,24 @@ protected void updateControl(ModInfoObject dataItem, boolean empty) { revealButton.setOnAction(e -> FXUtils.showFileInExplorer(modInfo.getFile())); infoButton.setOnAction(e -> Controllers.dialog(new ModInfoDialog(dataItem))); + // Nested (Jar-in-Jar) mods + if (expandedBinding != null) { + expanded.unbindBidirectional(expandedBinding); + } + expanded.bindBidirectional(expandedBinding = dataItem.expandedProperty()); + + nestedBox.getChildren().clear(); + boolean hasBundled = modInfo.hasBundledMods(); + expandButton.setVisible(hasBundled); + expandButton.setManaged(hasBundled); + if (hasBundled) { + List bundledMods = modInfo.getBundledMods(); + content.addTag(i18n("addon.bundled") + ": " + bundledMods.size()); + for (String path : bundledMods) { + nestedBox.getChildren().add(createNestedRow(path)); + } + } + if (!warning.isEmpty()) { pseudoClassStateChanged(WARNING, true); diff --git a/HMCL/src/main/resources/assets/css/root.css b/HMCL/src/main/resources/assets/css/root.css index 6edb943783e..7a7614b5b0c 100644 --- a/HMCL/src/main/resources/assets/css/root.css +++ b/HMCL/src/main/resources/assets/css/root.css @@ -1097,6 +1097,23 @@ -fx-background-color: -monet-error-container; } +.mod-nested-list { + -fx-spacing: 2; + -fx-padding: 2 0 4 42; +} + +.mod-nested-item { + -fx-padding: 3 8 3 0; +} + +.mod-nested-item .label { + -fx-text-fill: -monet-on-surface-variant; +} + +.mod-nested-item .svg { + -fx-fill: -monet-on-surface-variant; +} + .resource-pack-list-cell:warning { -fx-background-color: -monet-error-container; } diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 81308bee8a0..32e325bbbfa 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -174,6 +174,7 @@ account.username=Username addon.broken_dependency.title=Broken dependency addon.broken_dependency.desc=This dependency existed before, but it does not exist anymore. Try using another download source. +addon.bundled=Nested mods addon.category=Category addon.channel.alpha=Alpha addon.channel.beta=Beta diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 4a4bb270424..24950db9880 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -172,6 +172,7 @@ account.username=使用者名稱 addon.broken_dependency.title=損壞的相依內容 addon.broken_dependency.desc=該相依內容曾經存在於附加內容下載源中,但現在已被刪除,請嘗試其他下載源。 +addon.bundled=嵌套模組 addon.category=類別 addon.channel.alpha=Alpha addon.channel.beta=Beta 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 ab94a81a318..db1724517df 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -174,6 +174,7 @@ account.username=用户名 addon.broken_dependency.title=损坏的前置内容 addon.broken_dependency.desc=该前置内容曾经在该附加内容下载源上存在过,但现在被删除了。换个下载源试试吧。 +addon.bundled=嵌套模组 addon.category=类别 addon.channel.alpha=快照版本 addon.channel.beta=测试版本 diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/FabricModMetadata.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/FabricModMetadata.java index 4601ed1ca5d..f083b94336f 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/FabricModMetadata.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/FabricModMetadata.java @@ -19,6 +19,7 @@ import com.google.gson.*; import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; import kala.compress.archivers.zip.ZipArchiveEntry; import org.jackhuang.hmcl.addon.LocalAddonFile; import org.jackhuang.hmcl.addon.mod.LocalModFile; @@ -45,12 +46,13 @@ public final class FabricModMetadata { private final String icon; private final List authors; private final Map contact; + private final List jars; public FabricModMetadata() { - this("", "", "", "", "", Collections.emptyList(), Collections.emptyMap()); + this("", "", "", "", "", Collections.emptyList(), Collections.emptyMap(), Collections.emptyList()); } - public FabricModMetadata(String id, String name, String version, String icon, String description, List authors, Map contact) { + public FabricModMetadata(String id, String name, String version, String icon, String description, List authors, Map contact, List jars) { this.id = id; this.name = name; this.version = version; @@ -58,6 +60,7 @@ public FabricModMetadata(String id, String name, String version, String icon, St this.description = description; this.authors = authors; this.contact = contact; + this.jars = jars; } public static LocalModFile fromFile(ModManager modManager, Path modFile, ZipFileTree tree) throws IOException, JsonParseException { @@ -66,8 +69,23 @@ public static LocalModFile fromFile(ModManager modManager, Path modFile, ZipFile throw new IOException("File " + modFile + " is not a Fabric mod."); FabricModMetadata metadata = JsonUtils.fromNonNullJsonFully(tree.getInputStream(mcmod), FabricModMetadata.class); String authors = metadata.authors == null ? "" : metadata.authors.stream().map(author -> author.name).collect(Collectors.joining(", ")); + List bundledMods = metadata.jars == null ? Collections.emptyList() + : metadata.jars.stream().map(jar -> jar.file).toList(); return new LocalModFile(modManager, modManager.getLocalMod(metadata.id, ModLoaderType.FABRIC), modFile, metadata.name, new LocalAddonFile.Description(metadata.description), - authors, metadata.version, "", metadata.contact != null ? metadata.contact.getOrDefault("homepage", "") : "", metadata.icon); + authors, metadata.version, "", metadata.contact != null ? metadata.contact.getOrDefault("homepage", "") : "", metadata.icon, bundledMods); + } + + public static final class FabricNestedJar { + @SerializedName("file") + private final String file; + + public FabricNestedJar() { + this(""); + } + + public FabricNestedJar(String file) { + this.file = file; + } } @JsonAdapter(FabricModAuthorSerializer.class) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/ForgeNewModMetadata.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/ForgeNewModMetadata.java index 34d72756977..5d1b8241018 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/ForgeNewModMetadata.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/ForgeNewModMetadata.java @@ -235,10 +235,25 @@ private static LocalModFile fromFile0( ModLoaderType type = analyzeLoader(tomlParseResult, mod.getModId(), modLoaderType); + List bundledMods = new ArrayList<>(); + ZipArchiveEntry jarInJar = tree.getEntry("META-INF/jarjar/metadata.json"); + if (jarInJar != null) { + try { + JarInJarMetadata jarInJarMetadata = JsonUtils.fromJsonFully(tree.getInputStream(jarInJar), JarInJarMetadata.class); + if (jarInJarMetadata != null && jarInJarMetadata.jars() != null) { + for (EmbeddedJarMetadata jar : jarInJarMetadata.jars()) { + bundledMods.add(jar.path()); + } + } + } catch (Exception e) { + LOG.warning("Failed to parse Jar-in-Jar metadata for " + modFile, e); + } + } + return new LocalModFile(modManager, modManager.getLocalMod(mod.getModId(), type), modFile, mod.getDisplayName(), new LocalAddonFile.Description(mod.getDescription()), mod.getAuthors(), jarVersion == null ? mod.getVersion() : mod.getVersion().replace("${file.jarVersion}", jarVersion), "", mod.getDisplayURL(), - metadata.getLogoFile()); + metadata.getLogoFile(), bundledMods); } private static LocalModFile fromEmbeddedMod(ModManager modManager, Path modFile, ZipFileTree tree, ModLoaderType modLoaderType) throws IOException { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/QuiltModMetadata.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/QuiltModMetadata.java index 02e31ed7269..6d64cc11f69 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/QuiltModMetadata.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/QuiltModMetadata.java @@ -30,6 +30,8 @@ import java.io.IOException; import java.nio.file.Path; +import java.util.Collections; +import java.util.List; import java.util.Optional; import java.util.stream.Collectors; @@ -52,14 +54,24 @@ public Metadata(String name, String description, JsonObject contributors, String } } + private static final class NestedJar { + private final String file; + + public NestedJar(String file) { + this.file = file; + } + } + private final String id; private final String version; private final Metadata metadata; + private final List jars; - public QuiltLoader(String id, String version, Metadata metadata) { + public QuiltLoader(String id, String version, Metadata metadata, List jars) { this.id = id; this.version = version; this.metadata = metadata; + this.jars = jars; } } @@ -82,17 +94,25 @@ public static LocalModFile fromFile(ModManager modManager, Path modFile, ZipFile throw new IOException("File " + modFile + " is not a supported Quilt mod."); } + String authors = root.quilt_loader.metadata.contributors == null ? "" + : root.quilt_loader.metadata.contributors.entrySet().stream().map(entry -> String.format("%s (%s)", entry.getKey(), entry.getValue().getAsJsonPrimitive().getAsString())).collect(Collectors.joining(", ")); + String homepage = root.quilt_loader.metadata.contact == null ? "" + : Optional.ofNullable(root.quilt_loader.metadata.contact.get("homepage")).map(jsonElement -> jsonElement.getAsJsonPrimitive().getAsString()).orElse(""); + List bundledMods = root.quilt_loader.jars == null ? Collections.emptyList() + : root.quilt_loader.jars.stream().map(jar -> jar.file).toList(); + return new LocalModFile( modManager, modManager.getLocalMod(root.quilt_loader.id, ModLoaderType.QUILT), modFile, root.quilt_loader.metadata.name, new LocalAddonFile.Description(root.quilt_loader.metadata.description), - root.quilt_loader.metadata.contributors.entrySet().stream().map(entry -> String.format("%s (%s)", entry.getKey(), entry.getValue().getAsJsonPrimitive().getAsString())).collect(Collectors.joining(", ")), + authors, root.quilt_loader.version, "", - Optional.ofNullable(root.quilt_loader.metadata.contact.get("homepage")).map(jsonElement -> jsonElement.getAsJsonPrimitive().getAsString()).orElse(""), - root.quilt_loader.metadata.icon + homepage, + root.quilt_loader.metadata.icon, + bundledMods ); } } 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 f4bfafab115..eafb16b0054 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 @@ -50,6 +50,7 @@ public final class LocalModFile extends LocalAddonFile implements Comparable bundledMods; private final BooleanProperty activeProperty; public LocalModFile(ModManager modManager, LocalMod mod, Path file, String name, Description description) { @@ -57,6 +58,10 @@ public LocalModFile(ModManager modManager, LocalMod mod, Path file, String name, } public LocalModFile(ModManager modManager, LocalMod mod, Path file, String name, Description description, String authors, String version, String gameVersion, String url, String logoPath) { + this(modManager, mod, file, name, description, authors, version, gameVersion, url, logoPath, List.of()); + } + + public LocalModFile(ModManager modManager, LocalMod mod, Path file, String name, Description description, String authors, String version, String gameVersion, String url, String logoPath, List bundledMods) { super(); this.modManager = modManager; this.mod = mod; @@ -68,6 +73,7 @@ public LocalModFile(ModManager modManager, LocalMod mod, Path file, String name, this.gameVersion = gameVersion; this.url = url; this.logoPath = logoPath; + this.bundledMods = bundledMods == null ? List.of() : List.copyOf(bundledMods); activeProperty = new SimpleBooleanProperty(this, "active", !modManager.isDisabled(file)) { @Override @@ -145,6 +151,14 @@ public String getLogoPath() { return logoPath; } + public List getBundledMods() { + return bundledMods; + } + + public boolean hasBundledMods() { + return !bundledMods.isEmpty(); + } + public BooleanProperty activeProperty() { return activeProperty; } From ba21105b115d01430e39d09efb4c9d6604612353 Mon Sep 17 00:00:00 2001 From: lokins Date: Sat, 27 Jun 2026 01:26:30 +0800 Subject: [PATCH 2/8] =?UTF-8?q?=E6=9B=B4=E6=94=B9=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E4=BD=8D=E7=BD=AE=EF=BC=8C=E8=87=AA=E5=8A=A8=E9=9A=90=E8=97=8F?= =?UTF-8?q?=E6=BA=A2=E5=87=BA=E6=A0=87=E9=A2=98=E3=80=81=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/jackhuang/hmcl/ui/versions/ModListPageSkin.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java index d4411f4a33b..eec8134148e 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java @@ -597,6 +597,8 @@ final class ModInfoListCell extends MDListCell { header.setPickOnBounds(false); header.setAlignment(Pos.CENTER_LEFT); HBox.setHgrow(content, Priority.ALWAYS); + // Allow the title to shrink/ellipsize instead of pushing the buttons out of view. + content.setMinWidth(0); content.setMouseTransparent(true); setSelectable(); @@ -604,6 +606,9 @@ final class ModInfoListCell extends MDListCell { FXUtils.installFastTooltip(restoreButton, i18n("mods.restore")); FXUtils.installFastTooltip(expandButton, i18n("addon.bundled")); + // Don't reserve layout space for the restore button when it is hidden, + // otherwise it leaves a gap between the expand button and the other buttons. + restoreButton.managedProperty().bind(restoreButton.visibleProperty()); expandButton.getGraphic().rotateProperty().bind( Bindings.when(expanded).then(180.0).otherwise(0.0)); @@ -618,7 +623,7 @@ final class ModInfoListCell extends MDListCell { nestedBox.visibleProperty().bind(expanded); nestedBox.managedProperty().bind(nestedBox.visibleProperty()); - header.getChildren().setAll(checkBox, imageContainer, content, restoreButton, revealButton, infoButton, expandButton); + header.getChildren().setAll(checkBox, imageContainer, content, expandButton, restoreButton, revealButton, infoButton); VBox container = new VBox(header, nestedBox); container.setPickOnBounds(false); From a7ca47f1f30125123ad448ade9f240cd200619d0 Mon Sep 17 00:00:00 2001 From: lokins Date: Sat, 27 Jun 2026 21:56:50 +0800 Subject: [PATCH 3/8] =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=A8=A1=E7=BB=84?= =?UTF-8?q?=E5=85=B3=E7=B3=BB=E6=A3=80=E6=B5=8B=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=88=B7=E6=96=B0=E5=88=97=E8=A1=A8=E6=97=B6=E7=9A=84=E5=85=B3?= =?UTF-8?q?=E7=B3=BB=E4=B8=8E=E5=86=85=E5=B5=8C=E6=A3=80=E6=B5=8B=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/jackhuang/hmcl/game/LogExporter.java | 71 ++++++++++++---- .../hmcl/ui/versions/ModListPage.java | 9 ++- .../hmcl/ui/versions/ModListPageSkin.java | 81 ++++++++++++++++++- HMCL/src/main/resources/assets/css/root.css | 7 ++ .../resources/assets/lang/I18N.properties | 6 ++ .../resources/assets/lang/I18N_zh.properties | 6 ++ .../assets/lang/I18N_zh_CN.properties | 6 ++ .../hmcl/addon/meta/FabricModMetadata.java | 14 +++- .../hmcl/addon/meta/ForgeNewModMetadata.java | 63 ++++++++++----- .../hmcl/addon/meta/QuiltModMetadata.java | 29 ++++++- .../hmcl/addon/mod/LocalModFile.java | 14 +++- .../jackhuang/hmcl/addon/mod/ModManager.java | 77 ++++++++++++++++-- 12 files changed, 329 insertions(+), 54 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java index 4ecfa9f21cd..eae2163d3d6 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java @@ -34,7 +34,9 @@ import java.nio.file.PathMatcher; import java.util.ArrayList; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.concurrent.CompletableFuture; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -80,24 +82,63 @@ public static CompletableFuture exportLogs( ModManager modManager = gameRepository.getModManager(versionId); modManager.refresh(); + List activeMods = modManager.getLocalFiles().stream() + .filter(LocalModFile::isActive) + .sorted((m1, m2) -> String.CASE_INSENSITIVE_ORDER.compare(m1.getName(), m2.getName())) + .toList(); + StringBuilder infoBuilder = new StringBuilder(); + + // Mods that exist both as a standalone file and inside another active mod's + // Jar-in-Jar payload. Such duplicates can conflict and crash the game, so list + // them at the very top of the report. + LinkedHashSet duplicates = new LinkedHashSet<>(); + for (LocalModFile host : activeMods) { + if (!host.hasBundledMods()) continue; + for (String bundled : host.getBundledMods()) { + String bundledName = bundled.contains("/") ? bundled.substring(bundled.lastIndexOf('/') + 1) : bundled; + String bundledLower = bundledName.toLowerCase(Locale.ROOT); + String bundledNorm = bundledLower.replace("-", "").replace("_", ""); + for (LocalModFile other : activeMods) { + if (other == host) continue; + String id = other.getId(); + if (id == null || id.length() < 3) continue; + String idLower = id.toLowerCase(Locale.ROOT); + if (bundledLower.contains(idLower) + || bundledNorm.contains(idLower.replace("-", "").replace("_", ""))) { + duplicates.add(other.getName() + " [" + other.getFileName() + "] <-> bundled in " + host.getName() + " (" + bundledName + ")"); + } + } + } + } + + infoBuilder.append("=== Potential Duplicate Mods (installed separately AND bundled via Jar-in-Jar) ===").append(System.lineSeparator()); + if (duplicates.isEmpty()) { + infoBuilder.append("None").append(System.lineSeparator()); + } else { + infoBuilder.append("These mods may conflict with their bundled copies and cause crashes:").append(System.lineSeparator()); + for (String line : duplicates) { + infoBuilder.append("\t|-> ").append(line).append(System.lineSeparator()); + } + } + infoBuilder.append(System.lineSeparator()) + .append("----------------------------").append(System.lineSeparator()) + .append(System.lineSeparator()); + infoBuilder.append("=== Mod List ===").append(System.lineSeparator()); infoBuilder.append("Filesystem structure of: ").append(runDirectory.resolve("mods")).append(System.lineSeparator()); infoBuilder.append("|-> mods").append(System.lineSeparator()); - modManager.getLocalFiles().stream() - .filter(LocalModFile::isActive) - .sorted((m1, m2) -> String.CASE_INSENSITIVE_ORDER.compare(m1.getName(), m2.getName())) - .forEach(mod -> { - infoBuilder.append("| |-> ").append(mod.getName()); - if (StringUtils.isNotBlank(mod.getVersion()) && !"${version}".equals(mod.getVersion())) { - infoBuilder.append(" (").append(mod.getVersion()).append(")"); - } - if (!mod.getName().equals(mod.getFileName())) { - infoBuilder.append(" [").append(mod.getFileName()).append("]"); - } - infoBuilder.append(System.lineSeparator()); - }); + for (LocalModFile mod : activeMods) { + infoBuilder.append("| |-> ").append(mod.getName()); + if (StringUtils.isNotBlank(mod.getVersion()) && !"${version}".equals(mod.getVersion())) { + infoBuilder.append(" (").append(mod.getVersion()).append(")"); + } + if (!mod.getName().equals(mod.getFileName())) { + infoBuilder.append(" [").append(mod.getFileName()).append("]"); + } + infoBuilder.append(System.lineSeparator()); + } infoBuilder.append(System.lineSeparator()) .append("----------------------------").append(System.lineSeparator()) @@ -105,8 +146,8 @@ public static CompletableFuture exportLogs( .append("=== Jar-in-Jar Info List (active mods only) ===").append(System.lineSeparator()); boolean hasJij = false; - for (LocalModFile mod : modManager.getLocalFiles()) { - if (mod.isActive() && mod.hasBundledMods()) { + for (LocalModFile mod : activeMods) { + if (mod.hasBundledMods()) { hasJij = true; infoBuilder.append(mod.getName()); if (!mod.getName().equals(mod.getFileName())) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java index 2516c084a1e..f38189c3578 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java @@ -95,7 +95,14 @@ public void loadVersion(Profile profile, String id) { this.gameVersion = repository.getGameVersion(resolved).orElse(null); LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(resolved, gameVersion); modded.set(analyzer.hasModLoader()); - loadMods(profile.getRepository().getModManager(id)); + + // Reuse the existing ModManager (and its incremental scan cache) when reopening the same + // instance, so navigating back to the mod list doesn't rescan every jar from scratch. + ModManager manager = this.modManager; + if (manager == null || manager.getRepository() != repository || !id.equals(manager.getInstanceId())) { + manager = repository.getModManager(id); + } + loadMods(manager); } private void loadMods(ModManager modManager) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java index eec8134148e..0418c069ddd 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java @@ -20,6 +20,7 @@ import com.jfoenix.controls.*; import javafx.animation.PauseTransition; import javafx.application.Platform; +import javafx.beans.InvalidationListener; import javafx.beans.binding.Bindings; import javafx.beans.property.BooleanProperty; import javafx.beans.property.ObjectProperty; @@ -93,6 +94,10 @@ final class ModListPageSkin extends SkinBase { private final JFXListView listView; private final JFXTextField searchField; + // Re-render visible rows when a mod is enabled/disabled, so dependency statuses + // ("installed"/"disabled"/...) of the mods related to it update immediately. + private final InvalidationListener activeChangeListener; + @FXThread private boolean isSearching = false; @@ -107,6 +112,7 @@ final class ModListPageSkin extends SkinBase { root.getStyleClass().add("no-padding"); listView = new JFXListView<>(); listView.getStyleClass().add("no-horizontal-scrollbar"); + activeChangeListener = o -> listView.refresh(); { toolbarPane = new TransitionPane(); @@ -224,6 +230,12 @@ final class ModListPageSkin extends SkinBase { listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); Bindings.bindContent(listView.getItems(), skinnable.getItems()); skinnable.getItems().addListener((ListChangeListener) c -> { + while (c.next()) { + for (ModInfoObject removed : c.getRemoved()) + removed.active.removeListener(activeChangeListener); + for (ModInfoObject added : c.getAddedSubList()) + added.active.addListener(activeChangeListener); + } if (isSearching) { search(); } @@ -605,7 +617,7 @@ final class ModInfoListCell extends MDListCell { imageContainer.setImage(VersionIconType.COMMAND.getIcon()); FXUtils.installFastTooltip(restoreButton, i18n("mods.restore")); - FXUtils.installFastTooltip(expandButton, i18n("addon.bundled")); + FXUtils.installFastTooltip(expandButton, i18n("addon.relations")); // Don't reserve layout space for the restore button when it is hidden, // otherwise it leaves a gap between the expand button and the other buttons. restoreButton.managedProperty().bind(restoreButton.visibleProperty()); @@ -645,6 +657,58 @@ private Node createNestedRow(String path) { return row; } + private Node createSectionLabel(String text) { + Label label = new Label(text); + label.getStyleClass().add("mod-nested-section"); + return label; + } + + private Node createDependencyRow(LocalModFile modInfo, String depId) { + Label name = new Label(depId); + + var modManager = modInfo.getModManager(); + var loaderType = modInfo.getModLoaderType(); + // Only call getLocalMod() when the mod actually exists — it creates an entry otherwise. + Set installedFiles = modManager.hasMod(depId, loaderType) + ? modManager.getLocalMod(depId, loaderType).getFiles() + : Set.of(); + String statusText; + if (installedFiles.isEmpty()) { + // Not installed separately — it may instead be bundled inside this mod via Jar-in-Jar. + statusText = isBundledDependency(depId, modInfo.getBundledMods()) + ? i18n("addon.dependencies.bundled") + : i18n("addon.dependencies.missing"); + } else if (installedFiles.stream().anyMatch(LocalModFile::isActive)) { + statusText = i18n("addon.dependencies.installed"); + } else { + statusText = i18n("addon.dependencies.disabled"); + } + Label status = new Label("(" + statusText + ")"); + + HBox row = new HBox(8, SVG.EXTENSION.createIcon(16), name, status); + row.getStyleClass().add("mod-nested-item"); + row.setAlignment(Pos.CENTER_LEFT); + return row; + } + + // Heuristic: a dependency may be shipped inside the mod's own Jar-in-Jar payload, + // in which case it won't show up as a separately installed mod. Match the dependency + // id against the bundled jar file names. + private boolean isBundledDependency(String depId, List bundledMods) { + if (bundledMods.isEmpty()) + return false; + + String id = depId.toLowerCase(Locale.ROOT); + String idAlt = id.replace("-", "").replace("_", ""); + for (String path : bundledMods) { + String fileName = (path.contains("/") ? path.substring(path.lastIndexOf('/') + 1) : path) + .toLowerCase(Locale.ROOT); + if (fileName.contains(id) || fileName.replace("-", "").replace("_", "").contains(idAlt)) + return true; + } + return false; + } + @Override protected void updateControl(ModInfoObject dataItem, boolean empty) { pseudoClassStateChanged(WARNING, false); @@ -742,16 +806,27 @@ protected void updateControl(ModInfoObject dataItem, boolean empty) { nestedBox.getChildren().clear(); boolean hasBundled = modInfo.hasBundledMods(); - expandButton.setVisible(hasBundled); - expandButton.setManaged(hasBundled); + boolean hasDeps = modInfo.hasDependencies(); + boolean expandable = hasBundled || hasDeps; + expandButton.setVisible(expandable); + expandButton.setManaged(expandable); + if (hasBundled) { List bundledMods = modInfo.getBundledMods(); content.addTag(i18n("addon.bundled") + ": " + bundledMods.size()); + nestedBox.getChildren().add(createSectionLabel(i18n("addon.bundled"))); for (String path : bundledMods) { nestedBox.getChildren().add(createNestedRow(path)); } } + if (hasDeps) { + nestedBox.getChildren().add(createSectionLabel(i18n("addon.dependencies"))); + for (String depId : modInfo.getDependencies()) { + nestedBox.getChildren().add(createDependencyRow(modInfo, depId)); + } + } + if (!warning.isEmpty()) { pseudoClassStateChanged(WARNING, true); diff --git a/HMCL/src/main/resources/assets/css/root.css b/HMCL/src/main/resources/assets/css/root.css index 7a7614b5b0c..c7dea3dc9a9 100644 --- a/HMCL/src/main/resources/assets/css/root.css +++ b/HMCL/src/main/resources/assets/css/root.css @@ -1114,6 +1114,13 @@ -fx-fill: -monet-on-surface-variant; } +.mod-nested-section { + -fx-text-fill: -monet-on-surface-variant; + -fx-font-size: 11px; + -fx-font-weight: bold; + -fx-padding: 6 0 1 0; +} + .resource-pack-list-cell:warning { -fx-background-color: -monet-error-container; } diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 32e325bbbfa..e9e4968a791 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -175,6 +175,7 @@ account.username=Username addon.broken_dependency.title=Broken dependency addon.broken_dependency.desc=This dependency existed before, but it does not exist anymore. Try using another download source. addon.bundled=Nested mods +addon.relations=Mod relations addon.category=Category addon.channel.alpha=Alpha addon.channel.beta=Beta @@ -190,6 +191,11 @@ addon.check_update.file=File addon.check_update.source=Source addon.check_update.target_version=Target Version addon.curseforge=CurseForge +addon.dependencies=Dependencies +addon.dependencies.bundled=Bundled +addon.dependencies.disabled=Disabled +addon.dependencies.installed=Installed +addon.dependencies.missing=Not installed addon.dependency.embedded=Built-in Dependencies (Already packaged in the addon file by the author. No need to download separately) addon.dependency.optional=Optional Dependencies (If missing, the game will run normally, but the addon features may be missing) addon.dependency.required=Required Dependencies (Must be downloaded separately. Missing may prevent the game from launching) diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 24950db9880..929268e1366 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -173,6 +173,7 @@ account.username=使用者名稱 addon.broken_dependency.title=損壞的相依內容 addon.broken_dependency.desc=該相依內容曾經存在於附加內容下載源中,但現在已被刪除,請嘗試其他下載源。 addon.bundled=嵌套模組 +addon.relations=模組關係 addon.category=類別 addon.channel.alpha=Alpha addon.channel.beta=Beta @@ -188,6 +189,11 @@ addon.check_update.file=檔案 addon.check_update.source=來源 addon.check_update.target_version=目標版本 addon.curseforge=CurseForge +addon.dependencies=模組依賴 +addon.dependencies.bundled=已內置 +addon.dependencies.disabled=已停用 +addon.dependencies.installed=已安裝 +addon.dependencies.missing=未安裝 addon.dependency.embedded=內建相依內容 (作者已經打包在檔案中,無需單獨下載) addon.dependency.optional=可選相依內容 (如果不安裝,遊戲可以執行,但功能可能缺失) addon.dependency.required=必需相依內容 (必須單獨下載,缺少可能會導致遊戲無法啟動) 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 db1724517df..58b712dcd71 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -175,6 +175,7 @@ account.username=用户名 addon.broken_dependency.title=损坏的前置内容 addon.broken_dependency.desc=该前置内容曾经在该附加内容下载源上存在过,但现在被删除了。换个下载源试试吧。 addon.bundled=嵌套模组 +addon.relations=模组关系 addon.category=类别 addon.channel.alpha=快照版本 addon.channel.beta=测试版本 @@ -190,6 +191,11 @@ addon.check_update.file=文件 addon.check_update.source=来源 addon.check_update.target_version=目标版本 addon.curseforge=CurseForge +addon.dependencies=模组依赖 +addon.dependencies.bundled=已内置 +addon.dependencies.disabled=已禁用 +addon.dependencies.installed=已安装 +addon.dependencies.missing=未安装 addon.dependency.embedded=内置的前置内容 (已经由作者打包在文件中,无需另外下载) addon.dependency.optional=可选的前置内容 (若缺失游戏能够正常运行,但功能可能缺失) addon.dependency.required=必需的前置内容 (必须另外下载,缺失可能会导致游戏无法启动) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/FabricModMetadata.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/FabricModMetadata.java index f083b94336f..70decfcabce 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/FabricModMetadata.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/FabricModMetadata.java @@ -35,6 +35,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; @Immutable @@ -47,12 +48,16 @@ public final class FabricModMetadata { private final List authors; private final Map contact; private final List jars; + private final Map depends; + + // Loader/runtime ids that are not user-installable mods, excluded from the dependency list. + private static final Set IGNORED_DEPENDENCIES = Set.of("minecraft", "java", "fabricloader"); public FabricModMetadata() { - this("", "", "", "", "", Collections.emptyList(), Collections.emptyMap(), Collections.emptyList()); + this("", "", "", "", "", Collections.emptyList(), Collections.emptyMap(), Collections.emptyList(), Collections.emptyMap()); } - public FabricModMetadata(String id, String name, String version, String icon, String description, List authors, Map contact, List jars) { + public FabricModMetadata(String id, String name, String version, String icon, String description, List authors, Map contact, List jars, Map depends) { this.id = id; this.name = name; this.version = version; @@ -61,6 +66,7 @@ public FabricModMetadata(String id, String name, String version, String icon, St this.authors = authors; this.contact = contact; this.jars = jars; + this.depends = depends; } public static LocalModFile fromFile(ModManager modManager, Path modFile, ZipFileTree tree) throws IOException, JsonParseException { @@ -71,8 +77,10 @@ public static LocalModFile fromFile(ModManager modManager, Path modFile, ZipFile String authors = metadata.authors == null ? "" : metadata.authors.stream().map(author -> author.name).collect(Collectors.joining(", ")); List bundledMods = metadata.jars == null ? Collections.emptyList() : metadata.jars.stream().map(jar -> jar.file).toList(); + List dependencies = metadata.depends == null ? Collections.emptyList() + : metadata.depends.keySet().stream().filter(id -> !IGNORED_DEPENDENCIES.contains(id)).toList(); return new LocalModFile(modManager, modManager.getLocalMod(metadata.id, ModLoaderType.FABRIC), modFile, metadata.name, new LocalAddonFile.Description(metadata.description), - authors, metadata.version, "", metadata.contact != null ? metadata.contact.getOrDefault("homepage", "") : "", metadata.icon, bundledMods); + authors, metadata.version, "", metadata.contact != null ? metadata.contact.getOrDefault("homepage", "") : "", metadata.icon, bundledMods, dependencies); } public static final class FabricNestedJar { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/ForgeNewModMetadata.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/ForgeNewModMetadata.java index 5d1b8241018..e795fb07558 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/ForgeNewModMetadata.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/ForgeNewModMetadata.java @@ -48,6 +48,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.StringJoiner; import java.util.jar.Attributes; import java.util.jar.Manifest; @@ -56,6 +57,9 @@ @Immutable public final class ForgeNewModMetadata { + // Loader/runtime ids that are not user-installable mods, excluded from the dependency list. + private static final Set IGNORED_DEPENDENCIES = Set.of("minecraft", "forge", "neoforge"); + private final String modLoader; private final String loaderVersion; @@ -250,10 +254,28 @@ private static LocalModFile fromFile0( } } + List dependencies = new ArrayList<>(); + for (Map dependency : parseDependencies(tomlParseResult, mod.getModId())) { + if (!(dependency.get("modId") instanceof String depId) || IGNORED_DEPENDENCIES.contains(depId)) { + continue; + } + boolean required; + if (dependency.get("mandatory") instanceof Boolean mandatory) { + required = mandatory; + } else if (dependency.get("type") instanceof String depType) { + required = depType.equalsIgnoreCase("required"); + } else { + required = true; + } + if (required) { + dependencies.add(depId); + } + } + return new LocalModFile(modManager, modManager.getLocalMod(mod.getModId(), type), modFile, mod.getDisplayName(), new LocalAddonFile.Description(mod.getDescription()), mod.getAuthors(), jarVersion == null ? mod.getVersion() : mod.getVersion().replace("${file.jarVersion}", jarVersion), "", mod.getDisplayURL(), - metadata.getLogoFile(), bundledMods); + metadata.getLogoFile(), bundledMods, dependencies); } private static LocalModFile fromEmbeddedMod(ModManager modManager, Path modFile, ZipFileTree tree, ModLoaderType modLoaderType) throws IOException { @@ -317,39 +339,40 @@ private static LocalModFile fromEmbeddedMod(ModManager modManager, Path modFile, throw new IOException(); } - private static ModLoaderType analyzeLoader(TomlParseResult toml, String modID, ModLoaderType loader) { - List> dependencies = null; + private static List> parseDependencies(TomlParseResult toml, String modID) { try { TomlArray tomlArray = toml.getArray("dependencies." + modID); if (tomlArray != null) { - dependencies = tomlArray.toList().stream().map( o -> ((TomlTable) o).toMap()).toList(); + return tomlArray.toList().stream().map(o -> ((TomlTable) o).toMap()).toList(); } } catch (ClassCastException ignored) { // https://github.com/HMCL-dev/HMCL/issues/5068 } - if (dependencies == null) { + try { + TomlArray tomlArray = toml.getArray("dependencies"); // ??? I have no idea why some of the Forge mods use [[dependencies]] + if (tomlArray != null) { + return tomlArray.toList().stream().map(o -> ((TomlTable) o).toMap()).toList(); + } + } catch (ClassCastException e) { try { - TomlArray tomlArray = toml.getArray("dependencies"); // ??? I have no idea why some of the Forge mods use [[dependencies]] - if (tomlArray != null) { - dependencies = tomlArray.toList().stream().map( o -> ((TomlTable) o).toMap()).toList(); - } - } catch (ClassCastException e) { - try { - TomlTable table = toml.getTable("dependencies"); - if (table == null) - return loader; - + TomlTable table = toml.getTable("dependencies"); + if (table != null) { TomlArray tomlArray = table.getArray(modID); if (tomlArray != null) { - dependencies = tomlArray.toList().stream().map( o -> ((TomlTable) o).toMap()).toList(); + return tomlArray.toList().stream().map(o -> ((TomlTable) o).toMap()).toList(); } - } catch (Throwable ignored) { } + } catch (Throwable ignored) { } + } - if (dependencies == null) { - return loader; - } + return List.of(); + } + + private static ModLoaderType analyzeLoader(TomlParseResult toml, String modID, ModLoaderType loader) { + List> dependencies = parseDependencies(toml, modID); + if (dependencies.isEmpty()) { + return loader; } ModLoaderType result = null; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/QuiltModMetadata.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/QuiltModMetadata.java index 6d64cc11f69..26d7a7638bf 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/QuiltModMetadata.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/QuiltModMetadata.java @@ -17,6 +17,8 @@ */ package org.jackhuang.hmcl.addon.meta; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParseException; import kala.compress.archivers.zip.ZipArchiveEntry; @@ -30,9 +32,11 @@ import java.io.IOException; import java.nio.file.Path; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; @Immutable @@ -66,15 +70,20 @@ public NestedJar(String file) { private final String version; private final Metadata metadata; private final List jars; + private final JsonArray depends; - public QuiltLoader(String id, String version, Metadata metadata, List jars) { + public QuiltLoader(String id, String version, Metadata metadata, List jars, JsonArray depends) { this.id = id; this.version = version; this.metadata = metadata; this.jars = jars; + this.depends = depends; } } + // Loader/runtime ids that are not user-installable mods, excluded from the dependency list. + private static final Set IGNORED_DEPENDENCIES = Set.of("minecraft", "java", "quilt_loader", "quilt_base"); + private final int schema_version; private final QuiltLoader quilt_loader; @@ -101,6 +110,21 @@ public static LocalModFile fromFile(ModManager modManager, Path modFile, ZipFile List bundledMods = root.quilt_loader.jars == null ? Collections.emptyList() : root.quilt_loader.jars.stream().map(jar -> jar.file).toList(); + List dependencies = new ArrayList<>(); + if (root.quilt_loader.depends != null) { + for (JsonElement element : root.quilt_loader.depends) { + String id = null; + if (element.isJsonPrimitive()) { + id = element.getAsString(); + } else if (element.isJsonObject() && element.getAsJsonObject().has("id")) { + id = element.getAsJsonObject().getAsJsonPrimitive("id").getAsString(); + } + if (id != null && !IGNORED_DEPENDENCIES.contains(id)) { + dependencies.add(id); + } + } + } + return new LocalModFile( modManager, modManager.getLocalMod(root.quilt_loader.id, ModLoaderType.QUILT), @@ -112,7 +136,8 @@ public static LocalModFile fromFile(ModManager modManager, Path modFile, ZipFile "", homepage, root.quilt_loader.metadata.icon, - bundledMods + bundledMods, + dependencies ); } } 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 eafb16b0054..79b55cda0d8 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 @@ -51,6 +51,7 @@ public final class LocalModFile extends LocalAddonFile implements Comparable bundledMods; + private final List dependencies; private final BooleanProperty activeProperty; public LocalModFile(ModManager modManager, LocalMod mod, Path file, String name, Description description) { @@ -58,10 +59,10 @@ public LocalModFile(ModManager modManager, LocalMod mod, Path file, String name, } public LocalModFile(ModManager modManager, LocalMod mod, Path file, String name, Description description, String authors, String version, String gameVersion, String url, String logoPath) { - this(modManager, mod, file, name, description, authors, version, gameVersion, url, logoPath, List.of()); + this(modManager, mod, file, name, description, authors, version, gameVersion, url, logoPath, List.of(), List.of()); } - public LocalModFile(ModManager modManager, LocalMod mod, Path file, String name, Description description, String authors, String version, String gameVersion, String url, String logoPath, List bundledMods) { + public LocalModFile(ModManager modManager, LocalMod mod, Path file, String name, Description description, String authors, String version, String gameVersion, String url, String logoPath, List bundledMods, List dependencies) { super(); this.modManager = modManager; this.mod = mod; @@ -74,6 +75,7 @@ public LocalModFile(ModManager modManager, LocalMod mod, Path file, String name, this.url = url; this.logoPath = logoPath; this.bundledMods = bundledMods == null ? List.of() : List.copyOf(bundledMods); + this.dependencies = dependencies == null ? List.of() : List.copyOf(dependencies); activeProperty = new SimpleBooleanProperty(this, "active", !modManager.isDisabled(file)) { @Override @@ -159,6 +161,14 @@ public boolean hasBundledMods() { return !bundledMods.isEmpty(); } + public List getDependencies() { + return dependencies; + } + + public boolean hasDependencies() { + return !dependencies.isEmpty(); + } + public BooleanProperty activeProperty() { return activeProperty; } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/ModManager.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/ModManager.java index 8acc3151117..d00a8deb521 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/ModManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/ModManager.java @@ -68,6 +68,13 @@ private interface ModMetadataReader { private boolean loaded = false; + // Caches parsed mods by file path + fingerprint, so repeated refreshes only re-parse the + // files that were actually added, removed, or changed instead of rescanning everything. + private final Map cache = new HashMap<>(); + + private record CachedMod(long lastModified, long size, LocalModFile mod) { + } + public ModManager(GameRepository repository, String id) { super(repository, id); } @@ -100,14 +107,14 @@ public boolean hasMod(String modId, ModLoaderType modLoaderType) { } } - private void addModInfo(Path file) { + private LocalModFile addModInfo(Path file) { String fileName = StringUtils.removeSuffix(FileUtils.getName(file), DISABLED_EXTENSION, OLD_EXTENSION); String extension = fileName.substring(fileName.lastIndexOf(".") + 1); List> readersMap = READERS.get(extension); if (readersMap == null) { // Is not a mod file. - return; + return null; } Set modLoaderTypes = analyzer.getModLoaders(); @@ -169,35 +176,86 @@ private void addModInfo(Path file) { if (!modInfo.isOld()) { localFiles.add(modInfo); } + + return modInfo; + } + + private void removeModInfo(LocalModFile modInfo) { + localFiles.remove(modInfo); + + LocalMod mod = modInfo.getMod(); + mod.getFiles().remove(modInfo); + mod.getOldFiles().remove(modInfo); + if (mod.getFiles().isEmpty() && mod.getOldFiles().isEmpty()) { + localMods.remove(pair(mod.getId(), mod.getModLoaderType())); + } + } + + private static boolean isModCandidate(Path file) { + String name = StringUtils.removeSuffix(FileUtils.getName(file), DISABLED_EXTENSION, OLD_EXTENSION); + int dot = name.lastIndexOf('.'); + return dot >= 0 && READERS.containsKey(name.substring(dot + 1)); + } + + private void collectModFiles(Path file, Map current) { + try { + if (Files.isRegularFile(file) && isModCandidate(file)) { + current.put(file, new long[]{Files.getLastModifiedTime(file).toMillis(), Files.size(file)}); + } + } catch (IOException e) { + LOG.warning("Failed to stat mod file " + file, e); + } } @Override public void refresh() throws IOException { lock.lock(); try { - localFiles.clear(); - localMods.clear(); - analyzer = LibraryAnalyzer.analyze(getRepository().getResolvedPreservingPatchesVersion(id), null); boolean supportSubfolders = analyzer.has(LibraryAnalyzer.LibraryType.FORGE) || analyzer.has(LibraryAnalyzer.LibraryType.QUILT); + // Snapshot the current mod files on disk together with their fingerprints. + Map current = new LinkedHashMap<>(); if (Files.isDirectory(getDirectory())) { try (DirectoryStream modsDirectoryStream = Files.newDirectoryStream(getDirectory())) { for (Path subitem : modsDirectoryStream) { if (supportSubfolders && Files.isDirectory(subitem) && !".connector".equalsIgnoreCase(subitem.getFileName().toString())) { try (DirectoryStream subitemDirectoryStream = Files.newDirectoryStream(subitem)) { for (Path subsubitem : subitemDirectoryStream) { - addModInfo(subsubitem); + collectModFiles(subsubitem, current); } } } else { - addModInfo(subitem); + collectModFiles(subitem, current); } } } } + + // Drop cached mods whose file was removed or changed. + Iterator> iterator = cache.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + long[] stamp = current.get(entry.getKey()); + CachedMod cached = entry.getValue(); + if (stamp == null || stamp[0] != cached.lastModified() || stamp[1] != cached.size()) { + removeModInfo(cached.mod()); + iterator.remove(); + } + } + + // Parse only the files that are new or changed; reuse the rest from the cache. + for (Map.Entry entry : current.entrySet()) { + if (cache.containsKey(entry.getKey())) + continue; + LocalModFile modInfo = addModInfo(entry.getKey()); + if (modInfo != null) { + cache.put(entry.getKey(), new CachedMod(entry.getValue()[0], entry.getValue()[1], modInfo)); + } + } + loaded = true; } finally { lock.unlock(); @@ -235,7 +293,10 @@ public void addMod(Path file) throws IOException { Path newFile = modsDirectory.resolve(file.getFileName()); FileUtils.copyFile(file, newFile); - addModInfo(newFile); + LocalModFile modInfo = addModInfo(newFile); + if (modInfo != null) { + cache.put(newFile, new CachedMod(Files.getLastModifiedTime(newFile).toMillis(), Files.size(newFile), modInfo)); + } } finally { lock.unlock(); } From d4d08b1670562930416295c877866aef6b3fd42e Mon Sep 17 00:00:00 2001 From: lokins Date: Sat, 27 Jun 2026 23:14:27 +0800 Subject: [PATCH 4/8] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E7=9A=84=E5=8A=A8=E7=94=BB=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jackhuang/hmcl/ui/versions/ModListPageSkin.java | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java index 0418c069ddd..d1da468c90d 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java @@ -20,7 +20,6 @@ import com.jfoenix.controls.*; import javafx.animation.PauseTransition; import javafx.application.Platform; -import javafx.beans.InvalidationListener; import javafx.beans.binding.Bindings; import javafx.beans.property.BooleanProperty; import javafx.beans.property.ObjectProperty; @@ -94,10 +93,6 @@ final class ModListPageSkin extends SkinBase { private final JFXListView listView; private final JFXTextField searchField; - // Re-render visible rows when a mod is enabled/disabled, so dependency statuses - // ("installed"/"disabled"/...) of the mods related to it update immediately. - private final InvalidationListener activeChangeListener; - @FXThread private boolean isSearching = false; @@ -112,7 +107,6 @@ final class ModListPageSkin extends SkinBase { root.getStyleClass().add("no-padding"); listView = new JFXListView<>(); listView.getStyleClass().add("no-horizontal-scrollbar"); - activeChangeListener = o -> listView.refresh(); { toolbarPane = new TransitionPane(); @@ -230,12 +224,6 @@ final class ModListPageSkin extends SkinBase { listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); Bindings.bindContent(listView.getItems(), skinnable.getItems()); skinnable.getItems().addListener((ListChangeListener) c -> { - while (c.next()) { - for (ModInfoObject removed : c.getRemoved()) - removed.active.removeListener(activeChangeListener); - for (ModInfoObject added : c.getAddedSubList()) - added.active.addListener(activeChangeListener); - } if (isSearching) { search(); } From 08e40c3c17c758ada60c2f63e3a4cff27211e924 Mon Sep 17 00:00:00 2001 From: lokins Date: Sun, 28 Jun 2026 00:47:10 +0800 Subject: [PATCH 5/8] =?UTF-8?q?=E6=A8=A1=E7=BB=84=E7=A6=81=E7=94=A8/?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=97=B6=E6=A3=80=E6=B5=8B=E5=B9=B6=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E5=8F=8D=E5=90=91=E4=BE=9D=E8=B5=96=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E7=BA=A7=E8=81=94=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/versions/ModListPageSkin.java | 174 +++++++++++++++++- .../resources/assets/lang/I18N.properties | 6 + .../resources/assets/lang/I18N_zh.properties | 6 + .../assets/lang/I18N_zh_CN.properties | 6 + 4 files changed, 187 insertions(+), 5 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java index d1da468c90d..8f091b4c677 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java @@ -24,6 +24,7 @@ import javafx.beans.property.BooleanProperty; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleBooleanProperty; +import javafx.collections.FXCollections; import javafx.collections.ListChangeListener; import javafx.css.PseudoClass; import javafx.geometry.Insets; @@ -36,6 +37,7 @@ import javafx.scene.input.MouseEvent; 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.stage.Stage; @@ -172,14 +174,54 @@ final class ModListPageSkin extends SkinBase { toolbarSelecting.getChildren().setAll( createToolbarButton2(i18n("button.remove"), SVG.DELETE_FOREVER, () -> { - Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), () -> { - skinnable.removeSelected(listView.getSelectionModel().getSelectedItems()); - }, null); + var selected = listView.getSelectionModel().getSelectedItems(); + List targets = new ArrayList<>(); + for (ModInfoObject item : selected) + if (item != null) targets.add(item.getModInfo()); + List dependents = findActiveDependents(targets); + if (dependents.isEmpty()) { + Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), + () -> skinnable.removeSelected(selected), null); + } else { + List selectedSnapshot = new ArrayList<>(selected); + Controllers.dialog(new DependencyWarningDialog(dependents, i18n("button.remove"), List.of( + new CascadeOption(i18n("addon.dependencies.warning.cascade.none"), + () -> skinnable.removeSelected(FXCollections.observableArrayList(selectedSnapshot))), + new CascadeOption(i18n("addon.dependencies.warning.cascade"), () -> { + for (ModInfoObject dependent : dependents) + dependent.getModInfo().setActive(false); + skinnable.removeSelected(FXCollections.observableArrayList(selectedSnapshot)); + }), + new CascadeOption(i18n("addon.dependencies.warning.cascade.delete"), () -> { + List all = new ArrayList<>(selectedSnapshot); + all.addAll(dependents); + skinnable.removeSelected(FXCollections.observableArrayList(all)); + }) + ))); + } }), createToolbarButton2(i18n("mods.enable"), SVG.CHECK, () -> skinnable.enableSelected(listView.getSelectionModel().getSelectedItems())), - createToolbarButton2(i18n("mods.disable"), SVG.CLOSE, () -> - skinnable.disableSelected(listView.getSelectionModel().getSelectedItems())), + createToolbarButton2(i18n("mods.disable"), SVG.CLOSE, () -> { + var selected = listView.getSelectionModel().getSelectedItems(); + List targets = new ArrayList<>(); + for (ModInfoObject item : selected) + if (item != null) targets.add(item.getModInfo()); + List dependents = findActiveDependents(targets); + if (dependents.isEmpty()) { + skinnable.disableSelected(selected); + } else { + Controllers.dialog(new DependencyWarningDialog(dependents, i18n("mods.disable"), List.of( + new CascadeOption(i18n("addon.dependencies.warning.cascade.none"), + () -> skinnable.disableSelected(selected)), + new CascadeOption(i18n("addon.dependencies.warning.cascade"), () -> { + skinnable.disableSelected(selected); + for (ModInfoObject dependent : dependents) + dependent.getModInfo().setActive(false); + }) + ))); + } + }), createToolbarButton2(i18n("addon.check_update.button"), SVG.UPDATE, () -> skinnable.checkUpdates( listView.getSelectionModel().getSelectedItems().stream() @@ -302,6 +344,28 @@ private void search() { } } + // Finds enabled mods (other than the targets themselves) that declare a dependency on any of + // the targets — i.e. mods that may break if the targets are disabled. + private List findActiveDependents(Collection targets) { + Set targetIds = new HashSet<>(); + for (LocalModFile target : targets) { + if (StringUtils.isNotBlank(target.getId())) + targetIds.add(target.getId()); + } + if (targetIds.isEmpty()) + return List.of(); + + List dependents = new ArrayList<>(); + for (ModInfoObject item : getSkinnable().getItems()) { + LocalModFile mod = item.getModInfo(); + if (targets.contains(mod) || !mod.isActive()) + continue; + if (mod.getDependencies().stream().anyMatch(targetIds::contains)) + dependents.add(item); + } + return dependents; + } + static final class ModInfoObject { private final BooleanProperty active; // Whether the nested (Jar-in-Jar) mods of this entry are expanded in the list. @@ -566,6 +630,84 @@ final class ModInfoDialog extends JFXDialogLayout { } } + // A selectable "what to do with the dependent mods" choice. action performs the full operation + // (always acting on the target mods, plus optionally on the dependents). + private record CascadeOption(String label, Runnable action) { + @Override + public String toString() { + return label; + } + } + + final class DependencyWarningDialog extends JFXDialogLayout { + + DependencyWarningDialog(List dependents, String confirmText, List options) { + setHeading(new Label(i18n("addon.dependencies.warning.title"))); + + Label message = new Label(i18n("addon.dependencies.warning")); + message.setWrapText(true); + + ComponentList list = new ComponentList(); + list.getStyleClass().add("no-padding"); + for (ModInfoObject dependent : dependents) { + HBox row = new HBox(8); + row.setAlignment(Pos.CENTER_LEFT); + row.setPadding(new Insets(8)); + row.setMouseTransparent(true); + + ImageContainer icon = new ImageContainer(32); + dependent.loadIcon(icon, null); + + TwoLineListItem content = new TwoLineListItem(); + HBox.setHgrow(content, Priority.ALWAYS); + content.setTitle(dependent.getModTranslations() != null && I18n.isUseChinese() + ? dependent.getModTranslations().getDisplayName() + : dependent.getModInfo().getName()); + StringJoiner subtitle = new StringJoiner(" | "); + if (StringUtils.isNotBlank(dependent.getModInfo().getId())) + subtitle.add(dependent.getModInfo().getId()); + subtitle.add(FileUtils.getName(dependent.getModInfo().getFile())); + content.setSubtitle(subtitle.toString()); + + row.getChildren().setAll(icon, content); + list.getContent().add(row); + } + + ScrollPane scrollPane = new ScrollPane(list); + scrollPane.setFitToWidth(true); + scrollPane.setMaxHeight(300); + FXUtils.smoothScrolling(scrollPane); + scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + + setBody(new VBox(10, message, scrollPane)); + + JFXComboBox cascade = new JFXComboBox<>(); + cascade.getItems().setAll(options); + cascade.getSelectionModel().selectFirst(); + + HBox cascadeBox = new HBox(8, new Label(i18n("addon.dependencies.warning.cascade.label")), cascade); + cascadeBox.setAlignment(Pos.CENTER_LEFT); + + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + + JFXButton cancelButton = new JFXButton(i18n("button.cancel")); + cancelButton.setOnAction(e -> fireEvent(new DialogCloseEvent())); + + JFXButton confirmButton = new JFXButton(confirmText); + confirmButton.getStyleClass().add("dialog-accept"); + confirmButton.setOnAction(e -> { + fireEvent(new DialogCloseEvent()); + CascadeOption selected = cascade.getValue(); + if (selected != null) + selected.action().run(); + }); + + getActions().setAll(cascadeBox, spacer, cancelButton, confirmButton); + onEscPressed(this, cancelButton::fire); + } + } + private static final Lazy menu = new Lazy<>(PopupMenu::new); private static final Lazy popup = new Lazy<>(() -> new JFXPopup(menu.get())); @@ -619,6 +761,28 @@ final class ModInfoListCell extends MDListCell { e.consume(); }); + // Warn before disabling a mod that other enabled mods depend on. + checkBox.addEventFilter(MouseEvent.MOUSE_PRESSED, e -> { + ModInfoObject item = getItem(); + if (item == null || !checkBox.isSelected()) + return; // enabling (or empty) — nothing to warn about + List targets = List.of(item.getModInfo()); + List dependents = findActiveDependents(targets); + if (!dependents.isEmpty()) { + e.consume(); // block the immediate toggle; let the user confirm first + LocalModFile target = item.getModInfo(); + Controllers.dialog(new DependencyWarningDialog(dependents, i18n("mods.disable"), List.of( + new CascadeOption(i18n("addon.dependencies.warning.cascade.none"), + () -> target.setActive(false)), + new CascadeOption(i18n("addon.dependencies.warning.cascade"), () -> { + target.setActive(false); + for (ModInfoObject dependent : dependents) + dependent.getModInfo().setActive(false); + }) + ))); + } + }); + nestedBox.getStyleClass().add("mod-nested-list"); nestedBox.visibleProperty().bind(expanded); nestedBox.managedProperty().bind(nestedBox.visibleProperty()); diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index e9e4968a791..2c3243f7616 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -196,6 +196,12 @@ addon.dependencies.bundled=Bundled addon.dependencies.disabled=Disabled addon.dependencies.installed=Installed addon.dependencies.missing=Not installed +addon.dependencies.warning=The following enabled mods depend on the affected mod(s) and may stop working: +addon.dependencies.warning.cascade=Also disable +addon.dependencies.warning.cascade.delete=Also delete +addon.dependencies.warning.cascade.label=Dependent mods: +addon.dependencies.warning.cascade.none=Leave as-is +addon.dependencies.warning.title=Dependency warning addon.dependency.embedded=Built-in Dependencies (Already packaged in the addon file by the author. No need to download separately) addon.dependency.optional=Optional Dependencies (If missing, the game will run normally, but the addon features may be missing) addon.dependency.required=Required Dependencies (Must be downloaded separately. Missing may prevent the game from launching) diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 929268e1366..26b8fa9a0c3 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -194,6 +194,12 @@ addon.dependencies.bundled=已內置 addon.dependencies.disabled=已停用 addon.dependencies.installed=已安裝 addon.dependencies.missing=未安裝 +addon.dependencies.warning=以下已啟用的模組依賴於受影響的模組,操作後可能無法正常運作: +addon.dependencies.warning.cascade=一併停用 +addon.dependencies.warning.cascade.delete=一併刪除 +addon.dependencies.warning.cascade.label=對依賴模組: +addon.dependencies.warning.cascade.none=不處理 +addon.dependencies.warning.title=依賴警告 addon.dependency.embedded=內建相依內容 (作者已經打包在檔案中,無需單獨下載) addon.dependency.optional=可選相依內容 (如果不安裝,遊戲可以執行,但功能可能缺失) addon.dependency.required=必需相依內容 (必須單獨下載,缺少可能會導致遊戲無法啟動) 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 58b712dcd71..e7729bdfd31 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -196,6 +196,12 @@ addon.dependencies.bundled=已内置 addon.dependencies.disabled=已禁用 addon.dependencies.installed=已安装 addon.dependencies.missing=未安装 +addon.dependencies.warning=以下已启用的模组依赖于受影响的模组,操作后可能无法正常工作: +addon.dependencies.warning.cascade=一并禁用 +addon.dependencies.warning.cascade.delete=一并删除 +addon.dependencies.warning.cascade.label=对依赖模组: +addon.dependencies.warning.cascade.none=不处理 +addon.dependencies.warning.title=依赖警告 addon.dependency.embedded=内置的前置内容 (已经由作者打包在文件中,无需另外下载) addon.dependency.optional=可选的前置内容 (若缺失游戏能够正常运行,但功能可能缺失) addon.dependency.required=必需的前置内容 (必须另外下载,缺失可能会导致游戏无法启动) From 94cdf51a5c2b56ec5f1d4c728e280b03f03623d3 Mon Sep 17 00:00:00 2001 From: lokins Date: Sun, 28 Jun 2026 10:45:41 +0800 Subject: [PATCH 6/8] =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=9C=A8=E6=A8=A1?= =?UTF-8?q?=E7=BB=84=E4=B8=8B=E8=BD=BD=E9=A1=B5=E9=87=8C=E5=AF=B9=E5=BF=85?= =?UTF-8?q?=E9=9C=80=E6=A8=A1=E7=BB=84=E7=9A=84=E5=AE=89=E8=A3=85=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E8=BF=9B=E8=A1=8C=E6=A3=80=E6=B5=8B=E4=B8=8E=E6=A0=87?= =?UTF-8?q?=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/download/DownloadPage.java | 13 +- .../hmcl/ui/versions/DownloadPage.java | 138 +++++++++++++++++- .../hmcl/ui/versions/ModListPage.java | 4 + .../hmcl/ui/versions/ModListPageSkin.java | 17 +++ 4 files changed, 166 insertions(+), 6 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java index 404833134c4..e5c930da186 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java @@ -69,11 +69,11 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage { public static final org.jackhuang.hmcl.ui.versions.DownloadPage.DownloadCallback FOR_MOD = - (downloadProvider, profile, version, mod, file) -> download(downloadProvider, profile, version, file, "mods"); + (downloadProvider, profile, version, mod, file) -> download(downloadProvider, profile, version, file, "mods", mod); public static final org.jackhuang.hmcl.ui.versions.DownloadPage.DownloadCallback FOR_RESOURCE_PACK = - (downloadProvider, profile, version, pack, file) -> download(downloadProvider, profile, version, file, "resourcepacks"); + (downloadProvider, profile, version, pack, file) -> download(downloadProvider, profile, version, file, "resourcepacks", null); public static final org.jackhuang.hmcl.ui.versions.DownloadPage.DownloadCallback FOR_SHADER = - (downloadProvider, profile, version, shader, file) -> download(downloadProvider, profile, version, file, "shaderpacks"); + (downloadProvider, profile, version, shader, file) -> download(downloadProvider, profile, version, file, "shaderpacks", null); private final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(DecoratorPage.State.fromTitle(i18n("download"), -1)); private final TabHeader tab; @@ -141,9 +141,11 @@ private static Supplier loadVersionFor(Supplier nodeSuppl }; } - public static void download(DownloadProvider downloadProvider, Profile profile, @Nullable String version, RemoteAddon.Version file, String subdirectoryName) { + public static void download(DownloadProvider downloadProvider, Profile profile, @Nullable String version, RemoteAddon.Version file, String subdirectoryName, @Nullable RemoteAddon mod) { if (version == null) version = Profiles.getSelectedInstance(profile); + String finalVersion = version; + Path runDirectory = profile.getRepository().hasVersion(version) ? profile.getRepository().getRunDirectory(version) : profile.getRepository().getBaseDirectory(); Set existingFiles; @@ -175,6 +177,9 @@ public static void download(DownloadProvider downloadProvider, Profile profile, } } else { Controllers.showToast(i18n("install.success")); + if (mod != null) + org.jackhuang.hmcl.ui.versions.DownloadPage.markModInstalled( + new Profile.ProfileVersion(profile, finalVersion), mod); } }), i18n("message.downloading"), TaskCancellationAction.NORMAL); handler.resolve(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadPage.java index 24581b94d7e..fbfd8bddc44 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadPage.java @@ -34,7 +34,9 @@ import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.game.Version; +import org.jackhuang.hmcl.addon.mod.LocalModFile; import org.jackhuang.hmcl.addon.mod.ModLoaderType; +import org.jackhuang.hmcl.addon.mod.ModManager; import org.jackhuang.hmcl.addon.RemoteAddon; import org.jackhuang.hmcl.addon.RemoteAddonRepository; import org.jackhuang.hmcl.setting.Profile; @@ -60,6 +62,7 @@ import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; public class DownloadPage extends Control implements DecoratorPage { private final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(); @@ -86,6 +89,13 @@ public DownloadPage(DownloadListPage page, RemoteAddon addon, Profile.ProfileVer this.mod = translations.getModByCurseForgeId(addon.slug()); this.version = version; this.callback = callback; + + // Warm up the installed-mods cache for this instance as soon as its download page is opened, + // so dependency installation status is ready without reading the mods on every page. + if (version != null && version.version() != null) { + Task.supplyAsync(Schedulers.io(), () -> AddonVersion.getInstalledMods(version)).start(); + } + loadAddonVersions(); this.state.set(State.fromTitle(addon.title())); @@ -167,6 +177,43 @@ public void download(RemoteAddon addon, RemoteAddon.Version file) { } } + // Called when a mod has actually been downloaded into the instance, to refresh just that mod's + // entry in the cached installed-mods map (used for dependency installation status). + public static void markModInstalled(Profile.ProfileVersion version, RemoteAddon addon) { + if (version == null || addon == null) + return; + Set ids = new HashSet<>(); + if (StringUtils.isNotBlank(addon.slug())) + ids.add(addon.slug()); + RemoteAddonRepository.Type type = addon.repoType(); + if (type != null) { + ModTranslations.Mod mod = ModTranslations.getTranslationsByRepositoryType(type).getModByCurseForgeId(addon.slug()); + if (mod != null) + ids.addAll(mod.getModIds()); + } + AddonVersion.markInstalled(version, ids); + } + + // Called when a mod is enabled/disabled in the instance, to keep the cached installed-mods map + // in sync so the download page's dependency status reflects it without a re-scan. + public static void setModActive(Profile.ProfileVersion version, String modId, boolean active) { + if (version == null || StringUtils.isBlank(modId)) + return; + synchronized (AddonVersion.INSTALLED_CACHE_LOCK) { + if (version.equals(AddonVersion.installedCacheKey) && AddonVersion.installedCache != null) + AddonVersion.installedCache.put(modId.toLowerCase(Locale.ROOT), active); + } + } + + // Drops the cached installed-mods map (e.g. after mods are added/removed), so it is rebuilt + // from disk on the next access. + public static void invalidateInstalledMods() { + synchronized (AddonVersion.INSTALLED_CACHE_LOCK) { + AddonVersion.installedCacheKey = null; + AddonVersion.installedCache = null; + } + } + public void saveAs(RemoteAddon.Version file) { String extension = StringUtils.substringAfterLast(file.file().filename(), '.'); @@ -374,7 +421,7 @@ private static final class DependencyAddonItem extends LineButton { Pair.pair(RemoteAddon.DependencyType.BROKEN, "addon.dependency.broken") )); - DependencyAddonItem(DownloadListPage page, RemoteAddon addon, Profile.ProfileVersion version) { + DependencyAddonItem(DownloadListPage page, RemoteAddon addon, Profile.ProfileVersion version, Map installedMods) { HBox pane = new HBox(8); pane.setPadding(new Insets(0, 8, 0, 8)); pane.setAlignment(Pos.CENTER_LEFT); @@ -402,6 +449,24 @@ private static final class DependencyAddonItem extends LineButton { ModTranslations.Mod mod = ModTranslations.getTranslationsByRepositoryType(type).getModByCurseForgeId(addon.slug()); content.setTitle(mod != null && I18n.isUseChinese() ? mod.getDisplayName() : addon.title()); content.setSubtitle(addon.description()); + if (installedMods != null) { + Set candidates = new HashSet<>(); + if (StringUtils.isNotBlank(addon.slug())) + candidates.add(addon.slug().toLowerCase(Locale.ROOT)); + if (mod != null) + for (String modId : mod.getModIds()) + candidates.add(modId.toLowerCase(Locale.ROOT)); + Boolean active = null; + for (String candidate : candidates) { + if (installedMods.containsKey(candidate)) { + active = installedMods.get(candidate); + if (active) + break; + } + } + content.addTag(i18n(active == null ? "addon.dependencies.missing" + : active ? "addon.dependencies.installed" : "addon.dependencies.disabled")); + } for (String category : addon.categories()) { if (page.shouldDisplayCategory(category)) content.addTag(page.getLocalizedCategory(category, null)); @@ -565,6 +630,10 @@ public AddonVersion(RemoteAddon mod, RemoteAddon.Version version, DownloadPage s private void loadDependencies(RemoteAddon.Version version, DownloadPage selfPage, SpinnerPane spinnerPane, ComponentList dependenciesList) { spinnerPane.setLoading(true); Task.composeAsync(() -> { + // Which mods are already installed in the current instance (cached per instance), + // so required dependencies can show their installation status in real time. + Map installedMods = getInstalledMods(selfPage.version); + // TODO: Massive tasks may cause OOM. EnumMap> dependencies = new EnumMap<>(RemoteAddon.DependencyType.class); List> queue = new ArrayList<>(version.dependencies().size()); @@ -587,7 +656,10 @@ private void loadDependencies(RemoteAddon.Version version, DownloadPage selfPage if (dep == RemoteAddon.BROKEN) { return; } - DependencyAddonItem dependencyAddonItem = new DependencyAddonItem(selfPage.page, dep, selfPage.version); + // Only required dependencies need the installation-status hint. + Map statusSource = dependency.getType() == RemoteAddon.DependencyType.REQUIRED + ? installedMods : null; + DependencyAddonItem dependencyAddonItem = new DependencyAddonItem(selfPage.page, dep, selfPage.version, statusSource); dependencies.get(dependency.getType()).add(dependencyAddonItem); }) .setSignificance(Task.TaskSignificance.MINOR)); @@ -607,6 +679,68 @@ private void loadDependencies(RemoteAddon.Version version, DownloadPage selfPage } }).start(); } + + // Caches the installed-mods map for the most recently used instance, so it is read once + // when entering an instance's download page (or switching instances) instead of every time + // a mod's dependency list is shown. + private static final Object INSTALLED_CACHE_LOCK = new Object(); + private static Profile.ProfileVersion installedCacheKey; + private static Map installedCache; + + private static Map getInstalledMods(Profile.ProfileVersion version) { + if (version == null || version.version() == null) + return null; + + synchronized (INSTALLED_CACHE_LOCK) { + if (version.equals(installedCacheKey)) + return installedCache; + } + + Map resolved = resolveInstalledMods(version); + if (resolved != null) { + synchronized (INSTALLED_CACHE_LOCK) { + installedCacheKey = version; + installedCache = resolved; + } + } + return resolved; + } + + // Targeted cache update: mark the given mod ids as installed-and-enabled for the instance, + // so a freshly downloaded mod's dependency status updates without re-reading every mod. + static void markInstalled(Profile.ProfileVersion version, Collection modIds) { + if (version == null) + return; + synchronized (INSTALLED_CACHE_LOCK) { + if (!version.equals(installedCacheKey) || installedCache == null) + return; + for (String id : modIds) { + if (StringUtils.isNotBlank(id)) + installedCache.put(id.toLowerCase(Locale.ROOT), Boolean.TRUE); + } + } + } + + // Builds a map of installed mod id (lower-cased) -> whether any of its files is enabled, + // for the given instance. Returns null when there is no instance to check against. + private static Map resolveInstalledMods(Profile.ProfileVersion version) { + if (version == null || version.version() == null) + return null; + try { + ModManager modManager = version.profile().getRepository().getModManager(version.version()); + Map installed = new HashMap<>(); + for (LocalModFile file : modManager.getLocalFiles()) { + String id = file.getId(); + if (StringUtils.isBlank(id)) + continue; + installed.merge(id.toLowerCase(Locale.ROOT), file.isActive(), Boolean::logicalOr); + } + return installed; + } catch (Exception e) { + LOG.warning("Failed to resolve installed mods for dependency status", e); + return null; + } + } } @FunctionalInterface diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java index f38189c3578..4a3dfb58dfa 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java @@ -108,6 +108,10 @@ public void loadVersion(Profile profile, String id) { private void loadMods(ModManager modManager) { setLoading(true); + // The mod set may have changed (added/removed/refreshed); drop the download page's cached + // installed-mods snapshot so dependency status there is re-read. + DownloadPage.invalidateInstalledMods(); + this.modManager = modManager; CompletableFuture.supplyAsync(() -> { lock.lock(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java index 8f091b4c677..28761a624e5 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java @@ -20,6 +20,7 @@ import com.jfoenix.controls.*; import javafx.animation.PauseTransition; import javafx.application.Platform; +import javafx.beans.InvalidationListener; import javafx.beans.binding.Bindings; import javafx.beans.property.BooleanProperty; import javafx.beans.property.ObjectProperty; @@ -95,6 +96,16 @@ final class ModListPageSkin extends SkinBase { private final JFXListView listView; private final JFXTextField searchField; + // Keeps the download page's cached installed-mods map in sync when a mod is enabled/disabled, + // so dependency installation status there stays correct without a re-scan. + private final InvalidationListener activeChangeListener = observable -> { + if (observable instanceof BooleanProperty property && property.getBean() instanceof LocalModFile mod) { + DownloadPage.setModActive( + new Profile.ProfileVersion(getSkinnable().getProfile(), getSkinnable().getInstanceId()), + mod.getId(), mod.isActive()); + } + }; + @FXThread private boolean isSearching = false; @@ -266,6 +277,12 @@ final class ModListPageSkin extends SkinBase { listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); Bindings.bindContent(listView.getItems(), skinnable.getItems()); skinnable.getItems().addListener((ListChangeListener) c -> { + while (c.next()) { + for (ModInfoObject removed : c.getRemoved()) + removed.active.removeListener(activeChangeListener); + for (ModInfoObject added : c.getAddedSubList()) + added.active.addListener(activeChangeListener); + } if (isSearching) { search(); } From 9349260bf6accbb276dbb2e1f3d257e5033a959d Mon Sep 17 00:00:00 2001 From: lokins Date: Sun, 28 Jun 2026 14:38:19 +0800 Subject: [PATCH 7/8] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=B7=B2=E6=9C=89?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E3=80=81=E6=9B=B4=E6=94=B9=E6=A8=A1=E7=BB=84?= =?UTF-8?q?=E5=90=8D=E8=BF=87=E9=95=BF=E7=9A=84=E6=88=AA=E6=96=AD=E9=80=BB?= =?UTF-8?q?=E8=BE=91=E3=80=81=E4=BF=AE=E5=A4=8D=E6=AD=A7=E4=B9=89=E6=96=87?= =?UTF-8?q?=E6=A1=88=E5=8F=8A=E7=BC=BA=E5=A4=B1=E7=9A=84=E6=A8=A1=E7=BB=84?= =?UTF-8?q?=E4=BE=9D=E8=B5=96=E5=BF=BD=E7=95=A5=E9=9B=86=E3=80=81=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E5=B9=B3=E6=BB=91=E5=8A=A8=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/jackhuang/hmcl/game/LogExporter.java | 1 + .../hmcl/ui/construct/TwoLineListItem.java | 43 ++++- .../hmcl/ui/versions/DownloadPage.java | 4 + .../hmcl/ui/versions/ModListPageSkin.java | 149 +++++++++++++++--- .../resources/assets/lang/I18N.properties | 2 +- .../resources/assets/lang/I18N_zh.properties | 2 +- .../assets/lang/I18N_zh_CN.properties | 2 +- .../hmcl/addon/meta/FabricModMetadata.java | 4 +- .../hmcl/addon/meta/QuiltModMetadata.java | 4 +- .../jackhuang/hmcl/addon/mod/ModManager.java | 9 +- 10 files changed, 181 insertions(+), 39 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java index eae2163d3d6..b8bcdcd1f34 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java @@ -117,6 +117,7 @@ public static CompletableFuture exportLogs( infoBuilder.append("None").append(System.lineSeparator()); } else { infoBuilder.append("These mods may conflict with their bundled copies and cause crashes:").append(System.lineSeparator()); + infoBuilder.append("(Heuristic: matched by mod id appearing in the bundled jar file name; may include false positives.)").append(System.lineSeparator()); for (String line : duplicates) { infoBuilder.append("\t|-> ").append(line).append(System.lineSeparator()); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TwoLineListItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TwoLineListItem.java index f9f90e95904..f8b81d8305b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TwoLineListItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TwoLineListItem.java @@ -23,10 +23,13 @@ import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.css.PseudoClass; +import javafx.geometry.HPos; +import javafx.geometry.Insets; import javafx.geometry.Pos; +import javafx.geometry.VPos; +import javafx.scene.Node; import javafx.scene.control.Label; import javafx.scene.layout.HBox; -import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; import org.jackhuang.hmcl.ui.FXUtils; @@ -46,9 +49,41 @@ public TwoLineListItem() { lblTitle = new Label(); lblTitle.getStyleClass().add("title"); - this.firstLine = new HBox(lblTitle); + // Custom layout for the title row: when there isn't enough room, the tags shrink (and clip) + // first; only once they are gone does the title itself start to ellipsize. The preferred + // width stays the natural title+tags width (no width bindings), so this works in any parent + // without circular layout dependencies. + this.firstLine = new HBox(lblTitle) { + @Override + protected void layoutChildren() { + var managed = getManagedChildren(); + if (managed.size() != 2) { + super.layoutChildren(); + return; + } + + Insets insets = getInsets(); + double x = insets.getLeft(); + double y = insets.getTop(); + double availableWidth = Math.max(0, getWidth() - x - insets.getRight()); + double availableHeight = Math.max(0, getHeight() - y - insets.getBottom()); + + Node title = managed.get(0); + Node tagsNode = managed.get(1); + double spacing = getSpacing(); + + double titleWidth = Math.min(title.prefWidth(availableHeight), availableWidth); + double tagsWidth = Math.min(tagsNode.prefWidth(availableHeight), + Math.max(0, availableWidth - titleWidth - spacing)); + + layoutInArea(title, x, y, titleWidth, availableHeight, 0, HPos.LEFT, VPos.CENTER); + layoutInArea(tagsNode, x + titleWidth + spacing, y, tagsWidth, availableHeight, 0, HPos.LEFT, VPos.CENTER); + } + }; firstLine.getStyleClass().add("first-line"); firstLine.setAlignment(Pos.CENTER_LEFT); + // Safety net: never let the row's content render past its own right edge. + FXUtils.setOverflowHidden(firstLine); this.getChildren().setAll(firstLine); } @@ -168,15 +203,13 @@ public ObservableList