From fe25209860ec144a5b716e8814a6d61358179dfb Mon Sep 17 00:00:00 2001 From: extio-de Date: Sun, 27 Sep 2026 10:11:58 +0200 Subject: [PATCH] feat(FileSelectionDialogModule): add move-to-folder dialog, recency sort and lastModified tracking --- .../storage/StorageItemDescriptor.java | 6 +- .../StorageServiceAutoConfiguration.java | 13 +- .../storage/StorageServiceImpl.java | 35 +- .../dialog/FileSelectionDialogModule.java | 73 ++- .../dialog/FileSelectionMoveDialogModule.java | 438 ++++++++++++++++++ 5 files changed, 549 insertions(+), 16 deletions(-) create mode 100644 src/main/java/de/extio/game_engine/storage/dialog/FileSelectionMoveDialogModule.java diff --git a/src/main/java/de/extio/game_engine/storage/StorageItemDescriptor.java b/src/main/java/de/extio/game_engine/storage/StorageItemDescriptor.java index 2341219..b6f251c 100644 --- a/src/main/java/de/extio/game_engine/storage/StorageItemDescriptor.java +++ b/src/main/java/de/extio/game_engine/storage/StorageItemDescriptor.java @@ -3,6 +3,10 @@ package de.extio.game_engine.storage; import java.util.List; import java.util.UUID; -public record StorageItemDescriptor(UUID id, String name, List path) { +public record StorageItemDescriptor(UUID id, String name, List path, Long lastModified) { + + public StorageItemDescriptor(final UUID id, final String name, final List path) { + this(id, name, path, null); + } } diff --git a/src/main/java/de/extio/game_engine/storage/StorageServiceAutoConfiguration.java b/src/main/java/de/extio/game_engine/storage/StorageServiceAutoConfiguration.java index 925f030..f4b4af6 100644 --- a/src/main/java/de/extio/game_engine/storage/StorageServiceAutoConfiguration.java +++ b/src/main/java/de/extio/game_engine/storage/StorageServiceAutoConfiguration.java @@ -10,6 +10,7 @@ import de.extio.game_engine.event.EventService; import de.extio.game_engine.i18n.LocalizationService; import de.extio.game_engine.renderer.work.RenderingBoPool; import de.extio.game_engine.storage.dialog.FileSelectionDialogModule; +import de.extio.game_engine.storage.dialog.FileSelectionMoveDialogModule; @AutoConfiguration @ConditionalOnProperty(name = "game-engine.storage.enabled", havingValue = "true", matchIfMissing = true) @@ -25,7 +26,15 @@ public class StorageServiceAutoConfiguration { @ConditionalOnMissingBean @ConditionalOnProperty(name = "game-engine.storage.dialog.enabled", havingValue = "true", matchIfMissing = true) @ConditionalOnProperty(name = "game-engine.renderer.enabled", havingValue = "true", matchIfMissing = true) - FileSelectionDialogModule fileSelectionDialog(final ApplicationContext applicationContext, final EventService eventService, final LocalizationService localizationService, final RenderingBoPool renderingBoPool, final StorageService storageService) { - return new FileSelectionDialogModule(applicationContext, eventService, localizationService, renderingBoPool, storageService); + FileSelectionMoveDialogModule fileSelectionMoveDialog(final ApplicationContext applicationContext, final EventService eventService, final LocalizationService localizationService, final RenderingBoPool renderingBoPool, final StorageService storageService) { + return new FileSelectionMoveDialogModule(applicationContext, eventService, localizationService, renderingBoPool, storageService); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(name = "game-engine.storage.dialog.enabled", havingValue = "true", matchIfMissing = true) + @ConditionalOnProperty(name = "game-engine.renderer.enabled", havingValue = "true", matchIfMissing = true) + FileSelectionDialogModule fileSelectionDialog(final ApplicationContext applicationContext, final EventService eventService, final LocalizationService localizationService, final RenderingBoPool renderingBoPool, final StorageService storageService, final FileSelectionMoveDialogModule fileSelectionMoveDialog) { + return new FileSelectionDialogModule(applicationContext, eventService, localizationService, renderingBoPool, storageService, fileSelectionMoveDialog); } } diff --git a/src/main/java/de/extio/game_engine/storage/StorageServiceImpl.java b/src/main/java/de/extio/game_engine/storage/StorageServiceImpl.java index 7e15d6a..ddadcc3 100644 --- a/src/main/java/de/extio/game_engine/storage/StorageServiceImpl.java +++ b/src/main/java/de/extio/game_engine/storage/StorageServiceImpl.java @@ -7,6 +7,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; +import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -129,6 +130,30 @@ public class StorageServiceImpl implements StorageService { return DATA_LOCATION.resolve(id.toString()); } + private StorageItemDescriptor withLastModified(final StorageItemDescriptor descriptor) { + if (descriptor == null) { + return null; + } + try { + final var lastModified = Files.getLastModifiedTime(this.getFilePath(descriptor.id())).toMillis(); + return new StorageItemDescriptor(descriptor.id(), descriptor.name(), descriptor.path(), lastModified); + } + catch (final IOException e) { + return descriptor; + } + } + + private List withLastModified(final List descriptors) { + if (descriptors == null) { + return null; + } + final var result = new ArrayList(descriptors.size()); + for (final var descriptor : descriptors) { + result.add(this.withLastModified(descriptor)); + } + return result; + } + private void addToIndexAndSave(final UUID id, final String name, final List path) { final StorageItemDescriptor descriptor = new StorageItemDescriptor(id, name, path == null ? List.of() : path); this.index.add(descriptor); @@ -383,29 +408,29 @@ public class StorageServiceImpl implements StorageService { @Override public Optional searchById(final UUID id) { Objects.requireNonNull(id, "id"); - return this.index.getById(id); + return this.index.getById(id).map(this::withLastModified); } @Override public Optional searchByPath(final List path, final String name) { Objects.requireNonNull(name, "name"); - return this.index.find(path, name); + return this.index.find(path, name).map(this::withLastModified); } @Override public List searchByPattern(final List basePath, final String pattern, final boolean recursive) { Objects.requireNonNull(pattern, "pattern"); - return this.index.findByFilenamePattern(basePath, pattern, recursive); + return this.withLastModified(this.index.findByFilenamePattern(basePath, pattern, recursive)); } @Override public List listPath(final List path, final boolean recursive) { - return this.index.list(path, recursive); + return this.withLastModified(this.index.list(path, recursive)); } @Override public List listAll() { - return this.index.listAll(); + return this.withLastModified(this.index.listAll()); } } diff --git a/src/main/java/de/extio/game_engine/storage/dialog/FileSelectionDialogModule.java b/src/main/java/de/extio/game_engine/storage/dialog/FileSelectionDialogModule.java index 360f857..55344c4 100644 --- a/src/main/java/de/extio/game_engine/storage/dialog/FileSelectionDialogModule.java +++ b/src/main/java/de/extio/game_engine/storage/dialog/FileSelectionDialogModule.java @@ -37,6 +37,7 @@ import de.extio.game_engine.spatial2.model.ImmutableCoordI2; import de.extio.game_engine.storage.StorageItemDescriptor; import de.extio.game_engine.storage.StorageResource; import de.extio.game_engine.storage.StorageService; +import de.extio.game_engine.storage.dialog.FileSelectionMoveDialogModule.FileSelectionMoveDialogResponse; public class FileSelectionDialogModule extends AbstractClientModule { @@ -91,6 +92,8 @@ public class FileSelectionDialogModule extends AbstractClientModule { private static final String BUTTON_DELETE = "FileSelectionDialog_Button_Delete"; + private static final String BUTTON_MOVE = "FileSelectionDialog_Button_Move"; + private static final String BUTTON_OK = "FileSelectionDialog_Button_Ok"; private static final String BUTTON_CANCEL = "FileSelectionDialog_Button_Cancel"; @@ -105,12 +108,15 @@ public class FileSelectionDialogModule extends AbstractClientModule { private final StorageService storageService; - public FileSelectionDialogModule(final ApplicationContext applicationContext, final EventService eventService, final LocalizationService localizationService, final RenderingBoPool renderingBoPool, final StorageService storageService) { + private final FileSelectionMoveDialogModule moveDialogModule; + + public FileSelectionDialogModule(final ApplicationContext applicationContext, final EventService eventService, final LocalizationService localizationService, final RenderingBoPool renderingBoPool, final StorageService storageService, final FileSelectionMoveDialogModule moveDialogModule) { this.applicationContext = applicationContext; this.eventService = eventService; this.localizationService = localizationService; this.renderingBoPool = renderingBoPool; this.storageService = storageService; + this.moveDialogModule = moveDialogModule; } private Window dialogWindow; @@ -182,6 +188,7 @@ public class FileSelectionDialogModule extends AbstractClientModule { @Override public void onShow() { this.eventService.register(UiControlEvent.class, this.getId(), this::onUiControlEvent); + this.eventService.register(FileSelectionMoveDialogResponse.class, this.getId(), this::onMoveResponse); this.getModuleService().changeDisplayState(this.dialogWindow.getId(), true); if (this.modal) { this.getModuleService().hideExcept(this.getId(), this.dialogWindow.getId()); @@ -191,6 +198,7 @@ public class FileSelectionDialogModule extends AbstractClientModule { @Override public void onHide() { this.eventService.unregister(UiControlEvent.class, this.getId()); + this.eventService.unregister(FileSelectionMoveDialogResponse.class, this.getId()); this.getModuleService().changeDisplayState(this.dialogWindow.getId(), false); if (this.modal) { this.getModuleService().restoreVisibility(); @@ -257,6 +265,7 @@ public class FileSelectionDialogModule extends AbstractClientModule { case BUTTON_NEW_ITEM -> this.selectNewItem(); case BUTTON_RENAME -> this.renameSelection(); case BUTTON_DELETE -> this.deleteSelection(); + case BUTTON_MOVE -> this.openMoveDialog(); case BUTTON_OK -> this.onOk(); case BUTTON_CANCEL -> this.onCancel(); default -> { @@ -346,7 +355,7 @@ public class FileSelectionDialogModule extends AbstractClientModule { } yOffset += FIELD_HEIGHT + SPACING; - final boolean showFolderField = this.recursive && this.mode != FileSelectionMode.LOAD; + final boolean showFolderField = this.recursive; if (showFolderField) { final var folderLabel = this.renderingBoPool.acquire("FileSelectionDialog_Label_NewFolder", ControlRenderingBo.class) .setType(LabelControl.class) @@ -379,7 +388,7 @@ public class FileSelectionDialogModule extends AbstractClientModule { this.buildItemsList(contentWidth); var buttonX = Window.MARGIN_LEFT; - if (this.mode != FileSelectionMode.LOAD && this.recursive) { + if (this.recursive) { final var newFolderButton = this.renderingBoPool.acquire(BUTTON_NEW_FOLDER, ControlRenderingBo.class) .setType(ButtonControl.class) .setCaption(this.localizationService.translate("ecyoa-190")) @@ -413,6 +422,17 @@ public class FileSelectionDialogModule extends AbstractClientModule { this.dialogWindow.putRenderingBo(deleteButton); buttonX += BUTTON_WIDTH + SPACING; + final var moveButton = this.renderingBoPool.acquire(BUTTON_MOVE, ControlRenderingBo.class) + .setType(ButtonControl.class) + .setCaption(this.localizationService.translate("ecyoa-644")) + .setVisible(true) + .setEnabled(this.selectedDescriptor != null) + .withDimensionAbsolute(BUTTON_WIDTH, BUTTON_HEIGHT) + .withPositionRelative(buttonX, bottomRowY) + .setLayer(RenderingBoLayer.UI1); + this.dialogWindow.putRenderingBo(moveButton); + buttonX += BUTTON_WIDTH + SPACING; + final var okButton = this.renderingBoPool.acquire(BUTTON_OK, ControlRenderingBo.class) .setType(ButtonControl.class) .setCaption(this.localizationService.translate("ecyoa-3")) @@ -464,7 +484,7 @@ public class FileSelectionDialogModule extends AbstractClientModule { } final var sortedFolders = folderNames.stream().sorted(String::compareToIgnoreCase).toList(); - currentItems.sort(Comparator.comparing(StorageItemDescriptor::name, String.CASE_INSENSITIVE_ORDER)); + currentItems.sort(this.timestampThenNameComparator()); final var entries = new ArrayList(); if (this.recursive && this.currentPath.size() > this.basePath.size()) { @@ -547,7 +567,24 @@ public class FileSelectionDialogModule extends AbstractClientModule { } return this.storageService.listPath(queryPath, this.recursive); } - + + private Comparator timestampThenNameComparator() { + return (a, b) -> { + final var timestampA = a.lastModified(); + final var timestampB = b.lastModified(); + if (timestampA != null && timestampB != null && !timestampA.equals(timestampB)) { + return Long.compare(timestampB, timestampA); + } + if (timestampA != null) { + return -1; + } + if (timestampB != null) { + return 1; + } + return String.CASE_INSENSITIVE_ORDER.compare(a.name(), b.name()); + }; + } + private void createFolder() { if (!this.canCreateFolder()) { return; @@ -594,7 +631,25 @@ public class FileSelectionDialogModule extends AbstractClientModule { this.nameFieldValue = ""; this.buildDialog(); } - + + private void openMoveDialog() { + if (this.selectedDescriptor == null) { + return; + } + this.moveDialogModule.open(this.selectedDescriptor, this.basePath, this.dialogWindow); + } + + private void onMoveResponse(final FileSelectionMoveDialogResponse event) { + if (event == null || !event.confirmed() || this.requestId == null || this.selectedDescriptor == null || event.targetPath() == null) { + return; + } + this.storageService.moveById(this.selectedDescriptor.id(), event.targetPath(), this.selectedDescriptor.name()); + this.selectedDescriptor = null; + this.selectionIsNew = false; + this.nameFieldValue = ""; + this.buildDialog(); + } + private void onOk() { if (!this.canConfirm()) { return; @@ -609,7 +664,9 @@ public class FileSelectionDialogModule extends AbstractClientModule { } private void onCancel() { - this.eventService.fire(new FileSelectionDialogResponse(this.requestId, false, this.mode, null, null, null, null, false)); + if (this.requestId != null) { + this.eventService.fire(new FileSelectionDialogResponse(this.requestId, false, this.mode, null, null, null, null, false)); + } this.close(); } @@ -642,7 +699,7 @@ public class FileSelectionDialogModule extends AbstractClientModule { } private boolean canCreateFolder() { - if (!this.recursive || this.mode == FileSelectionMode.LOAD) { + if (!this.recursive) { return false; } if (this.newFolderValue == null || this.newFolderValue.isBlank()) { diff --git a/src/main/java/de/extio/game_engine/storage/dialog/FileSelectionMoveDialogModule.java b/src/main/java/de/extio/game_engine/storage/dialog/FileSelectionMoveDialogModule.java new file mode 100644 index 0000000..ae153c5 --- /dev/null +++ b/src/main/java/de/extio/game_engine/storage/dialog/FileSelectionMoveDialogModule.java @@ -0,0 +1,438 @@ +package de.extio.game_engine.storage.dialog; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import org.springframework.context.ApplicationContext; + +import de.extio.game_engine.event.Event; +import de.extio.game_engine.event.EventService; +import de.extio.game_engine.i18n.LocalizationService; +import de.extio.game_engine.module.AbstractClientModule; +import de.extio.game_engine.renderer.RendererControl; +import de.extio.game_engine.renderer.container.ScrollArea; +import de.extio.game_engine.renderer.container.Window; +import de.extio.game_engine.renderer.model.RenderingBoLayer; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.ButtonControl; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.LabelControl; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.LabelData; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.TextfieldControl; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.TextfieldData; +import de.extio.game_engine.renderer.model.bo.DrawFontRenderingBo; +import de.extio.game_engine.renderer.model.bo.HorizontalAlignment; +import de.extio.game_engine.renderer.model.event.UiControlEvent; +import de.extio.game_engine.renderer.work.RenderingBoPool; +import de.extio.game_engine.spatial2.model.Area2; +import de.extio.game_engine.spatial2.model.CoordI2; +import de.extio.game_engine.spatial2.model.ImmutableCoordI2; +import de.extio.game_engine.storage.StorageItemDescriptor; +import de.extio.game_engine.storage.StorageService; + +public class FileSelectionMoveDialogModule extends AbstractClientModule { + + public record FileSelectionMoveDialogResponse( + boolean confirmed, + List targetPath) implements Event { + } + + private static final int WINDOW_WIDTH = 700; + + private static final int WINDOW_HEIGHT = 620; + + private static final int TITLE_HEIGHT = 46; + + private static final int LABEL_HEIGHT = 26; + + private static final int FIELD_HEIGHT = 36; + + private static final int BUTTON_HEIGHT = 42; + + private static final int BUTTON_WIDTH = 150; + + private static final int SPACING = 12; + + private static final int LIST_PADDING = 8; + + private static final int ROW_SPACING = 6; + + private static final String LOC_TITLE = "ecyoa-645"; + + private static final String FOLDER_PREFIX = "FileSelectionMoveDialog_Folder_"; + + private static final String NAME_FIELD_ID = "FileSelectionMoveDialog_NameField"; + + private static final String BUTTON_MOVE = "FileSelectionMoveDialog_Button_Move"; + + private static final String BUTTON_CANCEL = "FileSelectionMoveDialog_Button_Cancel"; + + private final ApplicationContext applicationContext; + + private final EventService eventService; + + private final LocalizationService localizationService; + + private final RenderingBoPool renderingBoPool; + + private final StorageService storageService; + + public FileSelectionMoveDialogModule(final ApplicationContext applicationContext, final EventService eventService, final LocalizationService localizationService, final RenderingBoPool renderingBoPool, final StorageService storageService) { + this.applicationContext = applicationContext; + this.eventService = eventService; + this.localizationService = localizationService; + this.renderingBoPool = renderingBoPool; + this.storageService = storageService; + } + + private Window moveWindow; + + private ScrollArea folderScrollArea; + + private StorageItemDescriptor descriptor; + + private List basePath = List.of(); + + private List currentPath = List.of(); + + private String nameFieldValue = ""; + + private final Map> folderPathByControlId = new HashMap<>(); + + @Override + public boolean isProtected() { + return true; + } + + @Override + public void onLoad() { + this.moveWindow = this.applicationContext.getBean(Window.class); + this.moveWindow.setNormalizedDimension(ImmutableCoordI2.create(WINDOW_WIDTH, WINDOW_HEIGHT)); + this.moveWindow.setNormalizedPosition(this.centeredPosition(this.moveWindow.getNormalizedDimension())); + this.moveWindow.setDraggable(true); + this.moveWindow.setCloseButton(true); + this.moveWindow.setOnCloseAction(this::onCancel); + + this.folderScrollArea = this.applicationContext.getBean(ScrollArea.class); + this.moveWindow.addComponent(this.folderScrollArea); + + this.getModuleService().changeActiveState(this.getId(), true); + } + + @Override + public void onUnload() { + this.getModuleService().unloadModule(this.moveWindow.getId()); + } + + @Override + public void onShow() { + this.eventService.register(UiControlEvent.class, this.getId(), this::onUiControlEvent); + this.getModuleService().changeDisplayState(this.moveWindow.getId(), true); + } + + @Override + public void onHide() { + this.eventService.unregister(UiControlEvent.class, this.getId()); + this.getModuleService().changeDisplayState(this.moveWindow.getId(), false); + this.resetState(); + } + + public void open(final StorageItemDescriptor descriptor, final List basePath, final Window parentWindow) { + this.descriptor = descriptor; + this.basePath = basePath != null ? List.copyOf(basePath) : List.of(); + this.currentPath = this.basePath; + this.nameFieldValue = ""; + this.folderPathByControlId.clear(); + this.moveWindow.setParent(parentWindow); + this.buildDialog(); + this.getModuleService().changeDisplayState(this.getId(), true); + } + + private void onUiControlEvent(final UiControlEvent event) { + if (event.getId() == null) { + return; + } + if (event.getId().startsWith(FOLDER_PREFIX)) { + final var targetPath = this.folderPathByControlId.get(event.getId()); + if (targetPath != null) { + this.currentPath = targetPath; + this.buildDialog(); + } + return; + } + switch (event.getId()) { + case NAME_FIELD_ID -> { + if (event.getPayload() instanceof final String text) { + final var value = text != null ? text : ""; + if (!Objects.equals(this.nameFieldValue, value)) { + this.nameFieldValue = value; + this.buildDialog(); + } + } + } + case BUTTON_MOVE -> this.onMove(); + case BUTTON_CANCEL -> this.onCancel(); + default -> { + } + } + } + + private void buildDialog() { + this.moveWindow.clearRenderingBos(); + this.clearScrollArea(); + this.folderPathByControlId.clear(); + + final var contentWidth = WINDOW_WIDTH - Window.MARGIN_LEFT - Window.MARGIN_RIGHT; + int yOffset = Window.MARGIN_TOP; + + final var titleBo = this.renderingBoPool.acquire("FileSelectionMoveDialog_Title", DrawFontRenderingBo.class) + .setText(this.localizationService.translate(LOC_TITLE)) + .setSize(28) + .setAlignment(HorizontalAlignment.CENTER) + .withDimensionAbsolute(contentWidth, TITLE_HEIGHT) + .withPositionRelative(Window.MARGIN_LEFT, yOffset); + this.moveWindow.putRenderingBo(titleBo); + yOffset += TITLE_HEIGHT + SPACING; + + final var pathLabel = this.renderingBoPool.acquire("FileSelectionMoveDialog_Label_Path", ControlRenderingBo.class) + .setType(LabelControl.class) + .setCaption(this.localizationService.translate("ecyoa-188") + ": " + this.formatPath(this.currentPath)) + .setFontSize(18) + .setVisible(true) + .setEnabled(false) + .withDimensionAbsolute(contentWidth, LABEL_HEIGHT) + .withPositionRelative(Window.MARGIN_LEFT, yOffset) + .setLayer(RenderingBoLayer.UI0); + this.moveWindow.putRenderingBo(pathLabel); + yOffset += LABEL_HEIGHT + SPACING / 2; + + final var buttonRowY = WINDOW_HEIGHT - Window.MARGIN_BOTTOM - BUTTON_HEIGHT; + final var nameFieldY = buttonRowY - SPACING - FIELD_HEIGHT; + final var nameLabelY = nameFieldY - SPACING / 2 - LABEL_HEIGHT; + final var scrollHeight = Math.max(120, nameLabelY - SPACING - yOffset); + this.folderScrollArea.setRelativeArea(new Area2(ImmutableCoordI2.create(Window.MARGIN_LEFT, yOffset), ImmutableCoordI2.create(contentWidth, scrollHeight))); + this.buildFolderList(contentWidth); + + final var nameLabel = this.renderingBoPool.acquire("FileSelectionMoveDialog_Label_Name", ControlRenderingBo.class) + .setType(LabelControl.class) + .setCaption(this.localizationService.translate("ecyoa-646") + ":") + .setFontSize(18) + .setVisible(true) + .setEnabled(false) + .withDimensionAbsolute(contentWidth, LABEL_HEIGHT) + .withPositionRelative(Window.MARGIN_LEFT, nameLabelY) + .setLayer(RenderingBoLayer.UI0); + this.moveWindow.putRenderingBo(nameLabel); + + final var nameField = this.renderingBoPool.acquire(NAME_FIELD_ID, ControlRenderingBo.class) + .setType(TextfieldControl.class) + .setCaption(this.nameFieldValue != null ? this.nameFieldValue : "") + .setControlData(new TextfieldData(false, null)) + .setVisible(true) + .setEnabled(true) + .withDimensionAbsolute(contentWidth, FIELD_HEIGHT) + .withPositionRelative(Window.MARGIN_LEFT, nameFieldY) + .setLayer(RenderingBoLayer.UI1); + this.moveWindow.putRenderingBo(nameField); + + final var moveButton = this.renderingBoPool.acquire(BUTTON_MOVE, ControlRenderingBo.class) + .setType(ButtonControl.class) + .setCaption(this.localizationService.translate("ecyoa-644")) + .setVisible(true) + .setEnabled(this.canMove()) + .withDimensionAbsolute(BUTTON_WIDTH, BUTTON_HEIGHT) + .withPositionRelative(Window.MARGIN_LEFT, buttonRowY) + .setLayer(RenderingBoLayer.UI1); + this.moveWindow.putRenderingBo(moveButton); + + final var cancelButton = this.renderingBoPool.acquire(BUTTON_CANCEL, ControlRenderingBo.class) + .setType(ButtonControl.class) + .setCaption(this.localizationService.translate("ecyoa-89")) + .setVisible(true) + .setEnabled(true) + .withDimensionAbsolute(BUTTON_WIDTH, BUTTON_HEIGHT) + .withPositionRelative(WINDOW_WIDTH - Window.MARGIN_RIGHT - BUTTON_WIDTH, buttonRowY) + .setLayer(RenderingBoLayer.UI1); + this.moveWindow.putRenderingBo(cancelButton); + + this.folderScrollArea.draw(); + this.moveWindow.draw(); + } + + private void buildFolderList(final int contentWidth) { + final var descriptors = this.storageService.listPath(this.basePath, true); + final var folderNames = new HashSet(); + for (final var item : descriptors) { + if (item == null) { + continue; + } + final var path = this.safePath(item.path()); + if (this.isPathPrefix(this.currentPath, path) && path.size() > this.currentPath.size()) { + folderNames.add(path.get(this.currentPath.size())); + } + } + + final var leftPad = LIST_PADDING; + final var availableWidth = contentWidth - ScrollArea.SCROLLBAR_WIDTH_WITH_MARGIN - leftPad * 2; + + int yOffset = 0; + if (this.currentPath.size() > this.basePath.size()) { + final var upPath = List.copyOf(this.currentPath.subList(0, this.currentPath.size() - 1)); + final var controlId = FOLDER_PREFIX + "up_" + upPath.hashCode(); + this.folderPathByControlId.put(controlId, upPath); + final var upButton = this.renderingBoPool.acquire(controlId, ControlRenderingBo.class) + .setType(ButtonControl.class) + .setCaption("..") + .setTooltip(this.formatPath(upPath)) + .setVisible(true) + .setEnabled(true) + .withDimensionAbsolute(availableWidth, FIELD_HEIGHT) + .withPositionRelative(leftPad, yOffset) + .setLayer(RenderingBoLayer.UI1); + this.folderScrollArea.putRenderingBo(upButton); + yOffset += FIELD_HEIGHT + ROW_SPACING; + } + final var sortedFolders = folderNames.stream().sorted(String::compareToIgnoreCase).toList(); + for (int index = 0; index < sortedFolders.size(); index++) { + final var folderName = sortedFolders.get(index); + final var targetPath = this.appendPath(this.currentPath, folderName); + final var controlId = FOLDER_PREFIX + index + "_" + targetPath.hashCode(); + this.folderPathByControlId.put(controlId, targetPath); + final var folderButton = this.renderingBoPool.acquire(controlId, ControlRenderingBo.class) + .setType(ButtonControl.class) + .setCaption(folderName + "/") + .setTooltip(this.formatPath(targetPath)) + .setVisible(true) + .setEnabled(true) + .withDimensionAbsolute(availableWidth, FIELD_HEIGHT) + .withPositionRelative(leftPad, yOffset) + .setLayer(RenderingBoLayer.UI1); + this.folderScrollArea.putRenderingBo(folderButton); + yOffset += FIELD_HEIGHT + ROW_SPACING; + } + if (sortedFolders.isEmpty() && this.currentPath.size() <= this.basePath.size()) { + final var emptyLabel = this.renderingBoPool.acquire("FileSelectionMoveDialog_Label_Empty", ControlRenderingBo.class) + .setType(LabelControl.class) + .setCaption(this.localizationService.translate("ecyoa-193")) + .setControlData(new LabelData(null, null, HorizontalAlignment.CENTER)) + .setFontSize(18) + .setVisible(true) + .setEnabled(false) + .withDimensionAbsolute(availableWidth, FIELD_HEIGHT) + .withPositionRelative(leftPad, yOffset + 10) + .setLayer(RenderingBoLayer.UI0); + this.folderScrollArea.putRenderingBo(emptyLabel); + } + } + + private void onMove() { + if (this.descriptor == null || !this.canMove()) { + return; + } + this.eventService.fire(new FileSelectionMoveDialogResponse(true, this.resolveTargetPath())); + this.onClose(); + } + + private void onCancel() { + if (this.descriptor != null) { + this.eventService.fire(new FileSelectionMoveDialogResponse(false, null)); + this.onClose(); + } + } + + private void onClose() { + this.getModuleService().changeDisplayState(this.getId(), false); + } + + private void resetState() { + this.descriptor = null; + this.basePath = List.of(); + this.currentPath = List.of(); + this.nameFieldValue = ""; + this.folderPathByControlId.clear(); + this.clearScrollArea(); + } + + private void clearScrollArea() { + for (final var boId : this.folderScrollArea.getRenderingBoIds().toArray(new String[0])) { + this.folderScrollArea.removeRenderingBo(boId); + } + } + + private boolean canMove() { + if (this.descriptor == null) { + return false; + } + final var targetPath = this.resolveTargetPath(); + if (targetPath.equals(this.safePath(this.descriptor.path()))) { + return false; + } + return !this.targetContainsSameName(targetPath); + } + + private boolean targetContainsSameName(final List targetPath) { + final var name = this.descriptor != null ? this.descriptor.name() : null; + if (name == null || name.isBlank()) { + return false; + } + final var descriptors = this.storageService.listPath(this.basePath, true); + for (final var item : descriptors) { + if (item == null) { + continue; + } + if (this.safePath(item.path()).equals(targetPath) && name.equalsIgnoreCase(item.name())) { + return true; + } + } + return false; + } + + private List resolveTargetPath() { + final var name = this.nameFieldValue != null ? this.nameFieldValue.trim() : ""; + if (name.isEmpty()) { + return this.currentPath; + } + return this.appendPath(this.currentPath, name); + } + + private String formatPath(final List path) { + if (path == null || path.isEmpty()) { + return "/"; + } + return "/" + String.join("/", path); + } + + private List safePath(final List path) { + return path != null ? List.copyOf(path) : List.of(); + } + + private boolean isPathPrefix(final List prefix, final List candidate) { + if (prefix.size() > candidate.size()) { + return false; + } + for (int i = 0; i < prefix.size(); i++) { + if (!Objects.equals(prefix.get(i), candidate.get(i))) { + return false; + } + } + return true; + } + + private List appendPath(final List base, final String segment) { + final var result = new ArrayList(base.size() + 1); + result.addAll(base); + result.add(segment); + return List.copyOf(result); + } + + private ImmutableCoordI2 centeredPosition(final CoordI2 dimension) { + final var referenceResolution = RendererControl.REFERENCE_RESOLUTION; + final var x = (referenceResolution.getX() - dimension.getX()) / 2; + final var y = (referenceResolution.getY() - dimension.getY()) / 2; + return ImmutableCoordI2.create(x, y); + } + +}