Skip to content

feat: 优化世界管理#5842

Open
Mine-diamond wants to merge 46 commits intoHMCL-dev:mainfrom
Mine-diamond:one-world-manage-page
Open

feat: 优化世界管理#5842
Mine-diamond wants to merge 46 commits intoHMCL-dev:mainfrom
Mine-diamond:one-world-manage-page

Conversation

@Mine-diamond
Copy link
Copy Markdown
Contributor

@Mine-diamond Mine-diamond commented Mar 24, 2026

继续 #5215 对世界管理界面进行的优化第三期


用户可感知的更新

  • 现在支持拖动文件夹安装世界。
  • 现在安装/重命名/复制世界可以使用任意名字,或者已经存在的名字,会模拟minecraft的行为,该名字将会作为level.dat中的名字,文件夹名称将剔除非法字符或添加后缀。
  • 现在备份可以被还原了。
  • 现在备份页面新的备份在上
  • 现在快速启动后世界管理页面将变为只读模式,而不是退出。
image image

复用页面:

  • 世界管理页面现在是单例的了,就像其它页面一样。
    • 使用Controllers.getWorldManagePage().setWorld(World world, Profile profile, String instanceId)来获取和设置页面
  • 世界管理页面会在不同世界时复用。
  • 相应的,世界信息页面,世界备份页面,数据包管理页面也经过了相应重构。

世界锁机制:

  • 现在World类本身持有锁,而不是在其它类中。
  • 原因是之前World类本身都不知道当前的锁是不是被自己锁定的,导致执行各种操作时需要协调WorldManagePage各种先释放锁再执行最后加锁,尽管某些操作根本不需要释放锁,现在World类本身知道自己是否持有锁,因此可以不再需要操作锁或者可以到内聚到World类中,简化了状态管理。
  • 虽然这可能导致无法完成一些情况比如一个持有锁的类不希望此时执行其它操作,但是这种情况应该是非常少见的。
  • 这修复了无法在世界管理页面复制世界的bug。

ImportableWorld类:

  • 添加了ImportableWorld类,用来表示在外部,即将被安装的世界。可以是压缩包或文件夹。
  • 现在World类仅表示在世界文件夹,以文件夹格式存储的世界。
  • 现在支持安装文件夹格式的世界了。
  • 现在安装世界对于文件名称不合法的名称会模拟minecraft的行为转换为合法的名称,而不是显示错误。

世界备份页面:

  • 添加了还原备份功能。
  • 现在备份以降序排列,即更新的在上。

重命名/复制世界:

  • 优化了重命名世界功能,现在不仅能修改level.dat中的名称,也能修改文件夹名称。
  • 重命名/复制世界功能输入对于文件名称不合法的名称会模拟minecraft的行为转换为合法的名称,而不是显示错误。
  • 重命名/复制弹窗现在会默认填充世界名称

其它:

  • 快速启动不在会退出WorldManagePage页面,而是变为只读模式。

@Mine-diamond Mine-diamond mentioned this pull request Mar 24, 2026
@Mine-diamond Mine-diamond changed the title feat: 优化世界管理页面 feat: 优化世界管理 Mar 24, 2026
@Mine-diamond Mine-diamond marked this pull request as ready for review March 25, 2026 12:24
@Glavo Glavo requested a review from Copilot March 25, 2026 12:35
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR continues the world-management UI refactor (following #5215) by making WorldManagePage reusable/singleton-like, moving the world lock responsibility into World, and introducing ArchiveWorld to represent zipped worlds/backups.

Changes:

  • Refactors world management pages to be reusable across worlds and adds restore-backup support in the backups UI.
  • Moves session.lock handling into World via an embedded WorldLock and updates callers accordingly.
  • Introduces ArchiveWorld for zip-based worlds; World now represents directory-based worlds only.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 14 comments.

Show a summary per file
File Description
HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java Adds getSafeWorldFolderName helper for Minecraft-like folder name sanitization.
HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java Renames quick-play feature check to supportsQuickPlay.
HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java Removes zip-world parsing, adds WorldLock, updates rename/copy/delete/export flows.
HMCL/src/main/resources/assets/lang/I18N.properties Adds datapack-not-supported and world-restore/rename strings (EN).
HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties Adds datapack-not-supported and world-restore/rename strings (zh_CN).
HMCL/src/main/resources/assets/lang/I18N_zh.properties Adds datapack-not-supported and world-restore/rename strings (zh).
HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldRestoreTask.java New task to restore a world from a backup zip.
HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java Refactors world operations; adds rename dialog flow.
HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java Makes page reusable via setWorld(...); updates lock/read-only/quick-play logic.
HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java Navigates to singleton world management page; adds rename in popup menu; uses ArchiveWorld for installs.
HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java Reworks read-only binding + integrates rename flow from world info.
HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java Uses ArchiveWorld, sorts backups descending, adds restore action.
HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupTask.java Uses new WorldLock.Guard instead of direct channel locking.
HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPageSkin.java Refactors list binding/disabled states; wires failedReason to UI.
HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPage.java Rebuilds datapack model on refresh; shows “not supported” message when applicable.
HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ArchiveWorld.java New representation of zip-based worlds and install logic.
HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java Adds lazy singleton accessor for WorldManagePage.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +45 to +48
// Check if the world format is correct
new ArchiveWorld(backupZipPath);
try {
new Unzipper(backupZipPath, tempPath).setSubDirectory(world.getFileName()).unzip();
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

Restore unzips using setSubDirectory(world.getFileName()), which assumes the backup zip’s root folder matches the current world folder name. If the world was renamed (folder renamed) after the backup was created, the backup root directory will differ and restore will fail with "Subdirectory ... does not exist". Consider parsing the backup via ArchiveWorld and using its root directory name (getFileName() / hasSubDir()) for setSubDirectory.

Suggested change
// Check if the world format is correct
new ArchiveWorld(backupZipPath);
try {
new Unzipper(backupZipPath, tempPath).setSubDirectory(world.getFileName()).unzip();
// Check if the world format is correct and determine archive root directory
ArchiveWorld archiveWorld = new ArchiveWorld(backupZipPath);
try {
Unzipper unzipper = new Unzipper(backupZipPath, tempPath);
if (archiveWorld.hasSubDir()) {
unzipper.setSubDirectory(archiveWorld.getFileName());
}
unzipper.unzip();

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

如果不匹配的话根本就检测不到,或者说就不是其备份

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 24 out of 24 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link
Copy Markdown
Contributor

@3gf8jv4dv 3gf8jv4dv left a comment

Choose a reason for hiding this comment

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

建议统一一下「存档」和「世界」的用法。

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 24 out of 24 changed files in this pull request and generated 6 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +69 to +73
Controllers.dialog(new InputDialogPane(i18n("world.duplicate.prompt"), world.getWorldName(), (newWorldName, handler) -> {
if (StringUtils.isBlank(newWorldName)) {
newWorldName = i18n("world.name.default");
}
String finalNewWorldName = newWorldName;
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

copyWorld reassigns the lambda parameter newWorldName (line 71). Lambda parameters are effectively final in Java, so this won’t compile. Use a separate local variable (e.g., String name = ...) before computing finalNewWorldName.

Copilot uses AI. Check for mistakes.
String finalNewWorldName = StringUtils.isBlank(newWorldName) ? i18n("world.name.default") : newWorldName;
boolean renameFolder = ((PromptDialogPane.Builder.BooleanQuestion) res.get(1)).getValue();

if (finalNewWorldName.equals(world.getWorldName())) {
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

The early-return if (finalNewWorldName.equals(world.getWorldName())) { ... return; } prevents renaming the folder when the user checks “重命名世界文件夹” but keeps the same displayed world name. Consider only skipping when both the name is unchanged and renameFolder is false, or separately handling “rename folder only”.

Suggested change
if (finalNewWorldName.equals(world.getWorldName())) {
if (finalNewWorldName.equals(world.getWorldName()) && !renameFolder) {

Copilot uses AI. Check for mistakes.
Comment on lines +59 to +62
private final BooleanProperty currentWorldSupportQuickPlay = new SimpleBooleanProperty(false);
private final BooleanProperty currentWorldSupportDataPack = new SimpleBooleanProperty(false);
private final BooleanProperty currentWorldSupportChunkBase = new SimpleBooleanProperty(false);
private final BooleanProperty currentWorldSupportEndCity = new SimpleBooleanProperty(false);
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

currentWorldSupportDataPack is set in setWorld(...) but never read anywhere in this class/skin. Either remove it or bind it to something (e.g., control the visibility/managed state of the datapack tab entry) to avoid dead state that can drift out of sync.

Copilot uses AI. Check for mistakes.
}
toolbar.addNavigationDrawerItem(i18n("version.launch"), SVG.ROCKET_LAUNCH, () -> getSkinnable().launch(), advancedListItem -> {
advancedListItem.disableProperty().bind(getSkinnable().readOnlyProperty());
advancedListItem.visibleProperty().bind(getSkinnable().currentWorldSupportQuickPlay);
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

The launch drawer item binds visibleProperty() but not managedProperty(). In JavaFX layouts, an invisible-but-managed node can still reserve space, which may leave a gap when quick play isn’t supported. Bind managedProperty() to the same condition as visibleProperty() (similar to how other menu items handle this below).

Suggested change
advancedListItem.visibleProperty().bind(getSkinnable().currentWorldSupportQuickPlay);
advancedListItem.visibleProperty().bind(getSkinnable().currentWorldSupportQuickPlay);
advancedListItem.managedProperty().bind(getSkinnable().currentWorldSupportQuickPlay);

Copilot uses AI. Check for mistakes.
Comment on lines +64 to +66
List<Path> files = Files.list(fs.getPath("/")).toList();
if (files.size() != 1 || !Files.isDirectory(files.get(0))) {
throw new IOException("Not a valid world zip file");
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

Files.list(fs.getPath("/")) returns a Stream that should be closed; here it’s consumed via toList() without try-with-resources. This can leak directory handles inside the zip filesystem. Wrap the Files.list(...) call in a try-with-resources and collect within it.

Copilot uses AI. Check for mistakes.
}
checkAndLoadLevelData(levelDatPath);
} else {
throw new IOException("Path " + sourcePath + " cannot be recognized as a archive Minecraft world");
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

Exception message grammar: "a archive Minecraft world" should be "an archive Minecraft world" (or rephrase to "a Minecraft world archive"). This message may surface in logs/UI, so it’s worth correcting.

Suggested change
throw new IOException("Path " + sourcePath + " cannot be recognized as a archive Minecraft world");
throw new IOException("Path " + sourcePath + " cannot be recognized as an archive Minecraft world");

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants