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..7ede7f4c198 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,8 @@ import com.jfoenix.controls.*; import javafx.animation.PauseTransition; import javafx.application.Platform; +import javafx.beans.InvalidationListener; +import javafx.beans.WeakInvalidationListener; import javafx.beans.binding.Bindings; import javafx.beans.property.BooleanProperty; import javafx.beans.property.ObjectProperty; @@ -90,6 +92,18 @@ final class ModListPageSkin extends SkinBase { private final JFXListView listView; private final JFXTextField searchField; + /** + * Creates the file subtitle shown in the mod list. + */ + static String createModSubtitle(LocalModFile modInfo) { + StringJoiner joiner = new StringJoiner(" | "); + if (modInfo.getModLoaderType() != ModLoaderType.UNKNOWN && StringUtils.isNotBlank(modInfo.getId())) + joiner.add(modInfo.getId()); + + joiner.add(FileUtils.getName(modInfo.getFile())); + return joiner.toString(); + } + @FXThread private boolean isSearching = false; @@ -568,6 +582,13 @@ final class ModInfoListCell extends MDListCell { JFXButton infoButton = FXUtils.newToggleButton4(SVG.INFO); JFXButton revealButton = FXUtils.newToggleButton4(SVG.FOLDER); BooleanProperty booleanProperty; + final InvalidationListener activeListener = observable -> { + ModInfoObject item = getItem(); + if (item != null) { + content.setSubtitle(createModSubtitle(item.getModInfo())); + } + }; + final WeakInvalidationListener weakActiveListener = new WeakInvalidationListener(activeListener); Tooltip warningTooltip; @@ -601,6 +622,12 @@ protected void updateControl(ModInfoObject dataItem, boolean empty) { warningTooltip = null; } + if (booleanProperty != null) { + booleanProperty.removeListener(weakActiveListener); + checkBox.selectedProperty().unbindBidirectional(booleanProperty); + booleanProperty = null; + } + if (empty) return; List warning = new ArrayList<>(); @@ -636,13 +663,7 @@ protected void updateControl(ModInfoObject dataItem, boolean empty) { } content.setTitle(displayName); - StringJoiner joiner = new StringJoiner(" | "); - if (modLoaderType != ModLoaderType.UNKNOWN && StringUtils.isNotBlank(modInfo.getId())) - joiner.add(modInfo.getId()); - - joiner.add(FileUtils.getName(modInfo.getFile())); - - content.setSubtitle(joiner.toString()); + content.setSubtitle(createModSubtitle(modInfo)); if (modLoaderType == ModLoaderType.UNKNOWN) { content.addTagWarning(i18n("mods.unknown")); @@ -664,10 +685,9 @@ protected void updateControl(ModInfoObject dataItem, boolean empty) { content.addTag(modVersion); } - if (booleanProperty != null) { - checkBox.selectedProperty().unbindBidirectional(booleanProperty); - } checkBox.selectedProperty().bindBidirectional(booleanProperty = dataItem.active); + // Re-read the current path after active toggling; failed renames leave it unchanged. + dataItem.active.addListener(weakActiveListener); restoreButton.setVisible(!modInfo.getMod().getOldFiles().isEmpty()); restoreButton.setOnAction(e -> { menu.get().getContent().setAll(modInfo.getMod().getOldFiles().stream() diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/ui/versions/ModListPageTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/ui/versions/ModListPageTest.java new file mode 100644 index 00000000000..e6411dfba0e --- /dev/null +++ b/HMCL/src/test/java/org/jackhuang/hmcl/ui/versions/ModListPageTest.java @@ -0,0 +1,100 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui.versions; + +import org.jackhuang.hmcl.addon.LocalAddonFile; +import org.jackhuang.hmcl.addon.mod.LocalModFile; +import org.jackhuang.hmcl.addon.mod.ModLoaderType; +import org.jackhuang.hmcl.addon.mod.ModManager; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Tests for instance mods list file name change. + * + * @author dev@howxu.cn + */ +final class ModListPageTest { + + @TempDir + Path tempDir; + + @Test + void modSubtitleUsesUpdatedFilePathAfterDisabling() throws IOException { + LocalModFile modFile = createModFile("example.jar", "testmod", ModLoaderType.FABRIC); + + modFile.setActive(false); + + assertEquals("testmod | example.jar.disabled", ModListPageSkin.createModSubtitle(modFile)); + } + + @Test + void modSubtitleUsesUpdatedFilePathAfterEnabling() throws IOException { + LocalModFile modFile = createModFile("example.jar.disabled", "testmod", ModLoaderType.FABRIC); + + modFile.setActive(true); + + assertEquals("testmod | example.jar", ModListPageSkin.createModSubtitle(modFile)); + } + + @Test + void modSubtitleShowsCurrentEnabledFilePath() throws IOException { + LocalModFile modFile = createModFile("example.jar", "testmod", ModLoaderType.FABRIC); + + assertEquals("testmod | example.jar", ModListPageSkin.createModSubtitle(modFile)); + } + + @Test + void modSubtitleOmitsUnknownLoaderId() throws IOException { + LocalModFile modFile = createModFile("unknown.jar", "unknown", ModLoaderType.UNKNOWN); + + assertEquals("unknown.jar", ModListPageSkin.createModSubtitle(modFile)); + } + + @Test + void modSubtitleDoesNotChangeWhenFileRenameFails() throws IOException { + LocalModFile modFile = createModFile("example.jar", "testmod", ModLoaderType.FABRIC); + Path disabledTarget = tempDir.resolve("example.jar.disabled"); + Files.createDirectory(disabledTarget); + Files.writeString(disabledTarget.resolve("child"), "occupied"); + + modFile.setActive(false); + + assertEquals("testmod | example.jar", ModListPageSkin.createModSubtitle(modFile)); + } + + private LocalModFile createModFile(String fileName, String modId, ModLoaderType modLoaderType) throws IOException { + Path file = tempDir.resolve(fileName); + Files.writeString(file, "mod"); + + ModManager modManager = new ModManager(null, "test"); + return new LocalModFile( + modManager, + modManager.getLocalMod(modId, modLoaderType), + file, + "Test Mod", + new LocalAddonFile.Description("Test mod") + ); + } +}