From 68fe5ca0c6e7284c51113075131f087220465094 Mon Sep 17 00:00:00 2001 From: rdavidek Date: Mon, 19 Jan 2026 22:24:38 +0100 Subject: [PATCH] operation queue, symbolic link support --- .../kfmanager/service/FileOperationQueue.java | 162 +++++++++++ .../kfmanager/service/FileOperations.java | 275 ++++++++++++++---- .../cz/kamma/kfmanager/ui/MainWindow.java | 146 +++++++--- .../kfmanager/ui/OperationQueueDialog.java | 128 ++++++++ .../cz/kamma/kfmanager/ui/ProgressDialog.java | 4 +- 5 files changed, 607 insertions(+), 108 deletions(-) create mode 100644 src/main/java/cz/kamma/kfmanager/service/FileOperationQueue.java create mode 100644 src/main/java/cz/kamma/kfmanager/ui/OperationQueueDialog.java diff --git a/src/main/java/cz/kamma/kfmanager/service/FileOperationQueue.java b/src/main/java/cz/kamma/kfmanager/service/FileOperationQueue.java new file mode 100644 index 0000000..2677a48 --- /dev/null +++ b/src/main/java/cz/kamma/kfmanager/service/FileOperationQueue.java @@ -0,0 +1,162 @@ +package cz.kamma.kfmanager.service; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; + +public class FileOperationQueue { + public enum OperationStatus { + QUEUED, RUNNING, COMPLETED, FAILED, CANCELLED + } + + public static class QueuedTask { + private final String title; + private final String description; + private final OperationExecutor executor; + private volatile OperationStatus status = OperationStatus.QUEUED; + private volatile long currentProgress = 0; + private volatile long totalProgress = 0; + private volatile String currentFile = ""; + private volatile String errorMessage = ""; + + public QueuedTask(String title, String description, OperationExecutor executor) { + this.title = title; + this.description = description; + this.executor = executor; + } + + public String getTitle() { return title; } + public String getDescription() { return description; } + public OperationStatus getStatus() { return status; } + public long getCurrentProgress() { return currentProgress; } + public long getTotalProgress() { return totalProgress; } + public String getCurrentFile() { return currentFile; } + public String getErrorMessage() { return errorMessage; } + + public void cancel() { + if (status == OperationStatus.QUEUED || status == OperationStatus.RUNNING) { + status = OperationStatus.CANCELLED; + } + } + } + + @FunctionalInterface + public interface OperationExecutor { + void execute(FileOperations.ProgressCallback callback) throws Exception; + } + + private static FileOperationQueue instance; + private final List tasks = new CopyOnWriteArrayList<>(); + private final List>> listeners = new ArrayList<>(); + private boolean isRunning = false; + + private FileOperationQueue() {} + + public static synchronized FileOperationQueue getInstance() { + if (instance == null) { + instance = new FileOperationQueue(); + } + return instance; + } + + public void addTask(QueuedTask task) { + tasks.add(task); + notifyListeners(); + startNextTask(); + } + + public void addListener(Consumer> listener) { + listeners.add(listener); + } + + public void removeListener(Consumer> listener) { + listeners.remove(listener); + } + + private void notifyListeners() { + List currentTasks = new ArrayList<>(tasks); + for (Consumer> listener : listeners) { + listener.accept(currentTasks); + } + } + + private synchronized void startNextTask() { + if (isRunning) return; + + QueuedTask nextTask = null; + for (QueuedTask task : tasks) { + if (task.status == OperationStatus.QUEUED) { + nextTask = task; + break; + } + } + + if (nextTask != null) { + isRunning = true; + final QueuedTask taskToRun = nextTask; + new Thread(() -> { + runTask(taskToRun); + }).start(); + } + } + + private void runTask(QueuedTask task) { + task.status = OperationStatus.RUNNING; + notifyListeners(); + + try { + task.executor.execute(new FileOperations.ProgressCallback() { + @Override + public void onProgress(long current, long total, String currentFile) { + task.currentProgress = current; + task.totalProgress = total; + task.currentFile = currentFile; + notifyListeners(); + } + + @Override + public boolean isCancelled() { + return task.status == OperationStatus.CANCELLED; + } + + @Override + public FileOperations.OverwriteResponse confirmOverwrite(File file) { + // In background queue, we might want a default or auto-rename? + // For now, let's assume YES or handle it in the executor if possible. + // This is tricky for a background queue. + return FileOperations.OverwriteResponse.YES; + } + + @Override + public FileOperations.ErrorResponse onError(File file, Exception e) { + // For background queue, maybe skip on error? + return FileOperations.ErrorResponse.SKIP; + } + }); + + if (task.status != OperationStatus.CANCELLED) { + task.status = OperationStatus.COMPLETED; + task.currentProgress = task.totalProgress; // Force 100% on completion + } + } catch (Exception e) { + task.status = OperationStatus.FAILED; + task.errorMessage = e.getMessage(); + task.currentProgress = 0; + } + + notifyListeners(); + isRunning = false; + startNextTask(); + } + + public List getTasks() { + return new ArrayList<>(tasks); + } + + public void clearCompleted() { + tasks.removeIf(t -> t.status == OperationStatus.COMPLETED || t.status == OperationStatus.FAILED || t.status == OperationStatus.CANCELLED); + notifyListeners(); + } +} diff --git a/src/main/java/cz/kamma/kfmanager/service/FileOperations.java b/src/main/java/cz/kamma/kfmanager/service/FileOperations.java index b05c21d..8973fb9 100644 --- a/src/main/java/cz/kamma/kfmanager/service/FileOperations.java +++ b/src/main/java/cz/kamma/kfmanager/service/FileOperations.java @@ -5,6 +5,7 @@ import cz.kamma.kfmanager.model.FileItem; import java.io.*; import java.nio.file.*; import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -23,15 +24,21 @@ public class FileOperations { throw new IOException("Target directory does not exist"); } - long totalSize = calculateTotalSize(items); - final long[] currentCopied = {0}; + List cleanedItems = cleanDuplicateItems(items); + long totalItems = calculateTotalItems(cleanedItems); + final long[] currentItem = {0}; final OverwriteResponse[] globalResponse = {null}; - for (FileItem item : items) { + for (FileItem item : cleanedItems) { if (callback != null && callback.isCancelled()) break; File source = item.getFile(); File target = new File(targetDirectory, source.getName()); + // If target is redicrected to subfolder of source, skip to avoid infinite loop + if (isSubfolder(source, targetDirectory)) { + continue; + } + // If target is the same as source (copying to the same directory), rename target if (source.getAbsolutePath().equals(target.getAbsolutePath())) { target = new File(targetDirectory, "copy-of-" + source.getName()); @@ -47,14 +54,23 @@ public class FileOperations { if (res == OverwriteResponse.NO) continue; if (res == OverwriteResponse.YES_TO_ALL) globalResponse[0] = OverwriteResponse.YES_TO_ALL; } + + // Handle directory vs file clash + if (target.isDirectory() && !source.isDirectory()) { + deleteDirectoryInternal(target.toPath()); + } else if (!target.isDirectory() && source.isDirectory()) { + Files.delete(target.toPath()); + } } while (true) { try { - if (source.isDirectory()) { - copyDirectory(source.toPath(), target.toPath(), totalSize, currentCopied, callback, globalResponse); + if (Files.isSymbolicLink(source.toPath())) { + copySymlink(source.toPath(), target.toPath(), totalItems, currentItem, callback); + } else if (source.isDirectory()) { + copyDirectory(source.toPath(), target.toPath(), totalItems, currentItem, callback, globalResponse); } else { - copyFileWithProgress(source.toPath(), target.toPath(), totalSize, currentCopied, callback); + copyFileWithProgress(source.toPath(), target.toPath(), totalItems, currentItem, callback); } break; } catch (IOException e) { @@ -71,34 +87,37 @@ public class FileOperations { } } - private static long calculateTotalSize(List items) { + private static long calculateTotalItems(List items) { long total = 0; for (FileItem item : items) { - total += calculateSize(item.getFile().toPath()); + total += countItems(item.getFile().toPath()); } return total; } - private static long calculateSize(Path path) { - if (!Files.exists(path)) return 0; - if (!Files.isDirectory(path)) { - try { return Files.size(path); } catch (IOException e) { return 0; } - } - final long[] size = {0}; + private static long countItems(Path path) { + if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) return 0; + if (Files.isSymbolicLink(path)) return 1; + if (!Files.isDirectory(path)) return 1; + final long[] count = {1}; // Start with 1 for the directory itself try { Files.walkFileTree(path, new SimpleFileVisitor() { + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) { + if (!dir.equals(path)) count[0]++; + return FileVisitResult.CONTINUE; + } @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { - size[0] += attrs.size(); + count[0]++; return FileVisitResult.CONTINUE; } }); } catch (IOException ignore) {} - return size[0]; + return count[0]; } - private static void copyFileWithProgress(Path source, Path target, long totalSize, long[] totalCopied, ProgressCallback callback) throws IOException { - long initialTotalCopied = totalCopied[0]; + private static void copyFileWithProgress(Path source, Path target, long totalItems, long[] currentItem, ProgressCallback callback) throws IOException { try (InputStream in = Files.newInputStream(source); OutputStream out = Files.newOutputStream(target)) { byte[] buffer = new byte[24576]; @@ -106,26 +125,42 @@ public class FileOperations { while ((length = in.read(buffer)) > 0) { if (callback != null && callback.isCancelled()) return; out.write(buffer, 0, length); - totalCopied[0] += length; - if (callback != null) { - callback.onProgress(totalCopied[0], totalSize, source.getFileName().toString()); - } } - } catch (IOException e) { - totalCopied[0] = initialTotalCopied; - throw e; + } + currentItem[0]++; + if (callback != null) { + callback.onProgress(currentItem[0], totalItems, source.getFileName().toString()); } Files.setLastModifiedTime(target, Files.getLastModifiedTime(source)); } - private static void copyDirectory(Path source, Path target, long totalSize, final long[] totalCopied, ProgressCallback callback, final OverwriteResponse[] globalResponse) throws IOException { + private static void copySymlink(Path source, Path target, long totalItems, long[] currentItem, ProgressCallback callback) throws IOException { + Path linkTarget = Files.readSymbolicLink(source); + Files.deleteIfExists(target); + Files.createSymbolicLink(target, linkTarget); + currentItem[0]++; + if (callback != null) { + callback.onProgress(currentItem[0], totalItems, source.getFileName().toString()); + } + } + + private static void copyDirectory(Path source, Path target, long totalItems, final long[] currentItem, ProgressCallback callback, final OverwriteResponse[] globalResponse) throws IOException { Files.walkFileTree(source, new SimpleFileVisitor() { @Override public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { Path targetDir = target.resolve(source.relativize(dir)); + if (Files.exists(targetDir) && !Files.isDirectory(targetDir)) { + Files.delete(targetDir); + } while (true) { try { Files.createDirectories(targetDir); + if (!dir.equals(source)) { + currentItem[0]++; + if (callback != null) { + callback.onProgress(currentItem[0], totalItems, dir.getFileName().toString()); + } + } return FileVisitResult.CONTINUE; } catch (IOException e) { if (callback != null) { @@ -156,11 +191,19 @@ public class FileOperations { if (res == OverwriteResponse.NO) return FileVisitResult.CONTINUE; if (res == OverwriteResponse.YES_TO_ALL) globalResponse[0] = OverwriteResponse.YES_TO_ALL; } + // If we are here, we are overwriting. If target is a directory, delete it. + if (Files.isDirectory(targetFile)) { + deleteDirectoryInternal(targetFile); + } } while (true) { try { - copyFileWithProgress(file, targetFile, totalSize, totalCopied, callback); + if (Files.isSymbolicLink(file)) { + copySymlink(file, targetFile, totalItems, currentItem, callback); + } else { + copyFileWithProgress(file, targetFile, totalItems, currentItem, callback); + } return FileVisitResult.CONTINUE; } catch (IOException e) { if (callback != null) { @@ -175,6 +218,25 @@ public class FileOperations { } }); } + + private static void deleteDirectoryInternal(Path path) throws IOException { + if (Files.isSymbolicLink(path)) { + Files.delete(path); + return; + } + Files.walkFileTree(path, new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Files.delete(file); + return FileVisitResult.CONTINUE; + } + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { + Files.delete(dir); + return FileVisitResult.CONTINUE; + } + }); + } /** * Move files/directories to target directory @@ -184,17 +246,22 @@ public class FileOperations { throw new IOException("Target directory does not exist"); } - int current = 0; - int total = items.size(); + List cleanedItems = cleanDuplicateItems(items); + long totalItems = calculateTotalItems(cleanedItems); + long[] currentItem = {0}; final OverwriteResponse[] globalResponse = {null}; - for (FileItem item : items) { + for (FileItem item : cleanedItems) { if (callback != null && callback.isCancelled()) break; - current++; File source = item.getFile(); File target = new File(targetDirectory, source.getName()); - - if (target.exists() && !source.getAbsolutePath().equals(target.getAbsolutePath())) { + + if (isSubfolder(source, targetDirectory)) { + continue; + } + + // Use NOFOLLOW_LINKS for target existence check + if (Files.exists(target.toPath(), LinkOption.NOFOLLOW_LINKS) && !source.getAbsolutePath().equals(target.getAbsolutePath())) { if (globalResponse[0] == OverwriteResponse.NO_TO_ALL) continue; if (globalResponse[0] != OverwriteResponse.YES_TO_ALL) { OverwriteResponse res = callback.confirmOverwrite(target); @@ -206,10 +273,22 @@ public class FileOperations { if (res == OverwriteResponse.NO) continue; if (res == OverwriteResponse.YES_TO_ALL) globalResponse[0] = OverwriteResponse.YES_TO_ALL; } + + // Handle directory vs file clash + if (target.isDirectory() && !source.isDirectory()) { + deleteDirectoryInternal(target.toPath()); + } else if (!target.isDirectory() && source.isDirectory()) { + Files.delete(target.toPath()); + } } if (callback != null) { - callback.onProgress(current, total, source.getName()); + // For move, we report the start of moving the item. + // Note: if it's a directory, this counts as multiple items in totalItems, + // but Files.move will do it in one go. We increment currentItem by the actual count. + long itemCount = countItems(source.toPath()); + currentItem[0] += itemCount; + callback.onProgress(currentItem[0], totalItems, source.getName()); } while (true) { @@ -234,23 +313,29 @@ public class FileOperations { * Delete files/directories */ public static void delete(List items, ProgressCallback callback) throws IOException { - int current = 0; - int total = items.size(); + List cleanedItems = cleanDuplicateItems(items); + long totalItems = calculateTotalItems(cleanedItems); + long[] currentItem = {0}; - for (FileItem item : items) { + for (FileItem item : cleanedItems) { if (callback != null && callback.isCancelled()) break; - current++; File file = item.getFile(); - if (callback != null) { - callback.onProgress(current, total, file.getName()); - } - while (true) { try { - if (file.isDirectory()) { - deleteDirectory(file.toPath(), callback); + if (Files.isSymbolicLink(file.toPath())) { + currentItem[0]++; + if (callback != null) { + callback.onProgress(currentItem[0], totalItems, file.getName()); + } + Files.delete(file.toPath()); + } else if (file.isDirectory()) { + deleteDirectory(file.toPath(), totalItems, currentItem, callback); } else { + currentItem[0]++; + if (callback != null) { + callback.onProgress(currentItem[0], totalItems, file.getName()); + } Files.delete(file.toPath()); } break; @@ -268,6 +353,47 @@ public class FileOperations { } } + private static List cleanDuplicateItems(List items) { + if (items == null || items.size() <= 1) return items; + + List sorted = new ArrayList<>(items); + sorted.sort((a, b) -> a.getFile().getAbsolutePath().compareTo(b.getFile().getAbsolutePath())); + + List cleaned = new ArrayList<>(); + for (int i = 0; i < sorted.size(); i++) { + FileItem current = sorted.get(i); + boolean isDuplicate = false; + for (int j = 0; j < i; j++) { + File parent = sorted.get(j).getFile(); + if (isSubfolder(parent, current.getFile())) { + isDuplicate = true; + break; + } + } + if (!isDuplicate) { + cleaned.add(current); + } + } + return cleaned; + } + + private static boolean isSubfolder(File parent, File child) { + if (parent == null || child == null) return false; + try { + String parentPath = parent.getCanonicalPath(); + String childPath = child.getCanonicalPath(); + + if (parentPath.equals(childPath)) return true; + + if (!parentPath.endsWith(File.separator)) { + parentPath += File.separator; + } + return childPath.startsWith(parentPath); + } catch (IOException e) { + return child.getAbsolutePath().startsWith(parent.getAbsolutePath()); + } + } + /** * Rename a file or directory */ @@ -289,12 +415,24 @@ public class FileOperations { /** * Delete directory recursively */ - private static void deleteDirectory(Path directory, ProgressCallback callback) throws IOException { + private static void deleteDirectory(Path directory, long totalItems, long[] currentItem, ProgressCallback callback) throws IOException { + if (Files.isSymbolicLink(directory)) { + currentItem[0]++; + if (callback != null) { + callback.onProgress(currentItem[0], totalItems, directory.getFileName().toString()); + } + Files.delete(directory); + return; + } Files.walkFileTree(directory, new SimpleFileVisitor() { @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { while (true) { try { + currentItem[0]++; + if (callback != null) { + callback.onProgress(currentItem[0], totalItems, file.getFileName().toString()); + } Files.delete(file); return FileVisitResult.CONTINUE; } catch (IOException e) { @@ -314,6 +452,10 @@ public class FileOperations { if (exc != null) throw exc; while (true) { try { + currentItem[0]++; + if (callback != null) { + callback.onProgress(currentItem[0], totalItems, dir.getFileName().toString()); + } Files.delete(dir); return FileVisitResult.CONTINUE; } catch (IOException e) { @@ -568,25 +710,28 @@ public class FileOperations { * Zip files/directories into a target zip file */ public static void zip(List items, File targetZipFile, ProgressCallback callback) throws IOException { + List cleanedItems = cleanDuplicateItems(items); + long totalItems = calculateTotalItems(cleanedItems); + long[] currentItem = {0}; + try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(targetZipFile))) { - long current = 0; - long total = items.size(); - - for (FileItem item : items) { - current++; - File source = item.getFile(); - if (callback != null) { - callback.onProgress(current, total, source.getName()); - } - addToZip(source, source.getName(), zos); + for (FileItem item : cleanedItems) { + if (callback != null && callback.isCancelled()) break; + addToZip(item.getFile(), item.getName(), zos, totalItems, currentItem, callback); } } } - private static void addToZip(File fileToZip, String fileName, ZipOutputStream zos) throws IOException { + private static void addToZip(File fileToZip, String fileName, ZipOutputStream zos, long totalItems, long[] currentItem, ProgressCallback callback) throws IOException { if (fileToZip.isHidden()) { return; } + + currentItem[0]++; + if (callback != null) { + callback.onProgress(currentItem[0], totalItems, fileName); + } + if (fileToZip.isDirectory()) { if (fileName.endsWith("/")) { zos.putNextEntry(new ZipEntry(fileName)); @@ -598,7 +743,7 @@ public class FileOperations { File[] children = fileToZip.listFiles(); if (children != null) { for (File childFile : children) { - addToZip(childFile, fileName + "/" + childFile.getName(), zos); + addToZip(childFile, fileName + "/" + childFile.getName(), zos, totalItems, currentItem, callback); } } return; @@ -623,14 +768,22 @@ public class FileOperations { Files.createDirectories(targetDirectory.toPath()); } + long totalItems = 0; + try (ZipFile zf = new ZipFile(zipFile)) { + totalItems = zf.size(); + } catch (IOException e) { + // fallback if ZipFile fails + } + + long currentItem = 0; try (ZipInputStream zis = new ZipInputStream(new FileInputStream(zipFile))) { ZipEntry entry; - // First pass or estimated count could be done, but keep it simple for now while ((entry = zis.getNextEntry()) != null) { + currentItem++; File newFile = new File(targetDirectory, entry.getName()); if (callback != null) { - callback.onProgress(0L, 0L, entry.getName()); + callback.onProgress(currentItem, totalItems, entry.getName()); } if (entry.isDirectory()) { @@ -643,6 +796,10 @@ public class FileOperations { if (!parent.isDirectory() && !parent.mkdirs()) { throw new IOException("Failed to create directory " + parent); } + + if (newFile.exists() && newFile.isDirectory()) { + deleteDirectoryInternal(newFile.toPath()); + } // write file content try (FileOutputStream fos = new FileOutputStream(newFile)) { diff --git a/src/main/java/cz/kamma/kfmanager/ui/MainWindow.java b/src/main/java/cz/kamma/kfmanager/ui/MainWindow.java index c2901e5..b377613 100644 --- a/src/main/java/cz/kamma/kfmanager/ui/MainWindow.java +++ b/src/main/java/cz/kamma/kfmanager/ui/MainWindow.java @@ -4,6 +4,7 @@ import cz.kamma.kfmanager.MainApp; import cz.kamma.kfmanager.config.AppConfig; import cz.kamma.kfmanager.model.FileItem; import cz.kamma.kfmanager.service.FileOperations; +import cz.kamma.kfmanager.service.FileOperationQueue; import javax.swing.*; import java.awt.*; @@ -656,6 +657,10 @@ public class MainWindow extends JFrame { JMenuItem refreshItem = new JMenuItem("Refresh"); refreshItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_F5, InputEvent.CTRL_DOWN_MASK)); refreshItem.addActionListener(e -> refreshPanels()); + + JMenuItem queueItem = new JMenuItem("Operations Queue..."); + queueItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_Q, InputEvent.CTRL_DOWN_MASK)); + queueItem.addActionListener(e -> OperationQueueDialog.showQueue(this)); JMenuItem exitItem = new JMenuItem("Exit"); exitItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_F10, 0)); @@ -664,6 +669,7 @@ public class MainWindow extends JFrame { fileMenu.add(searchItem); fileMenu.add(selectWildcardItem); fileMenu.add(refreshItem); + fileMenu.add(queueItem); fileMenu.addSeparator(); fileMenu.add(exitItem); @@ -1272,10 +1278,15 @@ public class MainWindow extends JFrame { "Copy"); if (result == 0 || result == 1) { - boolean modal = (result == 0); - performFileOperation((callback) -> { - FileOperations.copy(selectedItems, targetDir, callback); - }, "Copy completed", true, modal, targetPanel); + boolean background = (result == 1); + if (background) { + addOperationToQueue("Copy", String.format("Copy %d items to %s", selectedItems.size(), targetDir.getName()), + (cb) -> FileOperations.copy(selectedItems, targetDir, cb), targetPanel); + } else { + performFileOperation((callback) -> { + FileOperations.copy(selectedItems, targetDir, callback); + }, "Copy completed", false, true, targetPanel); + } } else { if (activePanel != null && activePanel.getFileTable() != null) { activePanel.getFileTable().requestFocusInWindow(); @@ -1305,10 +1316,15 @@ public class MainWindow extends JFrame { "Move"); if (result == 0 || result == 1) { - boolean modal = (result == 0); - performFileOperation((callback) -> { - FileOperations.move(selectedItems, targetDir, callback); - }, "Move completed", false, modal, activePanel, targetPanel); + boolean background = (result == 1); + if (background) { + addOperationToQueue("Move", String.format("Move %d items to %s", selectedItems.size(), targetDir.getName()), + (cb) -> FileOperations.move(selectedItems, targetDir, cb), activePanel, targetPanel); + } else { + performFileOperation((callback) -> { + FileOperations.move(selectedItems, targetDir, callback); + }, "Move completed", false, true, activePanel, targetPanel); + } } else { if (activePanel != null && activePanel.getFileTable() != null) { activePanel.getFileTable().requestFocusInWindow(); @@ -1344,10 +1360,10 @@ public class MainWindow extends JFrame { private void deleteFiles() { List selectedItems = activePanel.getSelectedItems(); if (selectedItems.isEmpty()) { - JOptionPane.showMessageDialog(this, - "No files selected", - "Delete", - JOptionPane.INFORMATION_MESSAGE); + JOptionPane.showMessageDialog(this, + "No files selected", + "Delete", + JOptionPane.INFORMATION_MESSAGE); requestFocusInActivePanel(); return; } @@ -1355,41 +1371,41 @@ public class MainWindow extends JFrame { final int rememberedIndex = (activePanel != null && activePanel.getCurrentTab() != null) ? activePanel.getCurrentTab().getFocusedItemIndex() : -1; - StringBuilder message = new StringBuilder("Really delete the following items?\n\n"); + StringBuilder message = new StringBuilder("Really delete the following items?\n\n"); for (FileItem item : selectedItems) { - message.append(item.getName()).append("\n"); + message.append(item.getName()).append("\n"); if (message.length() > 500) { message.append("..."); break; } } - int result = JOptionPane.showConfirmDialog(this, - message.toString(), - "Delete", - JOptionPane.YES_NO_OPTION, - JOptionPane.WARNING_MESSAGE); + int result = showConfirmWithBackground(message.toString(), "Delete"); - if (result == JOptionPane.YES_OPTION) { - performFileOperation((callback) -> { - FileOperations.delete(selectedItems, callback); - }, "Delete completed", false, activePanel); + if (result == 0 || result == 1) { + boolean background = (result == 1); + if (background) { + addOperationToQueue("Delete", String.format("Delete %d items", selectedItems.size()), + (cb) -> FileOperations.delete(selectedItems, cb), activePanel); + } else { + performFileOperation((callback) -> { + FileOperations.delete(selectedItems, callback); + }, "Delete completed", false, true, activePanel); - // After deletion and refresh, restore selection: move focus to the nearest higher item. - SwingUtilities.invokeLater(() -> { - try { - if (activePanel != null && activePanel.getCurrentTab() != null) { - // Use another invokeLater to ensure BRIEF mode layout is updated - SwingUtilities.invokeLater(() -> { - activePanel.getCurrentTab().selectItemByIndex(rememberedIndex - 1); - }); - } - } catch (Exception ignore) {} - }); - } else { - if (activePanel != null && activePanel.getFileTable() != null) { - activePanel.getFileTable().requestFocusInWindow(); + // After deletion and refresh, restore selection: move focus to the nearest higher item. + SwingUtilities.invokeLater(() -> { + try { + if (activePanel != null && activePanel.getCurrentTab() != null) { + // Use another invokeLater to ensure BRIEF mode layout is updated + SwingUtilities.invokeLater(() -> { + activePanel.getCurrentTab().selectItemByIndex(rememberedIndex - 1); + }); + } + } catch (Exception ignore) {} + }); } + } else { + requestFocusInActivePanel(); } } @@ -1453,9 +1469,23 @@ public class MainWindow extends JFrame { } final File finalTargetZip = targetZip; - performFileOperation((callback) -> { - FileOperations.zip(selectedItems, finalTargetZip, callback); - }, "Zipped into " + zipName, false, targetPanel, targetPanel); + int result = showConfirmWithBackground( + String.format("Zip %d items to:\n%s", selectedItems.size(), targetZip.getAbsolutePath()), + "Zip"); + + if (result == 0 || result == 1) { + boolean background = (result == 1); + if (background) { + addOperationToQueue("Zip", String.format("Zip %d items to %s", selectedItems.size(), finalTargetZip.getName()), + (cb) -> FileOperations.zip(selectedItems, finalTargetZip, cb), targetPanel); + } else { + performFileOperation((callback) -> { + FileOperations.zip(selectedItems, finalTargetZip, callback); + }, "Zipped into " + zipName, false, true, targetPanel); + } + } else { + requestFocusInActivePanel(); + } } /** @@ -1485,15 +1515,20 @@ public class MainWindow extends JFrame { FilePanel targetPanel = (activePanel == leftPanel) ? rightPanel : leftPanel; File targetDir = targetPanel.getCurrentDirectory(); - int result = JOptionPane.showConfirmDialog(this, + int result = showConfirmWithBackground( String.format("Unzip %s to:\n%s", zipFile.getName(), targetDir.getAbsolutePath()), - "Unzip", - JOptionPane.OK_CANCEL_OPTION); + "Unzip"); - if (result == JOptionPane.OK_OPTION) { - performFileOperation((callback) -> { - FileOperations.unzip(zipFile, targetDir, callback); - }, "Unzipped into " + targetDir.getName(), false, targetPanel); + if (result == 0 || result == 1) { + boolean background = (result == 1); + if (background) { + addOperationToQueue("Unzip", String.format("Unzip %s to %s", zipFile.getName(), targetDir.getName()), + (cb) -> FileOperations.unzip(zipFile, targetDir, cb), targetPanel); + } else { + performFileOperation((callback) -> { + FileOperations.unzip(zipFile, targetDir, callback); + }, "Unzipped into " + targetDir.getName(), false, true, targetPanel); + } } else { if (activePanel != null && activePanel.getFileTable() != null) { activePanel.getFileTable().requestFocusInWindow(); @@ -2200,6 +2235,23 @@ public class MainWindow extends JFrame { void execute(FileOperations.ProgressCallback callback) throws Exception; } + private void addOperationToQueue(String title, String description, FileOperation operation, FilePanel... panelsToRefresh) { + FileOperationQueue.QueuedTask task = new FileOperationQueue.QueuedTask(title, description, (callback) -> { + operation.execute(callback); + + SwingUtilities.invokeLater(() -> { + for (FilePanel panel : panelsToRefresh) { + if (panel.getCurrentDirectory() != null) { + panel.loadDirectory(panel.getCurrentDirectory(), false, false); + } + } + }); + }); + + FileOperationQueue.getInstance().addTask(task); + OperationQueueDialog.showQueue(this); + } + private void requestFocusInActivePanel() { if (activePanel != null && activePanel.getFileTable() != null) { activePanel.getFileTable().requestFocusInWindow(); diff --git a/src/main/java/cz/kamma/kfmanager/ui/OperationQueueDialog.java b/src/main/java/cz/kamma/kfmanager/ui/OperationQueueDialog.java new file mode 100644 index 0000000..fb92aa2 --- /dev/null +++ b/src/main/java/cz/kamma/kfmanager/ui/OperationQueueDialog.java @@ -0,0 +1,128 @@ +package cz.kamma.kfmanager.ui; + +import cz.kamma.kfmanager.service.FileOperationQueue; +import cz.kamma.kfmanager.service.FileOperationQueue.QueuedTask; + +import javax.swing.*; +import javax.swing.table.AbstractTableModel; +import javax.swing.table.DefaultTableCellRenderer; +import java.awt.*; +import java.util.ArrayList; +import java.util.List; + +public class OperationQueueDialog extends JDialog { + private final JTable table; + private final TaskTableModel model; + private static OperationQueueDialog instance; + + public static synchronized void showQueue(Frame owner) { + if (instance == null) { + instance = new OperationQueueDialog(owner); + } + instance.setVisible(true); + instance.toFront(); + } + + private OperationQueueDialog(Frame owner) { + super(owner, "File Operation Queue", false); + + setLayout(new BorderLayout(10, 10)); + setSize(600, 400); + setLocationRelativeTo(owner); + + model = new TaskTableModel(); + table = new JTable(model); + table.setRowHeight(30); + + table.getColumnModel().getColumn(2).setCellRenderer(new ProgressRenderer()); + + JScrollPane scrollPane = new JScrollPane(table); + add(scrollPane, BorderLayout.CENTER); + + JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT)); + JButton closeButton = new JButton("Close"); + closeButton.addActionListener(e -> setVisible(false)); + + JButton clearButton = new JButton("Clear Completed"); + clearButton.addActionListener(e -> FileOperationQueue.getInstance().clearCompleted()); + + buttonPanel.add(clearButton); + buttonPanel.add(closeButton); + add(buttonPanel, BorderLayout.SOUTH); + + FileOperationQueue.getInstance().addListener(tasks -> { + SwingUtilities.invokeLater(() -> model.setTasks(tasks)); + }); + + // Initial task load + model.setTasks(FileOperationQueue.getInstance().getTasks()); + } + + private class TaskTableModel extends AbstractTableModel { + private List tasks = new ArrayList<>(); + private final String[] columns = {"Operation", "Status", "Progress", "Active File"}; + + public void setTasks(List tasks) { + this.tasks = tasks; + fireTableDataChanged(); + } + + @Override + public int getRowCount() { return tasks.size(); } + @Override + public int getColumnCount() { return columns.length; } + @Override + public String getColumnName(int column) { return columns[column]; } + + @Override + public Object getValueAt(int rowIndex, int columnIndex) { + QueuedTask task = tasks.get(rowIndex); + switch (columnIndex) { + case 0: return task.getTitle(); + case 1: return task.getStatus().toString(); + case 2: return task; // For progress renderer + case 3: return task.getCurrentFile(); + default: return null; + } + } + } + + private class ProgressRenderer extends DefaultTableCellRenderer { + private final JProgressBar progressBar = new JProgressBar(0, 100); + + public ProgressRenderer() { + progressBar.setStringPainted(true); + progressBar.setBorder(BorderFactory.createEmptyBorder(2, 2, 2, 2)); + } + + @Override + public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column) { + if (value instanceof QueuedTask) { + QueuedTask task = (QueuedTask) value; + if (task.getStatus() == FileOperationQueue.OperationStatus.FAILED) { + progressBar.setIndeterminate(false); + progressBar.setValue(0); + progressBar.setString("Error"); + } else if (task.getStatus() == FileOperationQueue.OperationStatus.COMPLETED) { + progressBar.setIndeterminate(false); + progressBar.setValue(100); + progressBar.setString("100%"); + } else if (task.getTotalProgress() > 0) { + progressBar.setIndeterminate(false); + int percent = (int) Math.min(100, ((double) task.getCurrentProgress() / task.getTotalProgress() * 100)); + progressBar.setValue(percent); + progressBar.setString(task.getCurrentProgress() + " / " + task.getTotalProgress() + " (" + percent + "%)"); + } else if (task.getStatus() == FileOperationQueue.OperationStatus.RUNNING) { + progressBar.setIndeterminate(true); + progressBar.setString("Processing..."); + } else { + progressBar.setIndeterminate(false); + progressBar.setValue(0); + progressBar.setString(""); + } + return progressBar; + } + return super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column); + } + } +} diff --git a/src/main/java/cz/kamma/kfmanager/ui/ProgressDialog.java b/src/main/java/cz/kamma/kfmanager/ui/ProgressDialog.java index b5ed9c8..9d6535a 100644 --- a/src/main/java/cz/kamma/kfmanager/ui/ProgressDialog.java +++ b/src/main/java/cz/kamma/kfmanager/ui/ProgressDialog.java @@ -97,10 +97,10 @@ public class ProgressDialog extends JDialog { SwingUtilities.invokeLater(() -> { if (total > 0) { progressBar.setIndeterminate(false); - int percent = (int) ((double) current / total * 100); + int percent = (int) Math.min(100, ((double) current / total * 100)); progressBar.setValue(percent); if (displayAsBytes) { - progressBar.setString(formatSize(current) + " / " + formatSize(total) + " (" + percent + "%)"); + progressBar.setString(formatSize(Math.min(current, total)) + " / " + formatSize(total) + " (" + percent + "%)"); long elapsed = System.currentTimeMillis() - startTime; if (elapsed > 0) {