From 6899dc3a531ebdce59f86f35578aad554271d031 Mon Sep 17 00:00:00 2001 From: Coconut Date: Tue, 20 Oct 2026 17:06:28 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E9=92=89=E4=BD=8FMOD=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=EF=BC=8C=E5=8F=AF=E4=BB=A5=E8=AE=A9=E8=AE=A9=E7=89=B9=E5=AE=9A?= =?UTF-8?q?MOD=E4=B8=8D=E5=8F=82=E4=B8=8E=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/org/jackhuang/hmcl/ui/SVG.java | 1 + .../hmcl/ui/instances/ModListPage.java | 167 +++++++++++++++++- .../resources/assets/lang/I18N.properties | 4 + .../resources/assets/lang/I18N_ar.properties | 4 + .../resources/assets/lang/I18N_de.properties | 4 + .../resources/assets/lang/I18N_es.properties | 4 + .../resources/assets/lang/I18N_ja.properties | 4 + .../resources/assets/lang/I18N_lzh.properties | 4 + .../resources/assets/lang/I18N_ru.properties | 4 + .../resources/assets/lang/I18N_uk.properties | 4 + .../assets/lang/I18N_zh_Hans.properties | 4 + .../assets/lang/I18N_zh_Hant.properties | 4 + 12 files changed, 201 insertions(+), 7 deletions(-) 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 23fd12c0c3c..8a185725cf8 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java @@ -83,6 +83,7 @@ public enum SVG { INFO("M11 17H13V11H11V17ZM12 9Q12.425 9 12.7125 8.7125T13 8Q13 7.575 12.7125 7.2875T12 7Q11.575 7 11.2875 7.2875T11 8Q11 8.425 11.2875 8.7125T12 9ZM12 22Q9.925 22 8.1 21.2125T4.925 19.075Q3.575 17.725 2.7875 15.9T2 12Q2 9.925 2.7875 8.1T4.925 4.925Q6.275 3.575 8.1 2.7875T12 2Q14.075 2 15.9 2.7875T19.075 4.925Q20.425 6.275 21.2125 8.1T22 12Q22 14.075 21.2125 15.9T19.075 19.075Q17.725 20.425 15.9 21.2125T12 22ZM12 20Q15.35 20 17.675 17.675T20 12Q20 8.65 17.675 6.325T12 4Q8.65 4 6.325 6.325T4 12Q4 15.35 6.325 17.675T12 20ZM12 12Z"), INFO_FILL("M12 22q2.075-0 3.9-.7875t3.175-2.1375q1.35-1.35 2.1375-3.175T22 12q-0-2.075-.7875-3.9T19.075 4.925q-1.35-1.35-3.175-2.1375T12 2q-2.075 0-3.9.7875T4.925 4.925Q3.575 6.275 2.7875 8.1T2 12q0 2.075.7875 3.9T4.925 19.075q1.35 1.35 3.175 2.1375T12 22ZM12 9q-.425 0-.7125-.2875T11 8t.2875-.7125T12 7q.425-0 .7125.2875T13 8t-.2875.7125T12 9Zm-1 8V11h2v6H11Z"), KEEP("m16 12l2 2v2h-5v6l-1 1l-1-1v-6H6v-2l2-2V5H7V3h10v2h-1zm-7.15 2h6.3L14 12.85V5h-4v7.85zM12 14"), + KEEP_FILL("m16 12l2 2v2h-5v6l-1 1l-1-1v-6H6v-2l2-2V5H7V3h10v2h-1z"), KEYBOARD_ARROW_DOWN("M12 15.4 6 9.4 7.4 8 12 12.6 16.6 8 18 9.4 12 15.4Z"), KEYBOARD_ARROW_UP("M12 10.8 7.4 15.4 6 14 12 8 18 14 16.6 15.4 12 10.8Z"), LANDSCAPE("M1 18l6-8 4.5 6H19L14 9.35l-2.5 3.3L10.25 11 14 6l9 12H1Zm13.025-2ZM5 16H9L7 13.325 5 16ZH9 5Z"), diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java index 75e02adb294..ae739ca24c6 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java @@ -45,6 +45,7 @@ import javafx.util.Duration; import org.jackhuang.hmcl.addon.AddonLoader; import org.jackhuang.hmcl.addon.AddonProjectInfoRegistry; +import org.jackhuang.hmcl.addon.LocalAddonManager; import org.jackhuang.hmcl.addon.mod.ModProjectInfo; import org.jackhuang.hmcl.addon.RemoteAddon; import org.jackhuang.hmcl.addon.RemoteAddonRepository; @@ -73,10 +74,12 @@ import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Unmodifiable; import java.io.IOException; import java.io.UncheckedIOException; import java.lang.ref.WeakReference; +import java.nio.charset.StandardCharsets; import java.nio.file.FileSystem; import java.nio.file.Files; import java.nio.file.Path; @@ -99,6 +102,17 @@ public final class ModListPage extends ListPageBase i private final ReentrantLock lock = new ReentrantLock(); private final WeakListenerHolder listenerHolder = new WeakListenerHolder(); + /// Name of the file in the instance mods directory holding pinned mod file names, one per line. + /// + /// The file has no mod extension, so [ModManager#refresh()] ignores it. + private static final String PINNED_FILE_NAME = ".hmcl-pinned"; + + /// File names of the mods pinned to their current version. + /// + /// A file name includes the extension but not the `.disabled` or `.old` suffix, so pinning survives + /// enabling and disabling the mod. The set is replaced, never mutated, to stay safe across threads. + private volatile Set pinnedFiles = Set.of(); + private ModManager modManager; private @Nullable HMCLGameInstance gameInstance; @@ -156,7 +170,11 @@ private void loadMods(ModManager modManager) { lock.lock(); try { modManager.refresh(); - return modManager.getLocalFiles().stream().map(ModInfoObject::new).toList(); + Set pins = readPinnedFiles(modManager.getDirectory()); + pinnedFiles = pins; + return modManager.getLocalFiles().stream() + .map(file -> new ModInfoObject(file, pins.contains(getPinnedFileName(file.getFile())))) + .toList(); } catch (IOException e) { throw new UncheckedIOException(e); } finally { @@ -257,18 +275,103 @@ public void openModFolder() { } } - public void checkUpdates(Collection mods) { + /// Returns the pin key of a mod file: its name with the extension but without state suffixes. + /// + /// @param file the mod file + /// @return the name used to match the mod against [#pinnedFiles] + private static String getPinnedFileName(Path file) { + return LocalAddonManager.getLocalAddonName(file); + } + + /// Reads the pinned mod names of an instance. + /// + /// @param modsDirectory the instance mods directory + /// @return the pinned file names, empty when the file is missing or unreadable + private static @Unmodifiable Set readPinnedFiles(Path modsDirectory) { + Path file = modsDirectory.resolve(PINNED_FILE_NAME); + if (!Files.isRegularFile(file)) { + return Set.of(); + } + + try { + Set pins = new HashSet<>(); + for (String line : Files.readAllLines(file, StandardCharsets.UTF_8)) { + String name = line.trim(); + if (!name.isEmpty()) { + pins.add(name); + } + } + return Set.copyOf(pins); + } catch (IOException e) { + LOG.warning("Unable to read pinned mods from " + file, e); + return Set.of(); + } + } + + /// Writes [#pinnedFiles] to the instance mods directory. + private void savePinnedFiles() { + ModManager manager = modManager; + if (manager == null) { + return; + } + + Path file = manager.getDirectory().resolve(PINNED_FILE_NAME); + List lines = pinnedFiles.stream().sorted().toList(); + Task.runAsync(() -> Files.write(file, lines, StandardCharsets.UTF_8)) + .whenComplete(Schedulers.javafx(), exception -> { + if (exception != null) { + LOG.warning("Unable to save pinned mods to " + file, exception); + Controllers.showToast(i18n("message.failed")); + } + }) + .start(); + } + + /// Pins or unpins a mod, so that the update check skips it unless it is explicitly selected. + /// + /// @param item the mod to change + /// @param pinned whether the mod should stay at its current version + void setPinned(ModInfoObject item, boolean pinned) { + var files = new HashSet<>(pinnedFiles); + if (pinned ? files.add(item.pinnedFileName) : files.remove(item.pinnedFileName)) { + pinnedFiles = Set.copyOf(files); + item.setPinned(pinned); + savePinnedFiles(); + } + } + + /// Returns whether the mod is pinned to its current version. + /// + /// @param file the mod file + /// @return true if the update check should skip this mod by default + boolean isPinned(LocalModFile file) { + return pinnedFiles.contains(getPinnedFileName(file.getFile())); + } + + /// Checks for updates of the given mods. + /// + /// @param mods the mods to check + /// @param explicit whether the caller explicitly selected `mods`, in which case pinned mods are checked too + public void checkUpdates(Collection mods, boolean explicit) { Objects.requireNonNull(mods); if (isLoading() || gameInstance == null) { return; } + List targets = mods.stream() + .filter(mod -> explicit || !isPinned(mod)) + .toList(); + if (targets.isEmpty()) { + Controllers.dialog(i18n("mods.pin.nothing")); + return; + } + HMCLGameInstance gameInstance = this.gameInstance; Runnable action = () -> Controllers.taskDialog(Task .composeAsync(() -> { GameVersionNumber version = gameInstance.getVersion(); if (version != GameVersionNumber.unknown()) { - return new AddonCheckUpdatesTask(DownloadProviders.getDownloadProvider(), version.toString(), mods); + return new AddonCheckUpdatesTask(DownloadProviders.getDownloadProvider(), version.toString(), targets); } else { LOG.warning("Failed to check for updates, due to unable to get instance game version"); return null; @@ -403,7 +506,8 @@ public ModListPageSkin(ModListPage skinnable) { skinnable.checkUpdates( listView.getItems().stream() .map(ModListPage.ModInfoObject::getModInfo) - .toList() + .toList(), + false ) ), createToolbarButton2(i18n("mods.download"), SVG.DOWNLOAD, skinnable::download), @@ -437,7 +541,8 @@ public ModListPageSkin(ModListPage skinnable) { skinnable.checkUpdates( listView.getSelectionModel().getSelectedItems().stream() .map(ModListPage.ModInfoObject::getModInfo) - .toList() + .toList(), + true ) ), selectAll, @@ -570,11 +675,19 @@ public static final class ModInfoObject { /// Matching project metadata, or null if the local mod cannot be identified. private final @Nullable ModProjectInfo projectInfo; + /// The name used to record this mod in [#pinnedFiles]. + private final String pinnedFileName; + + /// Whether this mod stays at its current version during update checks. + private boolean pinned; + private final ItemPropertyAsyncCache iconCache; - ModInfoObject(LocalModFile localModFile) { + ModInfoObject(LocalModFile localModFile, boolean pinned) { this.localModFile = localModFile; this.projectInfo = AddonProjectInfoRegistry.MOD.getInfo(localModFile.getId(), localModFile.getName()); + this.pinnedFileName = getPinnedFileName(localModFile.getFile()); + this.pinned = pinned; this.iconCache = new ItemPropertyAsyncCache.Soft<>(this, this::loadIcon, this::getDefaultIcon); } @@ -583,6 +696,20 @@ public LocalModFile getModInfo() { return localModFile; } + /// Returns whether the mod stays at its current version when checking for updates. + /// + /// @return true if the mod is pinned + public boolean isPinned() { + return pinned; + } + + /// Records the pin state, which [#setPinned(ModInfoObject, boolean)] keeps in sync with the file. + /// + /// @param pinned the new pin state + private void setPinned(boolean pinned) { + this.pinned = pinned; + } + /// Returns matching project metadata, or null if no project was found. public @Nullable ModProjectInfo getProjectInfo() { return projectInfo; @@ -744,6 +871,9 @@ private static final class ModInfoListCell extends MDListCell { private final JFXCheckBox checkBox = new JFXCheckBox(); private final ImageContainer imageContainer = new ImageContainer(32); private final TwoLineListItem content = new TwoLineListItem(); + private final JFXButton pinButton = FXUtils.newToggleButton4(SVG.KEEP); + /// Shared tooltip of [#pinButton], whose text follows the pin state of the displayed mod. + private final Tooltip pinTooltip = new Tooltip(i18n("mods.pin")); private final JFXButton restoreButton = FXUtils.newToggleButton4(SVG.RESTORE); private final JFXButton infoButton = FXUtils.newToggleButton4(SVG.INFO); private final JFXButton revealButton = FXUtils.newToggleButton4(SVG.FOLDER); @@ -769,9 +899,10 @@ private static final class ModInfoListCell extends MDListCell { imageContainer.setImage(GameInstanceIconType.COMMAND.getIcon()); + FXUtils.installFastTooltip(pinButton, pinTooltip); FXUtils.installFastTooltip(restoreButton, i18n("mods.restore")); - container.getChildren().setAll(checkBox, imageContainer, content, restoreButton, revealButton, infoButton); + container.getChildren().setAll(checkBox, imageContainer, content, pinButton, restoreButton, revealButton, infoButton); StackPane.setMargin(container, new Insets(8)); getContainer().getChildren().setAll(container); @@ -869,6 +1000,13 @@ protected void updateControl(ModInfoObject dataItem, boolean empty) { revealButton.setOnAction(e -> FXUtils.showFileInExplorer(modInfo.getFile())); infoButton.setOnAction(e -> Controllers.dialog(new ModInfoDialog(dataItem))); + pinButton.setDisable(modInfo.isOld()); + applyPinState(dataItem); + pinButton.setOnAction(e -> { + page.setPinned(dataItem, !dataItem.isPinned()); + applyPinState(dataItem); + }); + if (!warning.isEmpty()) { pseudoClassStateChanged(WARNING, true); @@ -879,5 +1017,20 @@ protected void updateControl(ModInfoObject dataItem, boolean empty) { FXUtils.installFastTooltip(this, warningTooltip); } } + + /// Rebuilds the pin icon, tag and tooltip of the displayed mod. + /// + /// @param item the mod shown by this cell + private void applyPinState(ModInfoObject item) { + String text = i18n("mods.pinned"); + content.getTags().removeIf(label -> text.equals(label.getText())); + if (item.isPinned()) { + content.addTag(text); + } + // A pinned mod shows the filled glyph, so the state is visible without reading the tag. + pinButton.setGraphic((item.isPinned() ? SVG.KEEP_FILL : SVG.KEEP).createIcon()); + pinTooltip.setText(i18n(item.isPinned() ? "mods.unpin" : "mods.pin")); + } + } } diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 0655922e9fe..489e1d7ce6e 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1186,6 +1186,10 @@ mods.mcmod=MCMod mods.mcmod.page=MCMod Page mods.mcmod.search=Search in MCMod mods.name=Name +mods.pin=Pin to current version +mods.pin.nothing=All selected mods are pinned, so there is nothing to update. +mods.pinned=Pinned +mods.unpin=Unpin (allow update) mods.restore=Restore mods.toggle.failed=Failed to enable or disable mod(s). The mod file(s) may be in use by another program. mods.url=Official Page diff --git a/HMCL/src/main/resources/assets/lang/I18N_ar.properties b/HMCL/src/main/resources/assets/lang/I18N_ar.properties index ff0bf399b8a..d1ef35da953 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ar.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ar.properties @@ -1186,6 +1186,10 @@ mods.mcmod=MCMod mods.mcmod.page=صفحة MCMod mods.mcmod.search=البحث في MCMod mods.name=الاسم +mods.pin=تثبيت على الإصدار الحالي +mods.pin.nothing=جميع المودات المحددة مثبتة بالفعل، لا يوجد ما يمكن تحديثه. +mods.pinned=مثبَّت +mods.unpin=إلغاء التثبيت (السماح بالتحديث) mods.restore=استعادة # mods.toggle.failed= mods.url=الصفحة الرسمية diff --git a/HMCL/src/main/resources/assets/lang/I18N_de.properties b/HMCL/src/main/resources/assets/lang/I18N_de.properties index f6edfbf5066..e90a058ebe7 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_de.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_de.properties @@ -1185,6 +1185,10 @@ mods.mcmod=MCMod mods.mcmod.page=MCMod-Seite mods.mcmod.search=In MCMod suchen mods.name=Name +mods.pin=Auf aktuelle Version festlegen +mods.pin.nothing=Alle ausgewählten Mods sind bereits festgelegt, es gibt nichts zu aktualisieren. +mods.pinned=Festgelegt +mods.unpin=Festlegung aufheben (Updates erlauben) mods.restore=Wiederherstellen # mods.toggle.failed= mods.url=Offizielle Seite diff --git a/HMCL/src/main/resources/assets/lang/I18N_es.properties b/HMCL/src/main/resources/assets/lang/I18N_es.properties index 61be9e28bb0..e55ab811934 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_es.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_es.properties @@ -1178,6 +1178,10 @@ mods.mcmod=MCMod mods.mcmod.page=Página de MCMod mods.mcmod.search=Búsqueda en MCMod mods.name=Nombre +mods.pin=Fijar a la versión actual +mods.pin.nothing=Todos los mods seleccionados ya están fijados, no hay nada que actualizar. +mods.pinned=Fijado +mods.unpin=Desfijar (permitir la actualización) mods.restore=Restaurar # mods.toggle.failed= mods.url=Página oficial diff --git a/HMCL/src/main/resources/assets/lang/I18N_ja.properties b/HMCL/src/main/resources/assets/lang/I18N_ja.properties index 924514f450a..d919e5dfa95 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ja.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ja.properties @@ -986,6 +986,10 @@ mods.mcmod=MCMOD mods.mcmod.page=MCMOD mods.mcmod.search=MCMODで検索 mods.name=Name +mods.pin=現在のバージョンに固定 +mods.pin.nothing=選択した Mod はすべて固定済みのため、更新する項目はありません。 +mods.pinned=固定中 +mods.unpin=固定を解除(更新を許可) mods.restore=Restore # mods.toggle.failed= mods.url=公式ページ diff --git a/HMCL/src/main/resources/assets/lang/I18N_lzh.properties b/HMCL/src/main/resources/assets/lang/I18N_lzh.properties index 19cd22952bb..e076f01c73c 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_lzh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_lzh.properties @@ -998,6 +998,10 @@ mods.mcmod=礦藝改囊典 mods.mcmod.page=礦藝改囊典頁 mods.mcmod.search=礦藝改囊典查 mods.name=名 +mods.pin=錮之(不與更迭) +mods.pin.nothing=所擇之囊皆已錮,無可更者。 +mods.pinned=已錮 +mods.unpin=解錮(復其更迭) mods.restore=復 # mods.toggle.failed= mods.url=改囊官網 diff --git a/HMCL/src/main/resources/assets/lang/I18N_ru.properties b/HMCL/src/main/resources/assets/lang/I18N_ru.properties index 17c873a1bf2..0a0123f0edb 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ru.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ru.properties @@ -1118,6 +1118,10 @@ mods.mcmod=MCMod mods.mcmod.page=Страница MCMod mods.mcmod.search=Искать на MCMod mods.name=Название +mods.pin=Закрепить текущую версию +mods.pin.nothing=Все выбранные моды уже закреплены, обновлять нечего. +mods.pinned=Закреплён +mods.unpin=Открепить (разрешить обновление) mods.restore=Восстановить # mods.toggle.failed= mods.url=Официальная страница diff --git a/HMCL/src/main/resources/assets/lang/I18N_uk.properties b/HMCL/src/main/resources/assets/lang/I18N_uk.properties index 42c9e90f763..07514fdba0f 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_uk.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_uk.properties @@ -1141,6 +1141,10 @@ mods.mcmod=MCMod mods.mcmod.page=Сторінка MCMod mods.mcmod.search=Пошук в MCMod mods.name=Назва +mods.pin=Закріпити поточну версію +mods.pin.nothing=Усі вибрані моди вже закріплено, оновлювати нічого. +mods.pinned=Закріплено +mods.unpin=Скасувати закріплення (дозволити оновлення) mods.restore=Відновити # mods.toggle.failed= mods.url=Офіційна сторінка diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_Hans.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_Hans.properties index 33b86f97694..1ac8f375714 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_Hans.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_Hans.properties @@ -1002,6 +1002,10 @@ mods.mcmod=MC 百科 mods.mcmod.page=MC 百科页面 mods.mcmod.search=MC 百科搜索 mods.name=名称 +mods.pin=钉住(不参与更新) +mods.pin.nothing=所选模组都已钉住,没有需要更新的模组 +mods.pinned=已钉住 +mods.unpin=取消钉住(恢复更新) mods.restore=回退 mods.toggle.failed=无法启用或禁用模组,模组文件可能已被其他程序占用 mods.url=官方页面 diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_Hant.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_Hant.properties index e9163abcdca..8f787632880 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_Hant.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_Hant.properties @@ -996,6 +996,10 @@ mods.mcmod=MC 百科 mods.mcmod.page=MC 百科頁面 mods.mcmod.search=MC 百科檢索 mods.name=名稱 +mods.pin=釘住(不參與更新) +mods.pin.nothing=所選模組都已釘住,沒有需要更新的模組 +mods.pinned=已釘住 +mods.unpin=取消釘住(恢復更新) mods.restore=回退 mods.toggle.failed=無法啟用或停用模組,模組檔案可能已被其他程式佔用 mods.url=官方頁面 From 04b33d0751e92799a760bda5afc3bbb3d8e65cfb Mon Sep 17 00:00:00 2001 From: Coconut Date: Wed, 21 Oct 2026 11:25:22 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20=E9=92=89=E4=BD=8FMOD=E7=9A=84?= =?UTF-8?q?=E6=8C=89=E9=92=AE=E4=BD=8D=E7=BD=AE=E8=B0=83=E6=95=B4=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=8D=95=E7=8B=AC/=E5=A4=9A=E4=B8=AAmod?= =?UTF-8?q?=E6=A3=80=E6=B5=8B=E6=9B=B4=E6=96=B0=E9=92=89=E4=BD=8Fmod?= =?UTF-8?q?=E4=B8=8D=E7=94=9F=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/instances/ModListPage.java | 115 +++++++++++++++--- 1 file changed, 95 insertions(+), 20 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java index ae739ca24c6..e3d121ddf92 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java @@ -113,6 +113,9 @@ public final class ModListPage extends ListPageBase i /// enabling and disabling the mod. The set is replaced, never mutated, to stay safe across threads. private volatile Set pinnedFiles = Set.of(); + /// Notified after the pin state of any mod changed, so the skin refreshes its toolbar button. + private @Nullable Runnable pinnedStateChanged; + private ModManager modManager; private @Nullable HMCLGameInstance gameInstance; @@ -337,6 +340,36 @@ void setPinned(ModInfoObject item, boolean pinned) { pinnedFiles = Set.copyOf(files); item.setPinned(pinned); savePinnedFiles(); + firePinnedStateChanged(); + } + } + + /// Pins or unpins several mods at once, writing [#pinnedFiles] only once. + /// + /// @param selectedItems the mods to change + /// @param pinned whether the mods should stay at their current versions + void setSelectedPinned(ObservableList selectedItems, boolean pinned) { + var files = new HashSet<>(pinnedFiles); + boolean changed = false; + for (ModInfoObject item : selectedItems) { + if (pinned ? files.add(item.pinnedFileName) : files.remove(item.pinnedFileName)) { + item.setPinned(pinned); + changed = true; + } + } + + if (changed) { + pinnedFiles = Set.copyOf(files); + savePinnedFiles(); + firePinnedStateChanged(); + } + } + + /// Notifies [#pinnedStateChanged] that the pin state of some mod changed. + private void firePinnedStateChanged() { + Runnable listener = pinnedStateChanged; + if (listener != null) { + listener.run(); } } @@ -348,18 +381,17 @@ boolean isPinned(LocalModFile file) { return pinnedFiles.contains(getPinnedFileName(file.getFile())); } - /// Checks for updates of the given mods. + /// Checks for updates of the given mods, skipping the ones pinned to their current version. /// - /// @param mods the mods to check - /// @param explicit whether the caller explicitly selected `mods`, in which case pinned mods are checked too - public void checkUpdates(Collection mods, boolean explicit) { + /// @param mods the mods to check + public void checkUpdates(Collection mods) { Objects.requireNonNull(mods); if (isLoading() || gameInstance == null) { return; } List targets = mods.stream() - .filter(mod -> explicit || !isPinned(mod)) + .filter(mod -> !isPinned(mod)) .toList(); if (targets.isEmpty()) { Controllers.dialog(i18n("mods.pin.nothing")); @@ -438,6 +470,9 @@ private static final class ModListPageSkin extends SkinBase { private final HBox toolbarNormal; private final HBox toolbarSelecting; + /// Toolbar button of [#toolbarSelecting] that pins or unpins the selected mods. + private final JFXButton pinSelected; + private final JFXListView listView; /// Whether the search mechanism is currently active. @@ -506,8 +541,7 @@ public ModListPageSkin(ModListPage skinnable) { skinnable.checkUpdates( listView.getItems().stream() .map(ModListPage.ModInfoObject::getModInfo) - .toList(), - false + .toList() ) ), createToolbarButton2(i18n("mods.download"), SVG.DOWNLOAD, skinnable::download), @@ -519,9 +553,17 @@ public ModListPageSkin(ModListPage skinnable) { // reason for not using selectAll() is that selectAll() first clears all selected then selects all, causing the toolbar to flicker var selectAll = createToolbarButton2(i18n("button.select_all"), SVG.SELECT_ALL, () -> listView.getSelectionModel().selectRange(0, listView.getItems().size())); + // Pins or unpins every selected mod at once, the batch equivalent of the pin icon of a row. + pinSelected = createToolbarButton2(i18n("mods.pin"), SVG.KEEP, () -> { + skinnable.setSelectedPinned(listView.getSelectionModel().getSelectedItems(), !allPinned()); + updatePinSelectedButton(); + }); + skinnable.pinnedStateChanged = this::updatePinSelectedButton; + ListChangeListener listener = change -> { selectAll.setDisable(!listView.getItems().isEmpty() && listView.getSelectionModel().getSelectedItems().size() == listView.getItems().size()); + updatePinSelectedButton(); }; listView.getSelectionModel().getSelectedItems().addListener(listener); @@ -537,12 +579,12 @@ public ModListPageSkin(ModListPage skinnable) { skinnable.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), true)), createToolbarButton2(i18n("mods.disable"), SVG.CLOSE, () -> skinnable.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), false)), + pinSelected, createToolbarButton2(i18n("addon.check_update.button"), SVG.UPDATE, () -> skinnable.checkUpdates( listView.getSelectionModel().getSelectedItems().stream() .map(ModListPage.ModInfoObject::getModInfo) - .toList(), - true + .toList() ) ), selectAll, @@ -633,6 +675,21 @@ private void changeToolbar(HBox newToolbar) { } } + /// Returns whether every selected mod is already pinned, so [#pinSelected] unpins instead of pins. + /// + /// @return true if the selection is not empty and all of its mods are pinned + private boolean allPinned() { + ObservableList selected = listView.getSelectionModel().getSelectedItems(); + return !selected.isEmpty() && selected.stream().allMatch(ModListPage.ModInfoObject::isPinned); + } + + /// Makes the text and glyph of [#pinSelected] describe what the next click will do. + private void updatePinSelectedButton() { + boolean allPinned = allPinned(); + pinSelected.setText(i18n(allPinned ? "mods.unpin" : "mods.pin")); + pinSelected.setGraphic((allPinned ? SVG.KEEP_FILL : SVG.KEEP).createIcon(20)); + } + private void search() { isSearching.set(true); @@ -679,7 +736,7 @@ public static final class ModInfoObject { private final String pinnedFileName; /// Whether this mod stays at its current version during update checks. - private boolean pinned; + private final SimpleBooleanProperty pinned; private final ItemPropertyAsyncCache iconCache; @@ -687,7 +744,7 @@ public static final class ModInfoObject { this.localModFile = localModFile; this.projectInfo = AddonProjectInfoRegistry.MOD.getInfo(localModFile.getId(), localModFile.getName()); this.pinnedFileName = getPinnedFileName(localModFile.getFile()); - this.pinned = pinned; + this.pinned = new SimpleBooleanProperty(this, "pinned", pinned); this.iconCache = new ItemPropertyAsyncCache.Soft<>(this, this::loadIcon, this::getDefaultIcon); } @@ -700,6 +757,13 @@ public LocalModFile getModInfo() { /// /// @return true if the mod is pinned public boolean isPinned() { + return pinned.get(); + } + + /// Returns the observable pin state, so list cells follow the changes made elsewhere. + /// + /// @return the read-only pin state + public ReadOnlyBooleanProperty pinnedProperty() { return pinned; } @@ -707,7 +771,7 @@ public boolean isPinned() { /// /// @param pinned the new pin state private void setPinned(boolean pinned) { - this.pinned = pinned; + this.pinned.set(pinned); } /// Returns matching project metadata, or null if no project was found. @@ -882,6 +946,10 @@ private static final class ModInfoListCell extends MDListCell { private final WeakInvalidationListener weakActiveListener = new WeakInvalidationListener(activeListener); private @Nullable ReadOnlyBooleanProperty observedActiveProperty; + private final InvalidationListener pinnedListener = obs -> applyPinState(getItem()); + private final WeakInvalidationListener weakPinnedListener = new WeakInvalidationListener(pinnedListener); + private @Nullable ReadOnlyBooleanProperty observedPinnedProperty; + private Tooltip warningTooltip; ModInfoListCell(JFXListView listView, ModListPage page) { @@ -902,7 +970,7 @@ private static final class ModInfoListCell extends MDListCell { FXUtils.installFastTooltip(pinButton, pinTooltip); FXUtils.installFastTooltip(restoreButton, i18n("mods.restore")); - container.getChildren().setAll(checkBox, imageContainer, content, pinButton, restoreButton, revealButton, infoButton); + container.getChildren().setAll(checkBox, imageContainer, content, restoreButton, pinButton, revealButton, infoButton); StackPane.setMargin(container, new Insets(8)); getContainer().getChildren().setAll(container); @@ -912,6 +980,8 @@ private static final class ModInfoListCell extends MDListCell { protected void updateControl(ModInfoObject dataItem, boolean empty) { if (observedActiveProperty != null) observedActiveProperty.removeListener(weakActiveListener); + if (observedPinnedProperty != null) + observedPinnedProperty.removeListener(weakPinnedListener); pseudoClassStateChanged(WARNING, false); if (warningTooltip != null) { @@ -986,7 +1056,10 @@ protected void updateControl(ModInfoObject dataItem, boolean empty) { checkBox.setSelected(modInfo.isActive()); } }); - restoreButton.setVisible(!modInfo.getMod().getOldFiles().isEmpty()); + // Managed as well, so the pin icon keeps the same place whether or not the mod has an old file to restore. + boolean hasOldFiles = !modInfo.getMod().getOldFiles().isEmpty(); + restoreButton.setVisible(hasOldFiles); + restoreButton.setManaged(hasOldFiles); restoreButton.setOnAction(e -> { menu.get().getContent().setAll(modInfo.getMod().getOldFiles().stream() .map(localModFile -> new IconedMenuItem(null, localModFile.getVersion(), @@ -1001,11 +1074,9 @@ protected void updateControl(ModInfoObject dataItem, boolean empty) { infoButton.setOnAction(e -> Controllers.dialog(new ModInfoDialog(dataItem))); pinButton.setDisable(modInfo.isOld()); + (observedPinnedProperty = dataItem.pinnedProperty()).addListener(weakPinnedListener); applyPinState(dataItem); - pinButton.setOnAction(e -> { - page.setPinned(dataItem, !dataItem.isPinned()); - applyPinState(dataItem); - }); + pinButton.setOnAction(e -> page.setPinned(dataItem, !dataItem.isPinned())); if (!warning.isEmpty()) { pseudoClassStateChanged(WARNING, true); @@ -1020,8 +1091,12 @@ protected void updateControl(ModInfoObject dataItem, boolean empty) { /// Rebuilds the pin icon, tag and tooltip of the displayed mod. /// - /// @param item the mod shown by this cell - private void applyPinState(ModInfoObject item) { + /// @param item the mod shown by this cell, or null when the cell is empty + private void applyPinState(@Nullable ModInfoObject item) { + if (item == null) { + return; + } + String text = i18n("mods.pinned"); content.getTags().removeIf(label -> text.equals(label.getText())); if (item.isPinned()) { From 26f1da4705a3731ebd2bba29ea551ad89edd2567 Mon Sep 17 00:00:00 2001 From: Coconut Date: Wed, 21 Oct 2026 20:35:15 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20=E6=8A=8A=E4=BB=A3=E7=A0=81=E4=BB=8E?= =?UTF-8?q?UI=E5=B1=82=E7=A7=BB=E5=8A=A8=E5=88=B0Mod=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/instances/ModListPage.java | 122 +++++------------- .../jackhuang/hmcl/addon/mod/ModManager.java | 98 ++++++++++++++ 2 files changed, 128 insertions(+), 92 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java index e3d121ddf92..e4a31d3087c 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java @@ -45,7 +45,6 @@ import javafx.util.Duration; import org.jackhuang.hmcl.addon.AddonLoader; import org.jackhuang.hmcl.addon.AddonProjectInfoRegistry; -import org.jackhuang.hmcl.addon.LocalAddonManager; import org.jackhuang.hmcl.addon.mod.ModProjectInfo; import org.jackhuang.hmcl.addon.RemoteAddon; import org.jackhuang.hmcl.addon.RemoteAddonRepository; @@ -74,12 +73,10 @@ import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; -import org.jetbrains.annotations.Unmodifiable; import java.io.IOException; import java.io.UncheckedIOException; import java.lang.ref.WeakReference; -import java.nio.charset.StandardCharsets; import java.nio.file.FileSystem; import java.nio.file.Files; import java.nio.file.Path; @@ -102,17 +99,6 @@ public final class ModListPage extends ListPageBase i private final ReentrantLock lock = new ReentrantLock(); private final WeakListenerHolder listenerHolder = new WeakListenerHolder(); - /// Name of the file in the instance mods directory holding pinned mod file names, one per line. - /// - /// The file has no mod extension, so [ModManager#refresh()] ignores it. - private static final String PINNED_FILE_NAME = ".hmcl-pinned"; - - /// File names of the mods pinned to their current version. - /// - /// A file name includes the extension but not the `.disabled` or `.old` suffix, so pinning survives - /// enabling and disabling the mod. The set is replaced, never mutated, to stay safe across threads. - private volatile Set pinnedFiles = Set.of(); - /// Notified after the pin state of any mod changed, so the skin refreshes its toolbar button. private @Nullable Runnable pinnedStateChanged; @@ -173,10 +159,8 @@ private void loadMods(ModManager modManager) { lock.lock(); try { modManager.refresh(); - Set pins = readPinnedFiles(modManager.getDirectory()); - pinnedFiles = pins; return modManager.getLocalFiles().stream() - .map(file -> new ModInfoObject(file, pins.contains(getPinnedFileName(file.getFile())))) + .map(file -> new ModInfoObject(file, modManager.isPinned(file.getFile()))) .toList(); } catch (IOException e) { throw new UncheckedIOException(e); @@ -278,93 +262,50 @@ public void openModFolder() { } } - /// Returns the pin key of a mod file: its name with the extension but without state suffixes. + /// Pins or unpins a mod, so that the update check skips it. /// - /// @param file the mod file - /// @return the name used to match the mod against [#pinnedFiles] - private static String getPinnedFileName(Path file) { - return LocalAddonManager.getLocalAddonName(file); + /// @param item the mod to change + /// @param pinned whether the mod should stay at its current version + void setPinned(ModInfoObject item, boolean pinned) { + setPinned(List.of(item), pinned); } - /// Reads the pinned mod names of an instance. + /// Pins or unpins several mods at once, saving them with a single write. /// - /// @param modsDirectory the instance mods directory - /// @return the pinned file names, empty when the file is missing or unreadable - private static @Unmodifiable Set readPinnedFiles(Path modsDirectory) { - Path file = modsDirectory.resolve(PINNED_FILE_NAME); - if (!Files.isRegularFile(file)) { - return Set.of(); - } - - try { - Set pins = new HashSet<>(); - for (String line : Files.readAllLines(file, StandardCharsets.UTF_8)) { - String name = line.trim(); - if (!name.isEmpty()) { - pins.add(name); - } - } - return Set.copyOf(pins); - } catch (IOException e) { - LOG.warning("Unable to read pinned mods from " + file, e); - return Set.of(); - } + /// @param items the mods to change + /// @param pinned whether the mods should stay at their current versions + void setSelectedPinned(ObservableList items, boolean pinned) { + setPinned(items, pinned); } - /// Writes [#pinnedFiles] to the instance mods directory. - private void savePinnedFiles() { + /// Applies a pin change to the list immediately, then saves it in the background. + /// + /// @param items the mods to change + /// @param pinned whether the mods should stay at their current versions + private void setPinned(Collection items, boolean pinned) { ModManager manager = modManager; if (manager == null) { return; } - Path file = manager.getDirectory().resolve(PINNED_FILE_NAME); - List lines = pinnedFiles.stream().sorted().toList(); - Task.runAsync(() -> Files.write(file, lines, StandardCharsets.UTF_8)) + List files = items.stream() + .map(item -> item.getModInfo().getFile()) + .toList(); + for (ModInfoObject item : items) { + item.setPinned(pinned); + } + firePinnedStateChanged(); + + Task.runAsync(() -> manager.setPinned(files, pinned)) .whenComplete(Schedulers.javafx(), exception -> { if (exception != null) { - LOG.warning("Unable to save pinned mods to " + file, exception); + LOG.warning("Unable to save pinned mods", exception); Controllers.showToast(i18n("message.failed")); } }) .start(); } - /// Pins or unpins a mod, so that the update check skips it unless it is explicitly selected. - /// - /// @param item the mod to change - /// @param pinned whether the mod should stay at its current version - void setPinned(ModInfoObject item, boolean pinned) { - var files = new HashSet<>(pinnedFiles); - if (pinned ? files.add(item.pinnedFileName) : files.remove(item.pinnedFileName)) { - pinnedFiles = Set.copyOf(files); - item.setPinned(pinned); - savePinnedFiles(); - firePinnedStateChanged(); - } - } - - /// Pins or unpins several mods at once, writing [#pinnedFiles] only once. - /// - /// @param selectedItems the mods to change - /// @param pinned whether the mods should stay at their current versions - void setSelectedPinned(ObservableList selectedItems, boolean pinned) { - var files = new HashSet<>(pinnedFiles); - boolean changed = false; - for (ModInfoObject item : selectedItems) { - if (pinned ? files.add(item.pinnedFileName) : files.remove(item.pinnedFileName)) { - item.setPinned(pinned); - changed = true; - } - } - - if (changed) { - pinnedFiles = Set.copyOf(files); - savePinnedFiles(); - firePinnedStateChanged(); - } - } - /// Notifies [#pinnedStateChanged] that the pin state of some mod changed. private void firePinnedStateChanged() { Runnable listener = pinnedStateChanged; @@ -373,12 +314,13 @@ private void firePinnedStateChanged() { } } - /// Returns whether the mod is pinned to its current version. + /// Returns whether the mod stays at its current version during update checks. /// /// @param file the mod file - /// @return true if the update check should skip this mod by default + /// @return true if the update check skips the mod boolean isPinned(LocalModFile file) { - return pinnedFiles.contains(getPinnedFileName(file.getFile())); + ModManager manager = modManager; + return manager != null && manager.isPinned(file.getFile()); } /// Checks for updates of the given mods, skipping the ones pinned to their current version. @@ -732,9 +674,6 @@ public static final class ModInfoObject { /// Matching project metadata, or null if the local mod cannot be identified. private final @Nullable ModProjectInfo projectInfo; - /// The name used to record this mod in [#pinnedFiles]. - private final String pinnedFileName; - /// Whether this mod stays at its current version during update checks. private final SimpleBooleanProperty pinned; @@ -743,7 +682,6 @@ public static final class ModInfoObject { ModInfoObject(LocalModFile localModFile, boolean pinned) { this.localModFile = localModFile; this.projectInfo = AddonProjectInfoRegistry.MOD.getInfo(localModFile.getId(), localModFile.getName()); - this.pinnedFileName = getPinnedFileName(localModFile.getFile()); this.pinned = new SimpleBooleanProperty(this, "pinned", pinned); this.iconCache = new ItemPropertyAsyncCache.Soft<>(this, this::loadIcon, this::getDefaultIcon); 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 a1513a6016b..81adf8f3878 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 @@ -30,9 +30,11 @@ import org.jackhuang.hmcl.util.io.CompressingUtils; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.tree.ZipFileTree; +import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Unmodifiable; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.*; import java.util.*; @@ -221,6 +223,9 @@ public void refresh() throws IOException { localFiles.clear(); localMods.clear(); + // Re-read the pin file as well, so edits made outside this manager are picked up. + pinnedFiles = null; + analyze(); boolean supportSubfolders = analyzer.has(GameComponentType.FORGE) @@ -400,6 +405,99 @@ public boolean isDisabled(Path file) { return FileUtils.getName(file).endsWith(DISABLED_EXTENSION); } + /// Name of the file in the mods directory that lists the mods pinned to their current version, one per line. + /// + /// The file has no mod extension, so [#refresh()] skips it. + public static final String PINNED_FILE_NAME = ".hmcl-pinned"; + + /// Pinned mod file names, or null until they are read from [#PINNED_FILE_NAME]. + private @Nullable Set pinnedFiles; + + /// Returns whether the mod at the given path stays at its current version during update checks. + /// + /// @param file the mod file + /// @return true if the mod should not be updated + public boolean isPinned(Path file) { + lock.lock(); + try { + return getPinnedFiles().contains(getLocalAddonName(file)); + } finally { + lock.unlock(); + } + } + + /// Pins or unpins the given mods, writing [#PINNED_FILE_NAME] at most once. + /// + /// A name is stored without the `.disabled` or `.old` suffix, so pinning survives enabling, + /// disabling and rolling back the mod. + /// + /// @param files the mod files to change + /// @param pinned whether they should stay at their current versions + /// @throws IOException if the pin file cannot be written + public void setPinned(Collection files, boolean pinned) throws IOException { + lock.lock(); + try { + var names = new HashSet<>(getPinnedFiles()); + boolean changed = false; + for (Path file : files) { + if (pinned ? names.add(getLocalAddonName(file)) : names.remove(getLocalAddonName(file))) { + changed = true; + } + } + + if (changed) { + pinnedFiles = Set.copyOf(names); + savePinnedFiles(pinnedFiles); + } + } finally { + lock.unlock(); + } + } + + /// Returns the pinned mod names, reading [#PINNED_FILE_NAME] on first access. + /// + /// @return the pinned mod file names, empty when nothing is pinned + private @Unmodifiable Set getPinnedFiles() { + if (pinnedFiles == null) { + pinnedFiles = loadPinnedFiles(); + } + return pinnedFiles; + } + + /// Reads [#PINNED_FILE_NAME] from the mods directory. + /// + /// A missing or unreadable file yields an empty set, so it never hides the mod list. + /// + /// @return the pinned mod file names + private @Unmodifiable Set loadPinnedFiles() { + Path file = getDirectory().resolve(PINNED_FILE_NAME); + if (!Files.isRegularFile(file)) { + return Set.of(); + } + + try { + Set names = new HashSet<>(); + for (String line : Files.readAllLines(file, StandardCharsets.UTF_8)) { + String name = line.trim(); + if (!name.isEmpty()) { + names.add(name); + } + } + return Set.copyOf(names); + } catch (IOException e) { + LOG.warning("Unable to read pinned mods from " + file, e); + return Set.of(); + } + } + + /// Writes the pinned mod names to [#PINNED_FILE_NAME], sorted to keep the file stable. + /// + /// @param names the pinned mod file names + /// @throws IOException if the file cannot be written + private void savePinnedFiles(Collection names) throws IOException { + Files.write(getDirectory().resolve(PINNED_FILE_NAME), names.stream().sorted().toList(), StandardCharsets.UTF_8); + } + public static boolean isFileNameMod(Path file) { String name = getLocalAddonName(file); return MOD_EXTENSIONS.contains(FileUtils.getExtension(name).toLowerCase(Locale.ROOT)); From 6a85a2d198abbcb06a959fbd2e0f1868926df8f5 Mon Sep 17 00:00:00 2001 From: Coconut Date: Wed, 21 Oct 2026 20:40:09 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20mod=E5=88=97=E8=A1=A8=E8=AE=B0?= =?UTF-8?q?=E5=BD=95bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/instances/ModListPage.java | 3 ++- .../jackhuang/hmcl/addon/mod/ModManager.java | 25 ++++++++++++++++--- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java index e4a31d3087c..8d1b27f224c 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java @@ -284,7 +284,8 @@ void setSelectedPinned(ObservableList items, boolean pinned) { /// @param pinned whether the mods should stay at their current versions private void setPinned(Collection items, boolean pinned) { ModManager manager = modManager; - if (manager == null) { + // While the list reloads, the items still belong to the previous mod directory. + if (manager == null || isLoading()) { return; } 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 81adf8f3878..7bf6323052a 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 @@ -429,7 +429,8 @@ public boolean isPinned(Path file) { /// Pins or unpins the given mods, writing [#PINNED_FILE_NAME] at most once. /// /// A name is stored without the `.disabled` or `.old` suffix, so pinning survives enabling, - /// disabling and rolling back the mod. + /// disabling and rolling back the mod. The in-memory pins are updated only after the file was + /// written, so a failed write leaves both the file and the memory unchanged. /// /// @param files the mod files to change /// @param pinned whether they should stay at their current versions @@ -446,8 +447,9 @@ public void setPinned(Collection files, boolean pinned) throws IOException } if (changed) { - pinnedFiles = Set.copyOf(names); - savePinnedFiles(pinnedFiles); + Set newPins = Set.copyOf(names); + savePinnedFiles(newPins); + pinnedFiles = newPins; } } finally { lock.unlock(); @@ -492,10 +494,25 @@ public void setPinned(Collection files, boolean pinned) throws IOException /// Writes the pinned mod names to [#PINNED_FILE_NAME], sorted to keep the file stable. /// + /// The names go to a temporary file that then replaces the pin file, so an interrupted write + /// cannot leave a truncated file and lose every pin. + /// /// @param names the pinned mod file names /// @throws IOException if the file cannot be written private void savePinnedFiles(Collection names) throws IOException { - Files.write(getDirectory().resolve(PINNED_FILE_NAME), names.stream().sorted().toList(), StandardCharsets.UTF_8); + Path file = getDirectory().resolve(PINNED_FILE_NAME); + Path temp = Files.createTempFile(getDirectory(), PINNED_FILE_NAME, ".tmp"); + try { + Files.write(temp, names.stream().sorted().toList(), StandardCharsets.UTF_8); + try { + Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + // Some file systems cannot replace atomically, but the content is identical either way. + Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING); + } + } finally { + Files.deleteIfExists(temp); + } } public static boolean isFileNameMod(Path file) { From c0b8450dbf6efbe12f0bb816880dea73ad4e0ea6 Mon Sep 17 00:00:00 2001 From: Coconut Date: Mon, 26 Oct 2026 22:39:21 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20=E6=8C=89=E7=85=A7=E5=BC=80=E5=90=AF?= =?UTF-8?q?=E7=A6=81=E7=94=A8mod=E7=9A=84=E6=96=B9=E5=BC=8F=E9=87=8D?= =?UTF-8?q?=E5=86=99=E9=83=A8=E5=88=86=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/instances/ModListPage.java | 109 ++++++------------ .../jackhuang/hmcl/addon/mod/ModManager.java | 38 ++---- 2 files changed, 44 insertions(+), 103 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java index 629667b73d7..142e1edd187 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java @@ -84,7 +84,7 @@ public final class ModListPage extends ListPageBase i private final ReentrantLock lock = new ReentrantLock(); private final WeakListenerHolder listenerHolder = new WeakListenerHolder(); - /// Notified after the pin state of any mod changed, so the skin refreshes its toolbar button. + /// Called after a mod was pinned or unpinned. private @Nullable Runnable pinnedStateChanged; private ModManager modManager; @@ -247,71 +247,40 @@ public void openModFolder() { } } - /// Pins or unpins a mod, so that the update check skips it. - /// - /// @param item the mod to change - /// @param pinned whether the mod should stay at its current version - void setPinned(ModInfoObject item, boolean pinned) { - setPinned(List.of(item), pinned); - } - - /// Pins or unpins several mods at once, saving them with a single write. - /// - /// @param items the mods to change - /// @param pinned whether the mods should stay at their current versions - void setSelectedPinned(ObservableList items, boolean pinned) { - setPinned(items, pinned); - } - - /// Applies a pin change to the list immediately, then saves it in the background. - /// - /// @param items the mods to change - /// @param pinned whether the mods should stay at their current versions - private void setPinned(Collection items, boolean pinned) { + /// Pins or unpins the given mods. + void setPinned(Collection items, boolean pinned) { ModManager manager = modManager; // While the list reloads, the items still belong to the previous mod directory. if (manager == null || isLoading()) { return; } - List files = items.stream() - .map(item -> item.getModInfo().getFile()) - .toList(); + try { + manager.setPinned(items.stream() + .map(item -> item.getModInfo().getFile()) + .toList(), pinned); + } catch (IOException e) { + LOG.warning("Unable to save pinned mods", e); + Controllers.dialog(i18n("message.failed") + "\n" + e, + i18n("message.error"), MessageDialogPane.MessageType.ERROR); + return; + } + for (ModInfoObject item : items) { item.setPinned(pinned); } - firePinnedStateChanged(); - - Task.runAsync(() -> manager.setPinned(files, pinned)) - .whenComplete(Schedulers.javafx(), exception -> { - if (exception != null) { - LOG.warning("Unable to save pinned mods", exception); - Controllers.showToast(i18n("message.failed")); - } - }) - .start(); - } - - /// Notifies [#pinnedStateChanged] that the pin state of some mod changed. - private void firePinnedStateChanged() { - Runnable listener = pinnedStateChanged; - if (listener != null) { - listener.run(); + if (pinnedStateChanged != null) { + pinnedStateChanged.run(); } } - /// Returns whether the mod stays at its current version during update checks. - /// - /// @param file the mod file - /// @return true if the update check skips the mod + /// Returns whether the update check skips the given mod. boolean isPinned(LocalModFile file) { ModManager manager = modManager; return manager != null && manager.isPinned(file.getFile()); } - /// Checks for updates of the given mods, skipping the ones pinned to their current version. - /// - /// @param mods the mods to check + /// Checks for updates of the given mods, ignoring the pinned ones. public void checkUpdates(Collection mods) { Objects.requireNonNull(mods); if (isLoading() || gameInstance == null) { @@ -398,7 +367,7 @@ private static final class ModListPageSkin extends SkinBase { private final HBox toolbarNormal; private final HBox toolbarSelecting; - /// Toolbar button of [#toolbarSelecting] that pins or unpins the selected mods. + /// Pins or unpins the selected mods. private final JFXButton pinSelected; private final JFXListView listView; @@ -481,11 +450,9 @@ public ModListPageSkin(ModListPage skinnable) { // reason for not using selectAll() is that selectAll() first clears all selected then selects all, causing the toolbar to flicker var selectAll = createToolbarButton2(i18n("button.select_all"), SVG.SELECT_ALL, () -> listView.getSelectionModel().selectRange(0, listView.getItems().size())); - // Pins or unpins every selected mod at once, the batch equivalent of the pin icon of a row. - pinSelected = createToolbarButton2(i18n("mods.pin"), SVG.KEEP, () -> { - skinnable.setSelectedPinned(listView.getSelectionModel().getSelectedItems(), !allPinned()); - updatePinSelectedButton(); - }); + // Pins or unpins every selected mod at once. + pinSelected = createToolbarButton2(i18n("mods.pin"), SVG.KEEP, () -> + skinnable.setPinned(listView.getSelectionModel().getSelectedItems(), !allPinned())); skinnable.pinnedStateChanged = this::updatePinSelectedButton; ListChangeListener listener = change -> { @@ -603,15 +570,13 @@ private void changeToolbar(HBox newToolbar) { } } - /// Returns whether every selected mod is already pinned, so [#pinSelected] unpins instead of pins. - /// - /// @return true if the selection is not empty and all of its mods are pinned + /// Whether every selected mod is pinned. private boolean allPinned() { ObservableList selected = listView.getSelectionModel().getSelectedItems(); return !selected.isEmpty() && selected.stream().allMatch(ModListPage.ModInfoObject::isPinned); } - /// Makes the text and glyph of [#pinSelected] describe what the next click will do. + /// Updates the text and glyph of [#pinSelected] to match the selection. private void updatePinSelectedButton() { boolean allPinned = allPinned(); pinSelected.setText(i18n(allPinned ? "mods.unpin" : "mods.pin")); @@ -660,7 +625,7 @@ public static final class ModInfoObject { /// Matching project metadata, or null if the local mod cannot be identified. private final @Nullable ModProjectInfo projectInfo; - /// Whether this mod stays at its current version during update checks. + /// Whether this mod is pinned to its current version. private final SimpleBooleanProperty pinned; private final ItemPropertyAsyncCache iconCache; @@ -677,23 +642,17 @@ public LocalModFile getModInfo() { return localModFile; } - /// Returns whether the mod stays at its current version when checking for updates. - /// - /// @return true if the mod is pinned + /// Whether the update check skips this mod. public boolean isPinned() { return pinned.get(); } - /// Returns the observable pin state, so list cells follow the changes made elsewhere. - /// - /// @return the read-only pin state + /// The pin state, which the list cells observe. public ReadOnlyBooleanProperty pinnedProperty() { return pinned; } - /// Records the pin state, which [#setPinned(ModInfoObject, boolean)] keeps in sync with the file. - /// - /// @param pinned the new pin state + /// Records the pin state once it was written to disk. private void setPinned(boolean pinned) { this.pinned.set(pinned); } @@ -860,7 +819,7 @@ private static final class ModInfoListCell extends MDListCell { private final ImageContainer imageContainer = new ImageContainer(32); private final TwoLineListItem content = new TwoLineListItem(); private final JFXButton pinButton = FXUtils.newToggleButton4(SVG.KEEP); - /// Shared tooltip of [#pinButton], whose text follows the pin state of the displayed mod. + /// Tooltip of [#pinButton]. private final Tooltip pinTooltip = new Tooltip(i18n("mods.pin")); private final JFXButton restoreButton = FXUtils.newToggleButton4(SVG.RESTORE); private final JFXButton infoButton = FXUtils.newToggleButton4(SVG.INFO); @@ -980,7 +939,7 @@ protected void updateControl(ModInfoObject dataItem, boolean empty) { checkBox.setSelected(modInfo.isActive()); } }); - // Managed as well, so the pin icon keeps the same place whether or not the mod has an old file to restore. + // Managed as well, so the pin icon does not move when there is no file to restore. boolean hasOldFiles = !modInfo.getMod().getOldFiles().isEmpty(); restoreButton.setVisible(hasOldFiles); restoreButton.setManaged(hasOldFiles); @@ -1000,7 +959,7 @@ protected void updateControl(ModInfoObject dataItem, boolean empty) { pinButton.setDisable(modInfo.isOld()); (observedPinnedProperty = dataItem.pinnedProperty()).addListener(weakPinnedListener); applyPinState(dataItem); - pinButton.setOnAction(e -> page.setPinned(dataItem, !dataItem.isPinned())); + pinButton.setOnAction(e -> page.setPinned(List.of(dataItem), !dataItem.isPinned())); if (!warning.isEmpty()) { pseudoClassStateChanged(WARNING, true); @@ -1013,9 +972,7 @@ protected void updateControl(ModInfoObject dataItem, boolean empty) { } } - /// Rebuilds the pin icon, tag and tooltip of the displayed mod. - /// - /// @param item the mod shown by this cell, or null when the cell is empty + /// Updates the pin tag, icon and tooltip of the cell. private void applyPinState(@Nullable ModInfoObject item) { if (item == null) { return; @@ -1026,7 +983,7 @@ private void applyPinState(@Nullable ModInfoObject item) { if (item.isPinned()) { content.addTag(text); } - // A pinned mod shows the filled glyph, so the state is visible without reading the tag. + // A pinned mod shows the filled glyph. pinButton.setGraphic((item.isPinned() ? SVG.KEEP_FILL : SVG.KEEP).createIcon()); pinTooltip.setText(i18n(item.isPinned() ? "mods.unpin" : "mods.pin")); } 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 6fc2ef82a1a..f1cb154cb0e 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 @@ -208,7 +208,7 @@ public void refresh() throws IOException { localFiles.clear(); localMods.clear(); - // Re-read the pin file as well, so edits made outside this manager are picked up. + // Re-read the pin file, so edits made outside this manager are picked up. pinnedFiles = null; analyze(); @@ -390,18 +390,15 @@ public boolean isDisabled(Path file) { return FileUtils.getName(file).endsWith(DISABLED_EXTENSION); } - /// Name of the file in the mods directory that lists the mods pinned to their current version, one per line. + /// Name of the file that lists the pinned mods, one per line. /// - /// The file has no mod extension, so [#refresh()] skips it. + /// It has no mod extension, so the directory scan skips it. public static final String PINNED_FILE_NAME = ".hmcl-pinned"; /// Pinned mod file names, or null until they are read from [#PINNED_FILE_NAME]. private @Nullable Set pinnedFiles; - /// Returns whether the mod at the given path stays at its current version during update checks. - /// - /// @param file the mod file - /// @return true if the mod should not be updated + /// Returns whether the update check skips the given mod. public boolean isPinned(Path file) { lock.lock(); try { @@ -411,18 +408,13 @@ public boolean isPinned(Path file) { } } - /// Pins or unpins the given mods, writing [#PINNED_FILE_NAME] at most once. - /// - /// A name is stored without the `.disabled` or `.old` suffix, so pinning survives enabling, - /// disabling and rolling back the mod. The in-memory pins are updated only after the file was - /// written, so a failed write leaves both the file and the memory unchanged. + /// Pins or unpins the given mods, writing [#PINNED_FILE_NAME] once. /// - /// @param files the mod files to change - /// @param pinned whether they should stay at their current versions /// @throws IOException if the pin file cannot be written public void setPinned(Collection files, boolean pinned) throws IOException { lock.lock(); try { + // The name has no .disabled or .old suffix, so a pin survives these renames. var names = new HashSet<>(getPinnedFiles()); boolean changed = false; for (Path file : files) { @@ -432,6 +424,7 @@ public void setPinned(Collection files, boolean pinned) throws IOException } if (changed) { + // Save first, so a failed write leaves the memory unchanged. Set newPins = Set.copyOf(names); savePinnedFiles(newPins); pinnedFiles = newPins; @@ -442,8 +435,6 @@ public void setPinned(Collection files, boolean pinned) throws IOException } /// Returns the pinned mod names, reading [#PINNED_FILE_NAME] on first access. - /// - /// @return the pinned mod file names, empty when nothing is pinned private @Unmodifiable Set getPinnedFiles() { if (pinnedFiles == null) { pinnedFiles = loadPinnedFiles(); @@ -451,11 +442,7 @@ public void setPinned(Collection files, boolean pinned) throws IOException return pinnedFiles; } - /// Reads [#PINNED_FILE_NAME] from the mods directory. - /// - /// A missing or unreadable file yields an empty set, so it never hides the mod list. - /// - /// @return the pinned mod file names + /// Reads [#PINNED_FILE_NAME]. A missing or unreadable file yields no pins. private @Unmodifiable Set loadPinnedFiles() { Path file = getDirectory().resolve(PINNED_FILE_NAME); if (!Files.isRegularFile(file)) { @@ -477,22 +464,19 @@ public void setPinned(Collection files, boolean pinned) throws IOException } } - /// Writes the pinned mod names to [#PINNED_FILE_NAME], sorted to keep the file stable. - /// - /// The names go to a temporary file that then replaces the pin file, so an interrupted write - /// cannot leave a truncated file and lose every pin. + /// Writes the pinned mod names to [#PINNED_FILE_NAME], sorted. /// - /// @param names the pinned mod file names /// @throws IOException if the file cannot be written private void savePinnedFiles(Collection names) throws IOException { Path file = getDirectory().resolve(PINNED_FILE_NAME); + // Write to a temporary file, so an interrupted write cannot truncate the pin file. Path temp = Files.createTempFile(getDirectory(), PINNED_FILE_NAME, ".tmp"); try { Files.write(temp, names.stream().sorted().toList(), StandardCharsets.UTF_8); try { Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); } catch (AtomicMoveNotSupportedException e) { - // Some file systems cannot replace atomically, but the content is identical either way. + // Some file systems cannot replace atomically. Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING); } } finally {