Skip to content

Commit 08e40c3

Browse files
committed
模组禁用/删除时检测并提示反向依赖,支持级联处理
1 parent d4d08b1 commit 08e40c3

4 files changed

Lines changed: 187 additions & 5 deletions

File tree

HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java

Lines changed: 169 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import javafx.beans.property.BooleanProperty;
2525
import javafx.beans.property.ObjectProperty;
2626
import javafx.beans.property.SimpleBooleanProperty;
27+
import javafx.collections.FXCollections;
2728
import javafx.collections.ListChangeListener;
2829
import javafx.css.PseudoClass;
2930
import javafx.geometry.Insets;
@@ -36,6 +37,7 @@
3637
import javafx.scene.input.MouseEvent;
3738
import javafx.scene.layout.HBox;
3839
import javafx.scene.layout.Priority;
40+
import javafx.scene.layout.Region;
3941
import javafx.scene.layout.StackPane;
4042
import javafx.scene.layout.VBox;
4143
import javafx.stage.Stage;
@@ -172,14 +174,54 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
172174

173175
toolbarSelecting.getChildren().setAll(
174176
createToolbarButton2(i18n("button.remove"), SVG.DELETE_FOREVER, () -> {
175-
Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), () -> {
176-
skinnable.removeSelected(listView.getSelectionModel().getSelectedItems());
177-
}, null);
177+
var selected = listView.getSelectionModel().getSelectedItems();
178+
List<LocalModFile> targets = new ArrayList<>();
179+
for (ModInfoObject item : selected)
180+
if (item != null) targets.add(item.getModInfo());
181+
List<ModInfoObject> dependents = findActiveDependents(targets);
182+
if (dependents.isEmpty()) {
183+
Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"),
184+
() -> skinnable.removeSelected(selected), null);
185+
} else {
186+
List<ModInfoObject> selectedSnapshot = new ArrayList<>(selected);
187+
Controllers.dialog(new DependencyWarningDialog(dependents, i18n("button.remove"), List.of(
188+
new CascadeOption(i18n("addon.dependencies.warning.cascade.none"),
189+
() -> skinnable.removeSelected(FXCollections.observableArrayList(selectedSnapshot))),
190+
new CascadeOption(i18n("addon.dependencies.warning.cascade"), () -> {
191+
for (ModInfoObject dependent : dependents)
192+
dependent.getModInfo().setActive(false);
193+
skinnable.removeSelected(FXCollections.observableArrayList(selectedSnapshot));
194+
}),
195+
new CascadeOption(i18n("addon.dependencies.warning.cascade.delete"), () -> {
196+
List<ModInfoObject> all = new ArrayList<>(selectedSnapshot);
197+
all.addAll(dependents);
198+
skinnable.removeSelected(FXCollections.observableArrayList(all));
199+
})
200+
)));
201+
}
178202
}),
179203
createToolbarButton2(i18n("mods.enable"), SVG.CHECK, () ->
180204
skinnable.enableSelected(listView.getSelectionModel().getSelectedItems())),
181-
createToolbarButton2(i18n("mods.disable"), SVG.CLOSE, () ->
182-
skinnable.disableSelected(listView.getSelectionModel().getSelectedItems())),
205+
createToolbarButton2(i18n("mods.disable"), SVG.CLOSE, () -> {
206+
var selected = listView.getSelectionModel().getSelectedItems();
207+
List<LocalModFile> targets = new ArrayList<>();
208+
for (ModInfoObject item : selected)
209+
if (item != null) targets.add(item.getModInfo());
210+
List<ModInfoObject> dependents = findActiveDependents(targets);
211+
if (dependents.isEmpty()) {
212+
skinnable.disableSelected(selected);
213+
} else {
214+
Controllers.dialog(new DependencyWarningDialog(dependents, i18n("mods.disable"), List.of(
215+
new CascadeOption(i18n("addon.dependencies.warning.cascade.none"),
216+
() -> skinnable.disableSelected(selected)),
217+
new CascadeOption(i18n("addon.dependencies.warning.cascade"), () -> {
218+
skinnable.disableSelected(selected);
219+
for (ModInfoObject dependent : dependents)
220+
dependent.getModInfo().setActive(false);
221+
})
222+
)));
223+
}
224+
}),
183225
createToolbarButton2(i18n("addon.check_update.button"), SVG.UPDATE, () ->
184226
skinnable.checkUpdates(
185227
listView.getSelectionModel().getSelectedItems().stream()
@@ -302,6 +344,28 @@ private void search() {
302344
}
303345
}
304346

347+
// Finds enabled mods (other than the targets themselves) that declare a dependency on any of
348+
// the targets — i.e. mods that may break if the targets are disabled.
349+
private List<ModInfoObject> findActiveDependents(Collection<LocalModFile> targets) {
350+
Set<String> targetIds = new HashSet<>();
351+
for (LocalModFile target : targets) {
352+
if (StringUtils.isNotBlank(target.getId()))
353+
targetIds.add(target.getId());
354+
}
355+
if (targetIds.isEmpty())
356+
return List.of();
357+
358+
List<ModInfoObject> dependents = new ArrayList<>();
359+
for (ModInfoObject item : getSkinnable().getItems()) {
360+
LocalModFile mod = item.getModInfo();
361+
if (targets.contains(mod) || !mod.isActive())
362+
continue;
363+
if (mod.getDependencies().stream().anyMatch(targetIds::contains))
364+
dependents.add(item);
365+
}
366+
return dependents;
367+
}
368+
305369
static final class ModInfoObject {
306370
private final BooleanProperty active;
307371
// Whether the nested (Jar-in-Jar) mods of this entry are expanded in the list.
@@ -566,6 +630,84 @@ final class ModInfoDialog extends JFXDialogLayout {
566630
}
567631
}
568632

633+
// A selectable "what to do with the dependent mods" choice. action performs the full operation
634+
// (always acting on the target mods, plus optionally on the dependents).
635+
private record CascadeOption(String label, Runnable action) {
636+
@Override
637+
public String toString() {
638+
return label;
639+
}
640+
}
641+
642+
final class DependencyWarningDialog extends JFXDialogLayout {
643+
644+
DependencyWarningDialog(List<ModInfoObject> dependents, String confirmText, List<CascadeOption> options) {
645+
setHeading(new Label(i18n("addon.dependencies.warning.title")));
646+
647+
Label message = new Label(i18n("addon.dependencies.warning"));
648+
message.setWrapText(true);
649+
650+
ComponentList list = new ComponentList();
651+
list.getStyleClass().add("no-padding");
652+
for (ModInfoObject dependent : dependents) {
653+
HBox row = new HBox(8);
654+
row.setAlignment(Pos.CENTER_LEFT);
655+
row.setPadding(new Insets(8));
656+
row.setMouseTransparent(true);
657+
658+
ImageContainer icon = new ImageContainer(32);
659+
dependent.loadIcon(icon, null);
660+
661+
TwoLineListItem content = new TwoLineListItem();
662+
HBox.setHgrow(content, Priority.ALWAYS);
663+
content.setTitle(dependent.getModTranslations() != null && I18n.isUseChinese()
664+
? dependent.getModTranslations().getDisplayName()
665+
: dependent.getModInfo().getName());
666+
StringJoiner subtitle = new StringJoiner(" | ");
667+
if (StringUtils.isNotBlank(dependent.getModInfo().getId()))
668+
subtitle.add(dependent.getModInfo().getId());
669+
subtitle.add(FileUtils.getName(dependent.getModInfo().getFile()));
670+
content.setSubtitle(subtitle.toString());
671+
672+
row.getChildren().setAll(icon, content);
673+
list.getContent().add(row);
674+
}
675+
676+
ScrollPane scrollPane = new ScrollPane(list);
677+
scrollPane.setFitToWidth(true);
678+
scrollPane.setMaxHeight(300);
679+
FXUtils.smoothScrolling(scrollPane);
680+
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
681+
682+
setBody(new VBox(10, message, scrollPane));
683+
684+
JFXComboBox<CascadeOption> cascade = new JFXComboBox<>();
685+
cascade.getItems().setAll(options);
686+
cascade.getSelectionModel().selectFirst();
687+
688+
HBox cascadeBox = new HBox(8, new Label(i18n("addon.dependencies.warning.cascade.label")), cascade);
689+
cascadeBox.setAlignment(Pos.CENTER_LEFT);
690+
691+
Region spacer = new Region();
692+
HBox.setHgrow(spacer, Priority.ALWAYS);
693+
694+
JFXButton cancelButton = new JFXButton(i18n("button.cancel"));
695+
cancelButton.setOnAction(e -> fireEvent(new DialogCloseEvent()));
696+
697+
JFXButton confirmButton = new JFXButton(confirmText);
698+
confirmButton.getStyleClass().add("dialog-accept");
699+
confirmButton.setOnAction(e -> {
700+
fireEvent(new DialogCloseEvent());
701+
CascadeOption selected = cascade.getValue();
702+
if (selected != null)
703+
selected.action().run();
704+
});
705+
706+
getActions().setAll(cascadeBox, spacer, cancelButton, confirmButton);
707+
onEscPressed(this, cancelButton::fire);
708+
}
709+
}
710+
569711
private static final Lazy<PopupMenu> menu = new Lazy<>(PopupMenu::new);
570712
private static final Lazy<JFXPopup> popup = new Lazy<>(() -> new JFXPopup(menu.get()));
571713

@@ -619,6 +761,28 @@ final class ModInfoListCell extends MDListCell<ModInfoObject> {
619761
e.consume();
620762
});
621763

764+
// Warn before disabling a mod that other enabled mods depend on.
765+
checkBox.addEventFilter(MouseEvent.MOUSE_PRESSED, e -> {
766+
ModInfoObject item = getItem();
767+
if (item == null || !checkBox.isSelected())
768+
return; // enabling (or empty) — nothing to warn about
769+
List<LocalModFile> targets = List.of(item.getModInfo());
770+
List<ModInfoObject> dependents = findActiveDependents(targets);
771+
if (!dependents.isEmpty()) {
772+
e.consume(); // block the immediate toggle; let the user confirm first
773+
LocalModFile target = item.getModInfo();
774+
Controllers.dialog(new DependencyWarningDialog(dependents, i18n("mods.disable"), List.of(
775+
new CascadeOption(i18n("addon.dependencies.warning.cascade.none"),
776+
() -> target.setActive(false)),
777+
new CascadeOption(i18n("addon.dependencies.warning.cascade"), () -> {
778+
target.setActive(false);
779+
for (ModInfoObject dependent : dependents)
780+
dependent.getModInfo().setActive(false);
781+
})
782+
)));
783+
}
784+
});
785+
622786
nestedBox.getStyleClass().add("mod-nested-list");
623787
nestedBox.visibleProperty().bind(expanded);
624788
nestedBox.managedProperty().bind(nestedBox.visibleProperty());

HMCL/src/main/resources/assets/lang/I18N.properties

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,12 @@ addon.dependencies.bundled=Bundled
196196
addon.dependencies.disabled=Disabled
197197
addon.dependencies.installed=Installed
198198
addon.dependencies.missing=Not installed
199+
addon.dependencies.warning=The following enabled mods depend on the affected mod(s) and may stop working:
200+
addon.dependencies.warning.cascade=Also disable
201+
addon.dependencies.warning.cascade.delete=Also delete
202+
addon.dependencies.warning.cascade.label=Dependent mods:
203+
addon.dependencies.warning.cascade.none=Leave as-is
204+
addon.dependencies.warning.title=Dependency warning
199205
addon.dependency.embedded=Built-in Dependencies (Already packaged in the addon file by the author. No need to download separately)
200206
addon.dependency.optional=Optional Dependencies (If missing, the game will run normally, but the addon features may be missing)
201207
addon.dependency.required=Required Dependencies (Must be downloaded separately. Missing may prevent the game from launching)

HMCL/src/main/resources/assets/lang/I18N_zh.properties

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,12 @@ addon.dependencies.bundled=已內置
194194
addon.dependencies.disabled=已停用
195195
addon.dependencies.installed=已安裝
196196
addon.dependencies.missing=未安裝
197+
addon.dependencies.warning=以下已啟用的模組依賴於受影響的模組,操作後可能無法正常運作:
198+
addon.dependencies.warning.cascade=一併停用
199+
addon.dependencies.warning.cascade.delete=一併刪除
200+
addon.dependencies.warning.cascade.label=對依賴模組:
201+
addon.dependencies.warning.cascade.none=不處理
202+
addon.dependencies.warning.title=依賴警告
197203
addon.dependency.embedded=內建相依內容 (作者已經打包在檔案中,無需單獨下載)
198204
addon.dependency.optional=可選相依內容 (如果不安裝,遊戲可以執行,但功能可能缺失)
199205
addon.dependency.required=必需相依內容 (必須單獨下載,缺少可能會導致遊戲無法啟動)

HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,12 @@ addon.dependencies.bundled=已内置
196196
addon.dependencies.disabled=已禁用
197197
addon.dependencies.installed=已安装
198198
addon.dependencies.missing=未安装
199+
addon.dependencies.warning=以下已启用的模组依赖于受影响的模组,操作后可能无法正常工作:
200+
addon.dependencies.warning.cascade=一并禁用
201+
addon.dependencies.warning.cascade.delete=一并删除
202+
addon.dependencies.warning.cascade.label=对依赖模组:
203+
addon.dependencies.warning.cascade.none=不处理
204+
addon.dependencies.warning.title=依赖警告
199205
addon.dependency.embedded=内置的前置内容 (已经由作者打包在文件中,无需另外下载)
200206
addon.dependency.optional=可选的前置内容 (若缺失游戏能够正常运行,但功能可能缺失)
201207
addon.dependency.required=必需的前置内容 (必须另外下载,缺失可能会导致游戏无法启动)

0 commit comments

Comments
 (0)