diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/cz/kamma/kfmanager/MainApp.java b/src/main/java/cz/kamma/kfmanager/MainApp.java index 2e30251..439bd85 100644 --- a/src/main/java/cz/kamma/kfmanager/MainApp.java +++ b/src/main/java/cz/kamma/kfmanager/MainApp.java @@ -15,7 +15,7 @@ import java.io.InputStreamReader; */ public class MainApp { - public static final String APP_VERSION = "1.2.7"; + public static final String APP_VERSION = "1.2.8"; public enum OS { WINDOWS, LINUX, MACOS, UNKNOWN diff --git a/src/main/java/cz/kamma/kfmanager/service/FileOperations.java b/src/main/java/cz/kamma/kfmanager/service/FileOperations.java index fdfc046..5399c99 100644 --- a/src/main/java/cz/kamma/kfmanager/service/FileOperations.java +++ b/src/main/java/cz/kamma/kfmanager/service/FileOperations.java @@ -26,6 +26,7 @@ import java.util.Set; import java.util.regex.Pattern; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; +import java.util.zip.ZipOutputStream; import cz.kamma.kfmanager.model.FileItem; import net.lingala.zip4j.ZipFile; @@ -1093,6 +1094,87 @@ public class FileOperations { } } } + + /** + * True when archive can be rewritten from extracted temp directory. + */ + public static boolean supportsArchiveRewrite(File archiveFile) { + if (archiveFile == null) return false; + String n = archiveFile.getName().toLowerCase(); + return n.endsWith(".zip") || n.endsWith(".jar") || n.endsWith(".war"); + } + + /** + * Rebuild zip-like archive from already extracted directory content. + */ + public static void rewriteArchiveFromDirectory(File extractedRootDir, File targetArchiveFile, ProgressCallback callback) throws IOException { + rewriteArchiveFromDirectory(extractedRootDir, targetArchiveFile, null, callback); + } + + public static void rewriteArchiveFromDirectory(File extractedRootDir, File targetArchiveFile, String password, ProgressCallback callback) throws IOException { + if (extractedRootDir == null || !extractedRootDir.isDirectory()) { + throw new IOException("Extracted archive directory does not exist"); + } + if (targetArchiveFile == null) { + throw new IOException("Target archive is not defined"); + } + if (!supportsArchiveRewrite(targetArchiveFile)) { + throw new IOException("Updating this archive format is not supported: " + targetArchiveFile.getName()); + } + + boolean archiveEncrypted = false; + if (targetArchiveFile.exists()) { + try (ZipFile existing = new ZipFile(targetArchiveFile)) { + archiveEncrypted = existing.isEncrypted(); + } catch (IOException e) { + throw e; + } catch (Exception e) { + throw new IOException("Cannot inspect archive: " + e.getMessage(), e); + } + } + if (archiveEncrypted && (password == null || password.isBlank())) { + throw new IOException("Password is required to update encrypted archive"); + } + + Path tempArchivePath = Files.createTempFile("kfmanager-archive-sync-", ".zip"); + File tempArchiveFile = tempArchivePath.toFile(); + try { + File[] children = extractedRootDir.listFiles(); + if (children == null || children.length == 0) { + if (archiveEncrypted) { + throw new IOException("Updating encrypted archive to empty content is not supported"); + } + createEmptyZip(tempArchiveFile); + } else { + List items = new ArrayList<>(); + for (File child : children) { + items.add(new FileItem(child)); + } + zip(items, tempArchiveFile, archiveEncrypted ? password : null, callback); + } + + try { + Files.move(tempArchivePath, targetArchiveFile.toPath(), StandardCopyOption.REPLACE_EXISTING); + } catch (IOException moveError) { + throw new IOException("Failed to update archive " + targetArchiveFile.getName() + ": " + moveError.getMessage(), moveError); + } + } finally { + try { + Files.deleteIfExists(tempArchivePath); + } catch (Exception ignore) { + } + } + } + + private static void createEmptyZip(File targetZipFile) throws IOException { + if (targetZipFile.exists() && !targetZipFile.delete()) { + throw new IOException("Cannot recreate archive: " + targetZipFile.getAbsolutePath()); + } + try (OutputStream os = Files.newOutputStream(targetZipFile.toPath()); + ZipOutputStream zos = new ZipOutputStream(os)) { + // intentionally empty; valid empty ZIP archive + } + } /** * Extract an archive into a target directory diff --git a/src/main/java/cz/kamma/kfmanager/ui/FilePanelTab.java b/src/main/java/cz/kamma/kfmanager/ui/FilePanelTab.java index 3f1b2f4..fd81622 100644 --- a/src/main/java/cz/kamma/kfmanager/ui/FilePanelTab.java +++ b/src/main/java/cz/kamma/kfmanager/ui/FilePanelTab.java @@ -76,6 +76,7 @@ public class FilePanelTab extends JPanel { // we can cleanup older temp directories when navigation changes. private Path currentArchiveTempDir = null; private File currentArchiveSourceFile = null; + private String currentArchivePassword = null; private File archiveReturnDirectory = null; private Point archiveReturnViewPosition = null; private boolean inlineRenameActive = false; @@ -1618,7 +1619,7 @@ public class FilePanelTab extends JPanel { Path newPath = (newDirectory != null) ? newDirectory.toPath().toAbsolutePath().normalize() : null; if (newPath == null || !newPath.startsWith(currentArchiveTempDir)) { deleteTempDirRecursively(currentArchiveTempDir); - currentArchiveTempDir = null; + clearOpenedArchiveSession(); } } } catch (Exception ignore) { @@ -1665,6 +1666,7 @@ public class FilePanelTab extends JPanel { if (archive == null || !archive.isFile()) return null; try { Path tempDir = Files.createTempDirectory("kfmanager-archive-"); + final String[] usedPassword = new String[1]; FileOperations.extractArchive(archive, tempDir.toFile(), new FileOperations.ProgressCallback() { @Override public void onProgress(long current, long total, String currentFile) {} @@ -1701,6 +1703,7 @@ public class FilePanelTab extends JPanel { Object selectedValue = pane.getValue(); if (selectedValue != null && (Integer) selectedValue == JOptionPane.OK_OPTION) { result[0] = new String(pf.getPassword()); + usedPassword[0] = result[0]; } }; @@ -1715,12 +1718,14 @@ public class FilePanelTab extends JPanel { return result[0]; } }); + currentArchivePassword = usedPassword[0]; return tempDir; } catch (Exception ex) { // extraction failed; attempt best-effort cleanup try { if (currentArchiveTempDir != null) deleteTempDirRecursively(currentArchiveTempDir); } catch (Exception ignore) {} + currentArchivePassword = null; return null; } } @@ -1737,6 +1742,28 @@ public class FilePanelTab extends JPanel { } catch (IOException ignore) { } } + + public boolean isDirectoryInsideOpenedArchive(File directory) { + if (directory == null || currentArchiveTempDir == null || currentArchiveSourceFile == null) return false; + try { + Path dirPath = directory.toPath().toAbsolutePath().normalize(); + Path archiveRoot = currentArchiveTempDir.toAbsolutePath().normalize(); + return dirPath.startsWith(archiveRoot); + } catch (Exception ignore) { + return false; + } + } + + public boolean canSyncOpenedArchive() { + return currentArchiveTempDir != null + && currentArchiveSourceFile != null + && FileOperations.supportsArchiveRewrite(currentArchiveSourceFile); + } + + public void syncOpenedArchiveChanges(FileOperations.ProgressCallback callback) throws IOException { + if (currentArchiveTempDir == null || currentArchiveSourceFile == null) return; + FileOperations.rewriteArchiveFromDirectory(currentArchiveTempDir.toFile(), currentArchiveSourceFile, currentArchivePassword, callback); + } private void openSelectedItem() { int selectedRow = fileTable.getSelectedRow(); @@ -2270,8 +2297,7 @@ public class FilePanelTab extends JPanel { String archiveName = currentArchiveSourceFile.getName(); // cleanup temp dir before switching back deleteTempDirRecursively(currentArchiveTempDir); - currentArchiveTempDir = null; - currentArchiveSourceFile = null; + clearOpenedArchiveSession(); loadDirectory(parent, false, true, () -> { // Restore original viewport position and keep focus on archive item. // In BRIEF mode some selection listeners may still run later, so apply once more on EDT tail. @@ -2384,6 +2410,12 @@ public class FilePanelTab extends JPanel { archiveReturnViewPosition = null; } + private void clearOpenedArchiveSession() { + currentArchiveTempDir = null; + currentArchiveSourceFile = null; + currentArchivePassword = null; + } + /** * Public wrapper to select an item by name from outside this class. * Useful for other UI components to request focusing a specific file. @@ -2738,6 +2770,13 @@ public class FilePanelTab extends JPanel { } else { FileOperations.copy(itemsToPaste, targetDir, callback); } + + if (isDirectoryInsideOpenedArchive(targetDir)) { + if (!canSyncOpenedArchive()) { + throw new IOException("Updating this archive format is not supported"); + } + syncOpenedArchiveChanges(callback); + } SwingUtilities.invokeLater(() -> { progressDialog.dispose(); loadDirectory(targetDir, false); diff --git a/src/main/java/cz/kamma/kfmanager/ui/MainWindow.java b/src/main/java/cz/kamma/kfmanager/ui/MainWindow.java index efa9bcc..9e691b1 100644 --- a/src/main/java/cz/kamma/kfmanager/ui/MainWindow.java +++ b/src/main/java/cz/kamma/kfmanager/ui/MainWindow.java @@ -1775,10 +1775,14 @@ public class MainWindow extends JFrame { boolean background = (result == 1); if (background) { addOperationToQueue("Copy", "Copy %d items to %s".formatted(selectedItems.size(), targetDir.getName()), - (cb) -> FileOperations.copy(selectedItems, targetDir, cb), () -> sourcePanel.unselectAll(), targetPanel); + (cb) -> { + FileOperations.copy(selectedItems, targetDir, cb); + syncTargetArchiveIfNeeded(targetPanel, targetDir, cb); + }, () -> sourcePanel.unselectAll(), targetPanel); } else { performFileOperation((callback) -> { FileOperations.copy(selectedItems, targetDir, callback); + syncTargetArchiveIfNeeded(targetPanel, targetDir, callback); }, "Copy completed", false, true, () -> sourcePanel.unselectAll(), targetPanel); } } else { @@ -1813,10 +1817,14 @@ public class MainWindow extends JFrame { boolean background = (result == 1); if (background) { addOperationToQueue("Move", "Move %d items to %s".formatted(selectedItems.size(), targetDir.getName()), - (cb) -> FileOperations.move(selectedItems, targetDir, cb), activePanel, targetPanel); + (cb) -> { + FileOperations.move(selectedItems, targetDir, cb); + syncTargetArchiveIfNeeded(targetPanel, targetDir, cb); + }, activePanel, targetPanel); } else { performFileOperation((callback) -> { FileOperations.move(selectedItems, targetDir, callback); + syncTargetArchiveIfNeeded(targetPanel, targetDir, callback); }, "Move completed", false, true, activePanel, targetPanel); } } else { @@ -3461,6 +3469,17 @@ public class MainWindow extends JFrame { } } + private void syncTargetArchiveIfNeeded(FilePanel targetPanel, File targetDir, FileOperations.ProgressCallback callback) throws IOException { + if (targetPanel == null || targetDir == null) return; + FilePanelTab targetTab = targetPanel.getCurrentTab(); + if (targetTab == null) return; + if (!targetTab.isDirectoryInsideOpenedArchive(targetDir)) return; + if (!targetTab.canSyncOpenedArchive()) { + throw new IOException("Updating this archive format is not supported"); + } + targetTab.syncOpenedArchiveChanges(callback); + } + private void updateAutoRefreshTimer() { if (autoRefreshTimer != null) { autoRefreshTimer.stop();