Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ public enum SVG {
CHECK("M9.55 18 3.85 12.3 5.275 10.875 9.55 15.15 18.725 5.975 20.15 7.4 9.55 18Z"),
CHECKROOM("M3 20Q2.575 20 2.2875 19.7125T2 19Q2 18.75 2.1 18.5375T2.4 18.2L11 11.75V10Q11 9.575 11.3 9.2875T12.025 9Q12.65 9 13.075 8.55T13.5 7.475Q13.5 6.85 13.0625 6.425T12 6Q11.375 6 10.9375 6.4375T10.5 7.5H8.5Q8.5 6.05 9.525 5.025T12 4Q13.45 4 14.475 5.0125T15.5 7.475Q15.5 8.65 14.8125 9.575T13 10.85V11.75L21.6 18.2Q21.8 18.325 21.9 18.5375T22 19Q22 19.425 21.7125 19.7125T21 20H3ZM6 18H18L12 13.5 6 18Z"),
CHECK_CIRCLE("M10.6 16.6 17.65 9.55 16.25 8.15 10.6 13.8 7.75 10.95 6.35 12.35 10.6 16.6ZM12 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"),
CLEAN("M3 23v-7q0-2.075 1.463-3.537T8 11h1V3q0-.825.588-1.412T11 1h2q.825 0 1.413.588T15 3v8h1q2.075 0 3.538 1.463T21 16v7zm2-2h2v-3q0-.425.288-.712T8 17t.713.288T9 18v3h2v-3q0-.425.288-.712T12 17t.713.288T13 18v3h2v-3q0-.425.288-.712T16 17t.713.288T17 18v3h2v-5q0-1.25-.875-2.125T16 13H8q-1.25 0-2.125.875T5 16zm8-10V3h-2v8zm0 0h-2z"),
CLOSE("M6.4 19 5 17.6 10.6 12 5 6.4 6.4 5 12 10.6 17.6 5 19 6.4 13.4 12 19 17.6 17.6 19 12 13.4 6.4 19Z"),
CONTENT_CUT("M19 21l-7-7-2.35 2.35q.2.375.275.8T10 18q0 1.65-1.175 2.825T6 22q-1.65 0-2.825-1.175T2 18t1.175-2.825T6 14q.425 0 .85.075t.8.275L10 12 7.65 9.65q-.375.2-.8.275T6 10q-1.65 0-2.825-1.175T2 6q0-1.65 1.175-2.825T6 2q1.65 0 2.825 1.175T10 6q0 .425-.075.85t-.275.8L22 20v1H19Zm-4-10-2-2 6-6h3v1l-7 7ZM7.4125 7.4125Q8 6.825 8 6t-.5875-1.4125Q6.825 4 6 4t-1.4125.5875Q4 5.175 4 6t.5875 1.4125T6 8t1.4125-.5875ZM12.35 12.35q.15-.15.15-.35t-.15-.35-.35-.15-.35.15-.15.35.15.35.35.15.35-.15ZM7.4125 19.4125Q8 18.825 8 18t-.5875-1.4125Q6.825 16 6 16t-1.4125.5875Q4 17.175 4 18t.5875 1.4125Q5.175 20 6 20t1.4125-.5875Z"),
CONTENT_COPY("M9 18Q8.175 18 7.5875 17.4125T7 16V4Q7 3.175 7.5875 2.5875T9 2H18Q18.825 2 19.4125 2.5875T20 4V16Q20 16.825 19.4125 17.4125T18 18H9ZM9 16H18V4H9V16ZM5 22Q4.175 22 3.5875 21.4125T3 20V6H5V20H16V22H5ZM9 16V4 16Z"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ public String getDisplayName() {
}

private final HBox actions;
private final EnhancedTextFlow textFlow;

private @Nullable ButtonBase cancelButton;

Expand All @@ -87,7 +88,7 @@ public MessageDialogPane(@NotNull String text, @Nullable String title, @NotNull

StackPane content = new StackPane();
content.getStyleClass().add("jfx-layout-body");
EnhancedTextFlow textFlow = new EnhancedTextFlow(text);
textFlow = new EnhancedTextFlow(text);
textFlow.setStyle("-fx-font-size: 14px;");
if (textFlow.computePrefHeight(400.0) <= 350.0)
content.getChildren().setAll(textFlow);
Expand Down Expand Up @@ -115,6 +116,10 @@ public MessageDialogPane(@NotNull String text, @Nullable String title, @NotNull
});
}

public void setText(String text) {
textFlow.setText(text);
}

public void addButton(Node btn) {
btn.addEventHandler(ActionEvent.ACTION, e -> fireEvent(new DialogCloseEvent()));
actions.getChildren().add(btn);
Expand All @@ -130,7 +135,13 @@ public ButtonBase getCancelButton() {

private static final class EnhancedTextFlow extends TextFlow {
EnhancedTextFlow(String text) {
this.getChildren().setAll(FXUtils.parseSegment(text, Controllers::onHyperlinkAction));
setText(text);
}

public void setText(String newText) {
this.getChildren().setAll(
FXUtils.parseSegment(newText, Controllers::onHyperlinkAction)
);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@
import javafx.scene.control.SkinBase;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.*;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.util.Duration;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.setting.Profiles;
Expand Down Expand Up @@ -187,6 +190,10 @@ public void refreshList() {
Profiles.getSelectedProfile().getRepository().refreshVersionsAsync().start();
}

public void clean() {
Versions.cleanGameFiles(Profiles.getSelectedProfile());
}

@Override
protected Skin<?> createDefaultSkin() {
return new GameListSkin(this);
Expand Down Expand Up @@ -237,7 +244,7 @@ public GameListSkin(GameList skinnable) {

searchBar.getChildren().setAll(searchField, closeSearchBar);

toolbarNormal.getChildren().setAll(createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refreshList), createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar)));
toolbarNormal.getChildren().setAll(createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refreshList), createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar)), createToolbarButton2(i18n("game.clean"), SVG.CLEAN, skinnable::clean));

toolbarPane.setContent(toolbarNormal, ContainerAnimations.FADE);

Expand Down
126 changes: 126 additions & 0 deletions HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/Versions.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
package org.jackhuang.hmcl.ui.versions;

import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXSpinner;
import javafx.application.Platform;
import javafx.scene.layout.StackPane;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccount;
Expand All @@ -42,17 +44,22 @@
import org.jackhuang.hmcl.ui.export.ExportWizardProvider;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.OperatingSystem;

import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
Expand Down Expand Up @@ -194,6 +201,125 @@ public static void updateGameAssets(Profile profile, String version) {
executor.start();
}

public static void cleanGameFiles(Profile profile) {
var dialogBuilder = new MessageDialogPane.Builder(i18n("game.clean.content", i18n("game.clean.loading")), i18n("message.question"), MessageDialogPane.MessageType.QUESTION);
var spinner = new JFXSpinner();
spinner.getStyleClass().add("small-spinner");

StackPane buttonPane = new StackPane();

JFXButton okButton = new JFXButton(i18n("button.yes"));
okButton.getStyleClass().add("dialog-accept");

dialogBuilder.addAction(buttonPane);
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MessageDialogPane.Builder#addAction(Node) registers an ActionEvent handler on the provided node to close the dialog. Passing a container (buttonPane) means the ActionEvent from okButton will bubble up and close the dialog immediately on click, preventing the intended “show spinner while deleting, then close” flow. Prefer adding the actual button as the action node (and suppress auto-close), or expose/update the dialog action area via an API instead of wrapping with a pane.

Suggested change
dialogBuilder.addAction(buttonPane);
dialogBuilder.addAction(okButton);

Copilot uses AI. Check for mistakes.
dialogBuilder.addCancel(null);

var dialog = dialogBuilder.build();

Task.supplyAsync(() -> {
var repository = profile.getRepository();
var versions = repository.getVersions();

Set<String> activeAssets = versions.parallelStream()
.flatMap(version -> {
try {
var index = repository.getAssetIndex(version.getId(), version.getAssetIndex().getId());
return index.getObjects().values().stream().map(AssetObject::getLocation);
} catch (IOException ignored) {
return Stream.empty();
}
})
.collect(Collectors.toSet());

Set<String> activeLibraries = versions.parallelStream()
.flatMap(version -> version.getLibraries().stream())
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

activeLibraries is derived from version.getLibraries() only, but HMCL stores extra libraries in version.getPatches() (e.g., modpack/loader patches). Those patched libraries live under libraries/ too, so this cleanup can misclassify them as unused and delete required files. Build activeLibraries from the resolved version (e.g., resolvePreservingPatches(...)) or explicitly include libraries from patches when collecting paths.

Suggested change
.flatMap(version -> version.getLibraries().stream())
.flatMap(version -> {
Stream<Library> baseLibraries = version.getLibraries().stream();
Stream<Library> patchedLibraries = version.getPatches().stream()
.flatMap(patch -> patch.getLibraries().stream());
return Stream.concat(baseLibraries, patchedLibraries);
})

Copilot uses AI. Check for mistakes.
.map(Library::getPath)
.collect(Collectors.toSet());

List<Path> unusedFiles = new ArrayList<>();

unusedFiles.addAll(findUnlistedFiles(repository.getBaseDirectory().resolve("assets").resolve("objects"), activeAssets));
unusedFiles.addAll(findUnlistedFiles(repository.getBaseDirectory().resolve("libraries"), activeLibraries));

List<Path> unusedFolders = new ArrayList<>();

for (String path : List.of("logs", "crash-reports", "modernfix", "mods/.connector", "CustomSkinLoader/caches", ".fabric")) {
unusedFolders.add(repository.getBaseDirectory().resolve(path));
versions.forEach(v -> {
unusedFolders.add(repository.getVersionRoot(v.getId()).resolve(path));
});
}

versions.forEach(v -> {
try (var walker = Files.walk(repository.getVersionRoot(v.getId()), 1)) {
unusedFolders.addAll(walker
.filter(it -> {
var name = it.getFileName().toString();
return Files.isDirectory(it) && (name.startsWith("natives-") || name.endsWith("-natives"));
}).toList());
Comment on lines +254 to +259
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Files.walk(versionRoot, 1) includes versionRoot itself. If a user’s version id happens to start with natives- or end with -natives, this filter will treat the entire version directory as a “natives” folder and enqueue all its files for deletion. Use Files.list(versionRoot) (children only) or add an explicit !it.equals(versionRoot) guard before applying the name predicate.

Copilot uses AI. Check for mistakes.
} catch (IOException ignored) {
}
});

for (Path dir : unusedFolders) {
if (Files.exists(dir)) {
try (var s = Files.walk(dir)) {
s.filter(Files::isRegularFile).forEach(unusedFiles::add);
} catch (IOException ignored) {
}
}
}

return unusedFiles;
}).thenApplyAsync((list) -> {
long totalSize = list.stream()
.mapToLong(path -> {
try {
return Files.size(path);
} catch (IOException e) {
return 0L;
}
})
.sum();

FXUtils.runInFX(() -> {
dialog.setText(i18n("game.clean.content", I18n.formatSize(totalSize)));
buttonPane.getChildren().setAll(okButton);
okButton.setOnAction(event -> {
buttonPane.getChildren().setAll(spinner);
Task.runAsync(() -> list.forEach(path -> {
try {
Files.deleteIfExists(path);
} catch (IOException ignored) {
}
})).thenRunAsync(Schedulers.javafx(), () -> {
dialog.fireEvent(new DialogCloseEvent());
}).start();
});
});
return null;
}).start();

buttonPane.getChildren().setAll(spinner);

Controllers.dialog(dialog);
}

private static List<Path> findUnlistedFiles(Path root, Set<String> activePaths) {
if (!Files.exists(root)) return List.of();
try (var stream = Files.walk(root)) {
return stream
.filter(Files::isRegularFile)
.filter(path -> {
String relative = root.relativize(path).toString().replace("\\", "/");
return !activePaths.contains(relative);
})
.toList();
} catch (IOException e) {
return List.of();
}
}

public static void cleanVersion(Profile profile, String id) {
try {
profile.getRepository().clean(id);
Expand Down
4 changes: 4 additions & 0 deletions HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/I18n.java
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ public static String formatSpeed(long bytes) {
return getTranslator().formatSpeed(bytes);
}

public static String formatSize(long bytes) {
return getTranslator().formatSize(bytes);
}

public static String getDisplayVersion(RemoteVersion version) {
return getTranslator().getDisplayVersion(version);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,16 @@ public String formatSpeed(long bytes) {
return supportedLocale.i18n("download.speed.megabyte_per_second", (double) bytes / (1024 * 1024));
}
}

public String formatSize(long bytes) {
if (bytes < 1024) {
return supportedLocale.i18n("download.size.byte", bytes);
} else if (bytes < 1024 * 1024) {
return supportedLocale.i18n("download.size.kibibyte", (double) bytes / 1024);
} else if (bytes < 1024 * 1024 * 1024) {
return supportedLocale.i18n("download.size.megabyte", (double) bytes / (1024 * 1024));
} else {
return supportedLocale.i18n("download.size.gibabyte", (double) bytes / (1024 * 1024 * 1024));
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The i18n key name download.size.gibabyte appears to be a typo (GiB is “gibibyte”, matching the existing kibibyte/megabyte naming). Since this is newly introduced, consider renaming the key (and all translations/usages) to download.size.gibibyte to avoid confusion and keep key naming consistent.

Suggested change
return supportedLocale.i18n("download.size.gibabyte", (double) bytes / (1024 * 1024 * 1024));
return supportedLocale.i18n("download.size.gibibyte", (double) bytes / (1024 * 1024 * 1024));

Copilot uses AI. Check for mistakes.
}
}
}
7 changes: 7 additions & 0 deletions HMCL/src/main/resources/assets/lang/I18N.properties
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,10 @@ download.javafx.notes=We are currently downloading dependencies for HMCL from th
Note: If your download speed is too slow, you can try switching to another mirror.
download.javafx.component=Downloading module "%s"
download.javafx.prepare=Preparing to download
download.size.byte=%d B
download.size.kibibyte=%.1f KiB
download.size.megabyte=%.1f MiB
download.size.gibabyte=%.1f GiB
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The i18n key name download.size.gibabyte appears to be a typo (GiB is “gibibyte”, matching the existing kibibyte/megabyte naming). Since this is newly introduced, consider renaming the key to download.size.gibibyte (and update all locales/usages) for consistency.

Suggested change
download.size.gibabyte=%.1f GiB
download.size.gibibyte=%.1f GiB

Copilot uses AI. Check for mistakes.
download.speed.byte_per_second=%d B/s
download.speed.kibibyte_per_second=%.1f KiB/s
download.speed.megabyte_per_second=%.1f MiB/s
Expand Down Expand Up @@ -470,6 +474,9 @@ folder.screenshots=Screenshots
folder.world=World Directory

game=Games
game.clean=Clean Game Files
game.clean.loading=Loading...
game.clean.content=This operation will clean up game logs, redundant libraries, redundant resource files, etc., freeing up approximately %s of space.\nYour mods, save files, and other game data will not be affected. Do you want to continue?
game.crash.feedback=<b>Please do not share screenshots or photos of this interface with others!</b> If you ask for help from others, please click <b>"Export Crash Logs"</b> and send the exported file to others for analysis.
game.crash.info=Crash Info
game.crash.reason=Crash Cause
Expand Down
7 changes: 7 additions & 0 deletions HMCL/src/main/resources/assets/lang/I18N_zh.properties
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,10 @@ download.javafx=正在下載必要的執行時元件
download.javafx.notes=正在透過網路下載 HMCL 必要的執行時元件。\n點擊「切換下載源」按鈕查看詳情以及選取下載源。點擊「取消」按鈕停止並退出。\n注意:如果下載速度過慢,請嘗試切換下載源。
download.javafx.component=正在下載元件「%s」
download.javafx.prepare=準備開始下載
download.size.byte=%d B
download.size.kibibyte=%.1f KiB
download.size.megabyte=%.1f MiB
download.size.gibabyte=%.1f GiB
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The i18n key name download.size.gibabyte appears to be a typo (GiB is “gibibyte”, matching the kibibyte/megabyte naming). Since this is newly introduced, consider renaming the key to download.size.gibibyte (and update all locales/usages) for consistency.

Suggested change
download.size.gibabyte=%.1f GiB
download.size.gibibyte=%.1f GiB

Copilot uses AI. Check for mistakes.
download.speed.byte_per_second=%d B/s
download.speed.kibibyte_per_second=%.1f KiB/s
download.speed.megabyte_per_second=%.1f MiB/s
Expand Down Expand Up @@ -433,6 +437,9 @@ folder.screenshots=截圖目錄
folder.world=世界目錄

game=遊戲
game.clean=清理遊戲文件
game.clean.loading=统计中...
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the Traditional Chinese locale file, but the new string uses the Simplified character “统计”. Consider changing it to Traditional (“統計”) to keep the locale consistent.

Suggested change
game.clean.loading=统计中...
game.clean.loading=統計中...

Copilot uses AI. Check for mistakes.
game.clean.content=本操作將會清理遊戲的日誌、冗餘庫、冗餘資源等文件,預計釋放 %s 空間。\n你的模組、存檔等遊戲數據不會受到影響。是否繼續操作?
game.crash.feedback=<b>請不要將本介面截圖或拍照給他人!</b>如果你要求助他人,請你點擊左下角「匯出遊戲崩潰資訊」後將匯出的檔案發送給他人以供分析。\n你可以點擊下方的「幫助」前往社群尋求幫助。
game.crash.info=遊戲訊息
game.crash.reason=崩潰原因
Expand Down
7 changes: 7 additions & 0 deletions HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,10 @@ download.javafx=正在下载必要的运行时组件……
download.javafx.notes=正在通过网络下载 HMCL 必要的运行时组件。\n点击“切换下载源”按钮查看详情以及选择下载源。点击“取消”按钮停止并退出。\n注意:若下载速度过慢,请尝试切换下载源。
download.javafx.component=正在下载模块“%s”
download.javafx.prepare=准备开始下载
download.size.byte=%d B
download.size.kibibyte=%.1f KiB
download.size.megabyte=%.1f MiB
download.size.gibabyte=%.1f GiB
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The i18n key name download.size.gibabyte appears to be a typo (GiB is “gibibyte”, matching the kibibyte/megabyte naming). Since this is newly introduced, consider renaming the key to download.size.gibibyte (and update all locales/usages) for consistency.

Suggested change
download.size.gibabyte=%.1f GiB
download.size.gibibyte=%.1f GiB

Copilot uses AI. Check for mistakes.
download.speed.byte_per_second=%d B/s
download.speed.kibibyte_per_second=%.1f KiB/s
download.speed.megabyte_per_second=%.1f MiB/s
Expand Down Expand Up @@ -437,6 +441,9 @@ folder.screenshots=截图文件夹
folder.world=世界文件夹

game=游戏
game.clean=清理游戏文件
game.clean.loading=统计中...
game.clean.content=本操作将会清理游戏的日志、冗余库、冗余资源等文件,预计释放 %s 空间。\n你的模组、存档等游戏数据不会受到影响。是否继续操作?
game.crash.feedback=<b>请不要将本界面截图或拍照给他人!</b>如果你要向他人求助,请你点击左下角<b>“导出游戏崩溃信息”</b>后将导出的文件发送给他人以供分析。\n你可以点击下方的<b>“帮助”</b>前往交流群寻求帮助。
game.crash.info=游戏信息
game.crash.reason=崩溃原因
Expand Down
Loading