operation queue, symbolic link support

This commit is contained in:
rdavidek 2026-01-19 22:24:38 +01:00
parent 0950eba98a
commit 68fe5ca0c6
5 changed files with 607 additions and 108 deletions

View File

@ -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<QueuedTask> tasks = new CopyOnWriteArrayList<>();
private final List<Consumer<List<QueuedTask>>> 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<List<QueuedTask>> listener) {
listeners.add(listener);
}
public void removeListener(Consumer<List<QueuedTask>> listener) {
listeners.remove(listener);
}
private void notifyListeners() {
List<QueuedTask> currentTasks = new ArrayList<>(tasks);
for (Consumer<List<QueuedTask>> 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<QueuedTask> getTasks() {
return new ArrayList<>(tasks);
}
public void clearCompleted() {
tasks.removeIf(t -> t.status == OperationStatus.COMPLETED || t.status == OperationStatus.FAILED || t.status == OperationStatus.CANCELLED);
notifyListeners();
}
}

View File

@ -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<FileItem> 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<FileItem> items) {
private static long calculateTotalItems(List<FileItem> 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<Path>() {
@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<Path>() {
@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<Path>() {
@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<FileItem> 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<FileItem> items, ProgressCallback callback) throws IOException {
int current = 0;
int total = items.size();
List<FileItem> 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<FileItem> cleanDuplicateItems(List<FileItem> items) {
if (items == null || items.size() <= 1) return items;
List<FileItem> sorted = new ArrayList<>(items);
sorted.sort((a, b) -> a.getFile().getAbsolutePath().compareTo(b.getFile().getAbsolutePath()));
List<FileItem> 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<Path>() {
@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<FileItem> items, File targetZipFile, ProgressCallback callback) throws IOException {
List<FileItem> 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)) {

View File

@ -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<FileItem> 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();

View File

@ -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<QueuedTask> tasks = new ArrayList<>();
private final String[] columns = {"Operation", "Status", "Progress", "Active File"};
public void setTasks(List<QueuedTask> 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);
}
}
}

View File

@ -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) {