From a810cfac022ca5db4b8dd70941d42a39251a651d Mon Sep 17 00:00:00 2001 From: Radek Davidek Date: Tue, 21 Apr 2026 18:32:45 +0200 Subject: [PATCH] FTP draft --- pom.xml | 5 + .../cz/kamma/kfmanager/config/AppConfig.java | 62 ++ .../cz/kamma/kfmanager/model/FileItem.java | 70 ++- .../cz/kamma/kfmanager/model/FtpProfile.java | 71 +++ .../kfmanager/service/FileOperations.java | 156 ++++- .../kamma/kfmanager/service/FtpService.java | 535 ++++++++++++++++++ .../java/cz/kamma/kfmanager/ui/FilePanel.java | 126 ++++- .../cz/kamma/kfmanager/ui/FilePanelTab.java | 203 ++++++- .../kfmanager/ui/FtpProfileEditDialog.java | 155 +++++ .../kfmanager/ui/FtpProfileManagerDialog.java | 151 +++++ .../cz/kamma/kfmanager/ui/MainWindow.java | 336 ++++++++--- 11 files changed, 1752 insertions(+), 118 deletions(-) create mode 100644 src/main/java/cz/kamma/kfmanager/model/FtpProfile.java create mode 100644 src/main/java/cz/kamma/kfmanager/service/FtpService.java create mode 100644 src/main/java/cz/kamma/kfmanager/ui/FtpProfileEditDialog.java create mode 100644 src/main/java/cz/kamma/kfmanager/ui/FtpProfileManagerDialog.java diff --git a/pom.xml b/pom.xml index 895e57a..efe7d9c 100644 --- a/pom.xml +++ b/pom.xml @@ -116,5 +116,10 @@ flatlaf-extras 3.5.1 + + commons-net + commons-net + 3.11.1 + diff --git a/src/main/java/cz/kamma/kfmanager/config/AppConfig.java b/src/main/java/cz/kamma/kfmanager/config/AppConfig.java index b22695a..15e4492 100644 --- a/src/main/java/cz/kamma/kfmanager/config/AppConfig.java +++ b/src/main/java/cz/kamma/kfmanager/config/AppConfig.java @@ -1018,4 +1018,66 @@ public class AppConfig { public void setMaxCompareLines(int lines) { properties.setProperty("compare.maxLines", String.valueOf(lines)); } + + // --- FTP Profiles persistence --- + + public java.util.List getFtpProfiles() { + java.util.List list = new java.util.ArrayList<>(); + int count = Integer.parseInt(properties.getProperty("ftp.profiles.count", "0")); + for (int i = 0; i < count; i++) { + String id = properties.getProperty("ftp.profile." + i + ".id"); + String name = properties.getProperty("ftp.profile." + i + ".name"); + String host = properties.getProperty("ftp.profile." + i + ".host"); + String portStr = properties.getProperty("ftp.profile." + i + ".port", "21"); + String username = properties.getProperty("ftp.profile." + i + ".username"); + String password = properties.getProperty("ftp.profile." + i + ".password"); + boolean passive = Boolean.parseBoolean(properties.getProperty("ftp.profile." + i + ".passiveMode", "true")); + if (name != null && host != null) { + cz.kamma.kfmanager.model.FtpProfile p = new cz.kamma.kfmanager.model.FtpProfile( + name, host, Integer.parseInt(portStr), + username != null ? username : "", + password != null ? password : "", + passive); + p.setId(id); + list.add(p); + } + } + return list; + } + + public void saveFtpProfiles(java.util.List profiles) { + // Remove old entries + int old = Integer.parseInt(properties.getProperty("ftp.profiles.count", "0")); + for (int i = 0; i < old; i++) { + properties.remove("ftp.profile." + i + ".id"); + properties.remove("ftp.profile." + i + ".name"); + properties.remove("ftp.profile." + i + ".host"); + properties.remove("ftp.profile." + i + ".port"); + properties.remove("ftp.profile." + i + ".username"); + properties.remove("ftp.profile." + i + ".password"); + properties.remove("ftp.profile." + i + ".passiveMode"); + } + + if (profiles == null) { + properties.setProperty("ftp.profiles.count", "0"); + return; + } + + properties.setProperty("ftp.profiles.count", String.valueOf(profiles.size())); + for (int i = 0; i < profiles.size(); i++) { + cz.kamma.kfmanager.model.FtpProfile p = profiles.get(i); + properties.setProperty("ftp.profile." + i + ".id", p.getId() != null ? p.getId() : ""); + properties.setProperty("ftp.profile." + i + ".name", p.getName() != null ? p.getName() : ""); + properties.setProperty("ftp.profile." + i + ".host", p.getHost() != null ? p.getHost() : ""); + properties.setProperty("ftp.profile." + i + ".port", String.valueOf(p.getPort())); + properties.setProperty("ftp.profile." + i + ".username", p.getUsername() != null ? p.getUsername() : ""); + properties.setProperty("ftp.profile." + i + ".password", p.getPassword() != null ? p.getPassword() : ""); + properties.setProperty("ftp.profile." + i + ".passiveMode", String.valueOf(p.isPassiveMode())); + } + } + + /** Generate a unique ID for a new FTP profile */ + public String generateFtpProfileId() { + return java.util.UUID.randomUUID().toString(); + } } diff --git a/src/main/java/cz/kamma/kfmanager/model/FileItem.java b/src/main/java/cz/kamma/kfmanager/model/FileItem.java index 58ed6e5..c986b7a 100644 --- a/src/main/java/cz/kamma/kfmanager/model/FileItem.java +++ b/src/main/java/cz/kamma/kfmanager/model/FileItem.java @@ -5,10 +5,11 @@ import java.text.SimpleDateFormat; import java.util.Date; /** - * Model representing a file or directory for display in the table + * Model representing a file or directory for display in the table. + * Supports both local files (java.io.File) and remote FTP files. */ public class FileItem { - + private final File file; private final String name; private final long size; @@ -17,6 +18,10 @@ public class FileItem { private boolean marked; private boolean recentlyChanged; private String displayPath; + // FTP support + private final boolean isFtp; + private final String ftpPath; + private final FtpProfile ftpProfile; public FileItem(File file) { this(file, null); @@ -29,7 +34,28 @@ public class FileItem { this.modified = new Date(file.lastModified()); this.isDirectory = file.isDirectory(); this.marked = false; + this.recentlyChanged = false; this.displayPath = displayPath; + this.isFtp = false; + this.ftpPath = null; + this.ftpProfile = null; + } + + /** + * Create a FileItem for a remote FTP file/directory. + */ + public FileItem(String name, long size, Date modified, boolean isDirectory, String ftpPath, FtpProfile profile) { + this.file = null; + this.name = name; + this.size = isDirectory ? -1 : size; + this.modified = modified; + this.isDirectory = isDirectory; + this.marked = false; + this.recentlyChanged = false; + this.displayPath = null; + this.isFtp = true; + this.ftpPath = ftpPath; + this.ftpProfile = profile; } public File getFile() { @@ -64,6 +90,29 @@ public class FileItem { return isDirectory; } + /** + * Check if this item represents an FTP file/directory. + */ + public boolean isFtp() { + return isFtp; + } + + /** + * Get the remote FTP path (e.g. "/remote/dir/file.txt"). + * Returns null for local files. + */ + public String getFtpPath() { + return ftpPath; + } + + /** + * Get the FTP profile for this item. + * Returns null for local files. + */ + public FtpProfile getFtpProfile() { + return ftpProfile; + } + /** * Check if this item is the same as another item based on metadata. */ @@ -74,21 +123,26 @@ public class FileItem { return isDirectory == other.isDirectory && size == other.size && (name != null ? name.equals(other.name) : other.name == null) && - (modified != null ? modified.getTime() == other.modified.getTime() : other.modified == null); + (modified != null ? modified.getTime() == other.modified.getTime() : other.modified == null) && + isFtp == other.isFtp && + (isFtp ? (ftpPath != null ? ftpPath.equals(other.ftpPath) : other.ftpPath == null) : true); } public String getPath() { - return displayPath != null ? displayPath : file.getAbsolutePath(); + if (isFtp && ftpProfile != null) { + return "ftp://" + ftpProfile.getHost() + ":" + ftpProfile.getPort() + ftpPath; + } + return displayPath != null ? displayPath : (file != null ? file.getAbsolutePath() : ""); } - + public boolean isMarked() { return marked; } - + public void setMarked(boolean marked) { this.marked = marked; } - + public void toggleMarked() { this.marked = !this.marked; } @@ -100,7 +154,7 @@ public class FileItem { public void setRecentlyChanged(boolean recentlyChanged) { this.recentlyChanged = recentlyChanged; } - + /** * Format file size into a human-readable string */ diff --git a/src/main/java/cz/kamma/kfmanager/model/FtpProfile.java b/src/main/java/cz/kamma/kfmanager/model/FtpProfile.java new file mode 100644 index 0000000..bc36309 --- /dev/null +++ b/src/main/java/cz/kamma/kfmanager/model/FtpProfile.java @@ -0,0 +1,71 @@ +package cz.kamma.kfmanager.model; + +import java.io.Serializable; +import java.util.Objects; + +/** + * FTP connection profile for storing server connection details + */ +public class FtpProfile implements Serializable { + private static final long serialVersionUID = 1L; + + private String id; + private String name; + private String host; + private int port; + private String username; + private String password; + private boolean passiveMode; + + public FtpProfile() { + this.passiveMode = true; + } + + public FtpProfile(String name, String host, int port, String username, String password, boolean passiveMode) { + this.name = name; + this.host = host; + this.port = port; + this.username = username; + this.password = password; + this.passiveMode = passiveMode; + } + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getName() { return name != null ? name : ""; } + public void setName(String name) { this.name = name; } + + public String getHost() { return host != null ? host : ""; } + public void setHost(String host) { this.host = host; } + + public int getPort() { return port; } + public void setPort(int port) { this.port = port; } + + public String getUsername() { return username != null ? username : ""; } + public void setUsername(String username) { this.username = username; } + + public String getPassword() { return password != null ? password : ""; } + public void setPassword(String password) { this.password = password; } + + public boolean isPassiveMode() { return passiveMode; } + public void setPassiveMode(boolean passiveMode) { this.passiveMode = passiveMode; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + FtpProfile that = (FtpProfile) o; + return Objects.equals(id, that.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + @Override + public String toString() { + return name != null ? name : host; + } +} diff --git a/src/main/java/cz/kamma/kfmanager/service/FileOperations.java b/src/main/java/cz/kamma/kfmanager/service/FileOperations.java index c710c8c..e7b570e 100644 --- a/src/main/java/cz/kamma/kfmanager/service/FileOperations.java +++ b/src/main/java/cz/kamma/kfmanager/service/FileOperations.java @@ -19,6 +19,7 @@ import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import cz.kamma.kfmanager.model.FileItem; +import cz.kamma.kfmanager.model.FtpProfile; import net.lingala.zip4j.ZipFile; /** @@ -304,11 +305,15 @@ public class FileOperations { /** * Delete files/directories */ - public static void delete(List items, ProgressCallback callback) throws IOException { + public static void delete(List items, ProgressCallback callback) throws IOException { + if (!items.isEmpty() && items.get(0).isFtp()) { + deleteFromFtp(items, callback); + return; + } List cleanedItems = cleanDuplicateItems(items); long totalItems = calculateTotalItems(cleanedItems); long[] currentItem = {0}; - + for (FileItem item : cleanedItems) { if (callback != null && callback.isCancelled()) break; File file = item.getFile(); @@ -401,13 +406,156 @@ public class FileOperations { /** * Rename a file or directory */ - public static void rename(File file, String newName) throws IOException { + public static void rename(File file, String newName) throws IOException { File target = new File(file.getParentFile(), newName); if (!file.renameTo(target)) { throw new IOException("Failed to rename file"); } } - + + /** + * Rename using FileItem (handles both local and FTP). + */ + public static void rename(FileItem item, String newName) throws IOException { + if (item.isFtp()) { + FtpService.renameOnFtp(item.getFtpProfile(), item.getFtpPath(), newName); + } else { + rename(item.getFile(), newName); + } + } + + /** + * Create directory on FTP or local. + */ + public static void createDirectory(FileItem parentItem, String name) throws IOException { + if (parentItem.isFtp()) { + createDirectoryOnFtp(parentItem.getFtpProfile(), + parentItem.getFtpPath(), name); + } else { + createDirectory(parentItem.getFile(), name); + } + } + + /** + * Create file on FTP or local. + */ + public static void createFile(FileItem parentItem, String name) throws IOException { + if (parentItem.isFtp()) { + String parentPath = parentItem.getFtpPath(); + String fullPath = parentPath.equals("/") ? "/" + name : parentPath + "/" + name; + FtpService.createFileOnFtp(parentItem.getFtpProfile(), fullPath); + } else { + createFile(parentItem.getFile(), name); + } + } + + /** + * Delete FTP items. + */ + public static void deleteFromFtp(List items, ProgressCallback callback) throws IOException { + for (FileItem item : items) { + if (callback != null && callback.isCancelled()) break; + FtpService.deleteOnFtp(item.getFtpProfile(), item.getFtpPath()); + if (callback != null) { + callback.onFileProgress(item.getSize(), item.getSize()); + } + } + } + + /** + * Copy to FTP from local or FTP items. + */ + public static void copyToFtp(List items, FtpProfile profile, String remotePath, ProgressCallback callback) throws IOException { + for (FileItem item : items) { + if (callback != null && callback.isCancelled()) break; + + if (item.isDirectory()) { + if (item.isFtp()) { + File tempDir = java.nio.file.Files.createTempDirectory("kf-ftp-copy").toFile(); + copyFromFtp(java.util.Collections.singletonList(item), tempDir, callback); + uploadLocalToFtp(profile, tempDir, remotePath + "/" + item.getName(), callback); + deleteRecursively(tempDir); + } else { + uploadLocalToFtp(profile, item.getFile(), remotePath + "/" + item.getName(), callback); + } + } else { + if (item.isFtp()) { + File tempFile = java.nio.file.Files.createTempFile("kf-ftp-copy", ".tmp").toFile(); + FtpService.downloadFile(item.getFtpProfile(), item.getFtpPath(), tempFile, callback); + FtpService.uploadFile(profile, tempFile, remotePath + "/" + item.getName(), callback); + tempFile.delete(); + } else { + FtpService.uploadFile(profile, item.getFile(), remotePath + "/" + item.getName(), callback); + } + } + } + } + + private static void uploadLocalToFtp(FtpProfile profile, File localSource, String remotePath, ProgressCallback callback) throws IOException { + if (localSource.isDirectory()) { + FtpService.uploadDirectory(profile, localSource, remotePath, callback); + } else { + FtpService.uploadFile(profile, localSource, remotePath + "/" + localSource.getName(), callback); + } + } + + /** + * Copy from FTP to local directory. + */ + public static void copyFromFtp(List ftpItems, File localTarget, ProgressCallback callback) throws IOException { + long totalItems = ftpItems.size(); + long[] currentItem = {0}; + + for (FileItem item : ftpItems) { + if (callback != null && callback.isCancelled()) break; + + if (callback != null) { + callback.onProgress(currentItem[0], totalItems, item.getName()); + } + + if (item.isDirectory()) { + File targetDir = new File(localTarget, item.getName()); + FtpService.downloadDirectory(item.getFtpProfile(), item.getFtpPath(), targetDir, callback); + } else { + File targetFile = new File(localTarget, item.getName()); + FtpService.downloadFile(item.getFtpProfile(), item.getFtpPath(), targetFile, callback); + } + + currentItem[0]++; + if (callback != null) { + callback.onProgress(currentItem[0], totalItems, item.getName()); + } + } + } + + private static void deleteRecursively(File file) { + if (file.isDirectory()) { + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + deleteRecursively(child); + } + } + } + file.delete(); + } + + /** + * Create directory on FTP. + */ + public static void createDirectoryOnFtp(FtpProfile profile, String parentPath, String name) throws IOException { + String fullPath = parentPath.equals("/") ? "/" + name : parentPath + "/" + name; + FtpService.createDirectoryOnFtp(profile, fullPath); + } + + /** + * Create file on FTP. + */ + public static void createFileOnFtp(FtpProfile profile, String parentPath, String name) throws IOException { + String fullPath = parentPath.equals("/") ? "/" + name : parentPath + "/" + name; + FtpService.createFileOnFtp(profile, fullPath); + } + /** * Create a new directory */ diff --git a/src/main/java/cz/kamma/kfmanager/service/FtpService.java b/src/main/java/cz/kamma/kfmanager/service/FtpService.java new file mode 100644 index 0000000..95398b2 --- /dev/null +++ b/src/main/java/cz/kamma/kfmanager/service/FtpService.java @@ -0,0 +1,535 @@ +package cz.kamma.kfmanager.service; + +import cz.kamma.kfmanager.config.AppConfig; +import cz.kamma.kfmanager.model.FileItem; +import cz.kamma.kfmanager.model.FtpProfile; + +import org.apache.commons.net.ftp.FTP; +import org.apache.commons.net.ftp.FTPClient; +import org.apache.commons.net.ftp.FTPFile; +import org.apache.commons.net.ftp.FTPReply; + +import java.io.*; +import java.text.SimpleDateFormat; +import java.util.*; + +/** + * Service for FTP operations using Apache Commons Net + */ +public class FtpService { + + private static final String FTP_PREFIX = "ftp://"; + + /** Log all FTP commands to stderr for debugging */ + public static void log(String msg) { + System.err.println("[FTP] " + msg); + } + + /** + * Connect and login to FTP server. Returns configured FTPClient. + * Caller is responsible for closing the client. + */ + public static FTPClient connect(FtpProfile profile) throws IOException { + FTPClient client = new FTPClient(); + client.setRemoteVerificationEnabled(false); + client.setControlEncoding("UTF-8"); + + log("CMD: connect " + profile.getHost() + ":" + profile.getPort()); + client.connect(profile.getHost(), profile.getPort()); + int reply = client.getReplyCode(); + log("RESP: " + reply + " " + client.getReplyString()); + + if (!FTPReply.isPositiveCompletion(reply)) { + client.disconnect(); + throw new IOException("FTP connection rejected: " + client.getReplyString()); + } + + String user = (profile.getUsername() == null || profile.getUsername().isEmpty()) ? "anonymous" : profile.getUsername(); + String pass = (profile.getPassword() == null) ? "" : profile.getPassword(); + + log("CMD: login " + user + "@" + profile.getHost()); + boolean loggedIn = client.login(user, pass); + String loginReply = client.getReplyString(); + log("RESP: " + (loggedIn ? "230 Login successful" : "550 Login failed") + " " + loginReply); + + if (!loggedIn) { + client.disconnect(); + throw new IOException("FTP login failed for user: " + user + ". Reply: " + loginReply); + } + + // Set parameters AFTER login + client.setFileType(FTP.BINARY_FILE_TYPE); + if (profile.isPassiveMode()) { + log("CMD: enterLocalPassiveMode"); + client.enterLocalPassiveMode(); + } + + return client; + } + + /** + * List directory contents at the given remote path. + */ + public static List listDirectory(FtpProfile profile, String remotePath) throws IOException { + FTPClient client = null; + try { + client = connect(profile); + return listDirectory(client, profile, remotePath); + } finally { + if (client != null) { + try { client.logout(); client.disconnect(); } catch (IOException ignore) {} + } + } + } + + /** + * List directory contents using an already-connected client. + */ + public static List listDirectory(FTPClient client, FtpProfile profile, String remotePath) throws IOException { + log("CMD: cwd " + remotePath); + boolean changed = client.changeWorkingDirectory(remotePath); + log("RESP: " + (changed ? "250 CWD successful" : "550 CWD failed") + " " + client.getReplyString()); + + if (!changed) { + throw new IOException("Cannot access directory: " + remotePath); + } + + FTPFile[] entries = client.listFiles(); + log("RESP: " + entries.length + " entries listed"); + + return parseFtpEntries(client, remotePath, profile, entries); + } + + /** + * Parse FTPFile[] into FileItem list. + */ + public static List parseFtpEntries(FTPClient client, String remotePath, FtpProfile profile, FTPFile[] entries) { + List items = new ArrayList<>(); + + // Add ".." parent entry + String parentPath = getParentPath(remotePath); + if (!parentPath.equals(remotePath)) { + FileItem parent = new FileItem("..", -1, new Date(0), true, parentPath, profile); + items.add(parent); + } + + for (FTPFile entry : entries) { + if (entry.getName().equals(".") || entry.getName().equals("..")) continue; + + String entryPath = remotePath.endsWith("/") ? remotePath + entry.getName() : remotePath + "/" + entry.getName(); + FileItem item = new FileItem( + entry.getName(), + entry.getSize(), + entry.getTimestamp() != null ? new Date(entry.getTimestamp().getTimeInMillis()) : new Date(0), + entry.isDirectory(), + entryPath, + profile + ); + items.add(item); + } + + // Sort: directories first, then by name case-insensitive + items.sort(Comparator.comparing((FileItem f) -> !f.isDirectory()) + .thenComparing(FileItem::getName, String.CASE_INSENSITIVE_ORDER)); + + return items; + } + + /** + * Download a single file from FTP to local filesystem. + */ + public static void downloadFile(FtpProfile profile, String remotePath, File localTarget, FileOperations.ProgressCallback callback) throws IOException { + FTPClient client = null; + try { + client = connect(profile); + + client.setFileType(FTP.BINARY_FILE_TYPE); + log("CMD: retr " + remotePath); + + long fileSize = 0; + try { + // Try to get size before downloading + FTPFile[] files = client.listFiles(remotePath); + if (files != null && files.length > 0) { + fileSize = files[0].getSize(); + } + } catch (Exception ignore) {} + + FileOutputStream fos = new FileOutputStream(localTarget); + boolean success = client.retrieveFile(remotePath, fos); + fos.close(); + + log("RESP: " + (success ? "226 Transfer complete" : "550 Transfer failed")); + + log("RESP: " + (success ? "226 Transfer complete" : "550 Transfer failed")); + + if (!success) { + localTarget.delete(); + throw new IOException("Failed to download: " + remotePath); + } + + if (callback != null) { + callback.onFileProgress(fileSize, fileSize); + } + } finally { + if (client != null) { + try { client.logout(); client.disconnect(); } catch (IOException ignore) {} + } + } + } + + /** + * Upload a single local file to FTP. + */ + public static void uploadFile(FtpProfile profile, File localSource, String remotePath, FileOperations.ProgressCallback callback) throws IOException { + FTPClient client = null; + try { + client = connect(profile); + + // Ensure remote parent directory exists + String parentDir = getParentPath(remotePath); + if (parentDir != null && !parentDir.equals("/") && !parentDir.isEmpty()) { + ensureDirectoryExists(client, profile, parentDir); + } + + client.setFileType(FTP.BINARY_FILE_TYPE); + log("CMD: stor " + remotePath); + + long fileSize = localSource.length(); + FileInputStream fis = new FileInputStream(localSource); + boolean success = client.storeFile(remotePath, fis); + fis.close(); + + log("RESP: " + (success ? "226 Transfer complete" : "550 Transfer failed")); + + if (!success) { + throw new IOException("Failed to upload: " + remotePath); + } + + if (callback != null) { + callback.onFileProgress(fileSize, fileSize); + } + } finally { + if (client != null) { + try { client.logout(); client.disconnect(); } catch (IOException ignore) {} + } + } + } + + /** + * Upload an entire local directory to FTP. + */ + public static void uploadDirectory(FtpProfile profile, File localDir, String remoteParentPath, FileOperations.ProgressCallback callback) throws IOException { + FTPClient client = null; + try { + client = connect(profile); + uploadDirectoryInternal(client, profile, localDir, localDir.getName(), remoteParentPath, callback); + } finally { + if (client != null) { + try { client.logout(); client.disconnect(); } catch (IOException ignore) {} + } + } + } + + private static void uploadDirectoryInternal(FTPClient client, FtpProfile profile, File localDir, String dirName, String remoteParentPath, FileOperations.ProgressCallback callback) throws IOException { + String remoteDirPath = remoteParentPath.equals("/") ? "/" + dirName : remoteParentPath + "/" + dirName; + + log("CMD: mkdir " + remoteDirPath); + client.makeDirectory(remoteDirPath); + log("RESP: " + client.getReplyString()); + + File[] files = localDir.listFiles(); + if (files == null) return; + + long totalSize = 0; + for (File f : files) { + if (f.isDirectory()) { + uploadDirectoryInternal(client, profile, f, f.getName(), remoteDirPath, callback); + } else { + totalSize += f.length(); + } + } + + long current = 0; + for (File f : files) { + if (f.isFile()) { + client.setFileType(FTP.BINARY_FILE_TYPE); + log("CMD: stor " + f.getName()); + FileInputStream fis = new FileInputStream(f); + boolean success = client.storeFile(f.getName(), fis); + fis.close(); + log("RESP: " + (success ? "226 Transfer complete" : "550 Transfer failed")); + if (!success) { + throw new IOException("Failed to upload: " + f.getName()); + } + current += f.length(); + if (callback != null) { + callback.onFileProgress(current, totalSize); + } + } + } + } + + /** + * Download a single directory from FTP to local. + */ + public static void downloadDirectory(FtpProfile profile, String remoteDirPath, File localTarget, FileOperations.ProgressCallback callback) throws IOException { + FTPClient client = null; + try { + client = connect(profile); + downloadDirectoryInternal(client, profile, remoteDirPath, localTarget, callback); + } finally { + if (client != null) { + try { client.logout(); client.disconnect(); } catch (IOException ignore) {} + } + } + } + + private static void downloadDirectoryInternal(FTPClient client, FtpProfile profile, String remoteDirPath, File localTarget, FileOperations.ProgressCallback callback) throws IOException { + localTarget.mkdirs(); + + log("CMD: cwd " + remoteDirPath); + client.changeWorkingDirectory(remoteDirPath); + log("RESP: " + client.getReplyString()); + + FTPFile[] entries = client.listFiles(); + if (entries == null) return; + + for (FTPFile entry : entries) { + if (entry.getName().equals(".") || entry.getName().equals("..")) continue; + + String entryPath = remoteDirPath.equals("/") ? "/" + entry.getName() : remoteDirPath + "/" + entry.getName(); + File localEntry = new File(localTarget, entry.getName()); + + if (entry.isDirectory()) { + downloadDirectoryInternal(client, profile, entryPath, localEntry, callback); + } else { + client.setFileType(FTP.BINARY_FILE_TYPE); + log("CMD: retr " + entry.getName()); + FileOutputStream fos = new FileOutputStream(localEntry); + boolean success = client.retrieveFile(entry.getName(), fos); + fos.close(); + log("RESP: " + (success ? "226 Transfer complete" : "550 Transfer failed")); + if (!success) { + throw new IOException("Failed to download: " + entry.getName()); + } + if (callback != null) { + callback.onFileProgress(entry.getSize(), entry.getSize()); + } + } + } + } + + /** + * Delete a file or directory on FTP. + */ + public static void deleteOnFtp(FtpProfile profile, String remotePath) throws IOException { + FTPClient client = null; + try { + client = connect(profile); + removeRecursively(client, remotePath); + } finally { + if (client != null) { + try { client.logout(); client.disconnect(); } catch (IOException ignore) {} + } + } + } + + private static void removeRecursively(FTPClient client, String remotePath) throws IOException { + log("CMD: cwd " + remotePath); + boolean inDir = client.changeWorkingDirectory(remotePath); + log("RESP: " + client.getReplyString()); + + if (inDir) { + // It's a directory, delete contents first + FTPFile[] entries = client.listFiles(); + if (entries != null) { + for (FTPFile entry : entries) { + if (entry.getName().equals(".") || entry.getName().equals("..")) continue; + String entryPath = remotePath.equals("/") ? "/" + entry.getName() : remotePath + "/" + entry.getName(); + if (entry.isDirectory()) { + removeRecursively(client, entryPath); + } else { + log("CMD: rm " + entry.getName()); + client.deleteFile(entry.getName()); + } + } + } + // Go back to parent and remove directory + String parent = getParentPath(remotePath); + if (!parent.equals(remotePath)) { + client.changeWorkingDirectory(parent); + } + } + + log("CMD: rmd " + remotePath); + boolean removed = client.removeDirectory(remotePath); + log("RESP: " + (removed ? "250 Directory removed" : "550 Removal failed")); + if (!removed) { + // Try as file + log("CMD: rm " + remotePath); + boolean fileRemoved = client.deleteFile(remotePath); + log("RESP: " + (fileRemoved ? "250 File removed" : "550 File removal failed")); + if (!fileRemoved) { + throw new IOException("Failed to delete: " + remotePath); + } + } + } + + /** + * Create directory on FTP. + */ + public static void createDirectoryOnFtp(FtpProfile profile, String remotePath) throws IOException { + FTPClient client = null; + try { + client = connect(profile); + ensureDirectoryExists(client, profile, remotePath); + } finally { + if (client != null) { + try { client.logout(); client.disconnect(); } catch (IOException ignore) {} + } + } + } + + /** + * Ensure a directory exists on FTP, creating parent dirs as needed. + */ + public static void ensureDirectoryExists(FTPClient client, FtpProfile profile, String dirPath) throws IOException { + if (dirPath == null || dirPath.isEmpty() || dirPath.equals("/")) return; + + // Create parent first + String parent = getParentPath(dirPath); + if (!parent.equals(dirPath) && !parent.isEmpty()) { + ensureDirectoryExists(client, profile, parent); + } + + log("CMD: mkdir " + dirPath); + boolean created = client.makeDirectory(dirPath); + log("RESP: " + (created ? "257 Created" : "550 Already exists or error")); + } + + /** + * Rename on FTP. + */ + public static void renameOnFtp(FtpProfile profile, String oldPath, String newName) throws IOException { + FTPClient client = null; + try { + client = connect(profile); + + // Ensure parent directory exists + String parentDir = new File(oldPath).getParent(); + if (parentDir != null && !parentDir.equals("/") && !parentDir.isEmpty()) { + ensureDirectoryExists(client, profile, parentDir); + } + + // Rename using full old path and new full path + String newFullPath = parentDir.equals("/") ? "/" + newName : parentDir + "/" + newName; + log("CMD: renm " + oldPath + " -> " + newFullPath); + boolean success = client.rename(oldPath, newFullPath); + log("RESP: " + (success ? "250 Rename successful" : "550 Rename failed") + " " + client.getReplyString()); + + if (!success) { + throw new IOException("Failed to rename: " + oldPath + " to " + newName); + } + } finally { + if (client != null) { + try { client.logout(); client.disconnect(); } catch (IOException ignore) {} + } + } + } + + /** + * Create an empty file on FTP. + */ + public static void createFileOnFtp(FtpProfile profile, String remotePath) throws IOException { + FTPClient client = null; + try { + client = connect(profile); + + String parentDir = new File(remotePath).getParent(); + if (parentDir != null && !parentDir.equals("/") && !parentDir.isEmpty()) { + ensureDirectoryExists(client, profile, parentDir); + } + + String fileName = new File(remotePath).getName(); + log("CMD: stor " + fileName + " (empty)"); + boolean success = client.storeFile(fileName, new ByteArrayInputStream(new byte[0])); + log("RESP: " + (success ? "226 Transfer complete" : "550 Transfer failed")); + + if (!success) { + throw new IOException("Failed to create file: " + fileName); + } + } finally { + if (client != null) { + try { client.logout(); client.disconnect(); } catch (IOException ignore) {} + } + } + } + + /** + * Get the parent path of a remote FTP path. + */ + public static String getParentPath(String path) { + if (path == null || path.equals("/")) return "/"; + + String normalized = path.endsWith("/") ? path.substring(0, path.length() - 1) : path; + int lastSlash = normalized.lastIndexOf('/'); + + if (lastSlash <= 0) return "/"; + return normalized.substring(0, lastSlash); + } + + /** + * Get the current working directory from an FTP client. + */ + public static String getCurrentDirectory(FTPClient client) throws IOException { + log("CMD: pwd"); + String pwd = client.printWorkingDirectory(); + log("RESP: " + pwd); + return pwd; + } + + /** + * Parse an FTP URL (ftp://host:port/path) and find matching profile from AppConfig. + */ + public static FtpProfile findProfileForUrl(String ftpUrl, AppConfig config) { + if (ftpUrl == null || !ftpUrl.startsWith(FTP_PREFIX)) return null; + + String withoutProtocol = ftpUrl.substring(FTP_PREFIX.length()); + int pathSep = withoutProtocol.indexOf('/'); + String hostPart = pathSep >= 0 ? withoutProtocol.substring(0, pathSep) : withoutProtocol; + + int portSep = hostPart.indexOf(':'); + String host = portSep >= 0 ? hostPart.substring(0, portSep) : hostPart; + int port = portSep >= 0 ? Integer.parseInt(hostPart.substring(portSep + 1)) : 21; + + if (config != null) { + List profiles = config.getFtpProfiles(); + for (FtpProfile p : profiles) { + if (p.getHost().equalsIgnoreCase(host) && p.getPort() == port) { + return p; + } + } + } + + // No matching profile found - create a minimal one + FtpProfile fallback = new FtpProfile(); + fallback.setHost(host); + fallback.setPort(port); + return fallback; + } + + /** + * Build an FTP URL from profile and remote path. + */ + public static String buildFtpUrl(FtpProfile profile, String remotePath) { + if (remotePath == null || remotePath.isEmpty()) remotePath = "/"; + return FTP_PREFIX + profile.getHost() + ":" + profile.getPort() + remotePath; + } + + private static String getNameFromPath(String path) { + if (path == null || path.equals("/") || path.isEmpty()) return ""; + int lastSlash = path.lastIndexOf('/'); + return path.substring(lastSlash + 1); + } +} diff --git a/src/main/java/cz/kamma/kfmanager/ui/FilePanel.java b/src/main/java/cz/kamma/kfmanager/ui/FilePanel.java index 12dffe5..d848006 100644 --- a/src/main/java/cz/kamma/kfmanager/ui/FilePanel.java +++ b/src/main/java/cz/kamma/kfmanager/ui/FilePanel.java @@ -2,6 +2,8 @@ package cz.kamma.kfmanager.ui; import cz.kamma.kfmanager.MainApp; import cz.kamma.kfmanager.model.FileItem; +import cz.kamma.kfmanager.model.FtpProfile; +import cz.kamma.kfmanager.service.FtpService; import javax.swing.*; import javax.swing.event.PopupMenuEvent; @@ -422,6 +424,68 @@ public class FilePanel extends JPanel { }); } + /** + * Add a new FTP tab. + */ + public void addFtpTab(String ftpPath, FtpProfile profile) { + FilePanelTab tab = new FilePanelTab(ftpPath, true); + if (appConfig != null) tab.setAppConfig(appConfig); + tab.setActive(this.active); + tab.setOnDirectoryChanged(() -> { + updateTabTitle(tab); + if (onDirectoryChangedAll != null) onDirectoryChangedAll.run(); + }); + tab.setOnSwitchPanelRequested(switchPanelCallback); + + // Extract display name from FTP URL for tab title + String tabTitle = getFtpTabTitle(ftpPath); + + tabbedPane.addTab(tabTitle, tab); + tabbedPane.setSelectedComponent(tab); + + addMouseListenerToComponents(tab); + updateTabStyles(); + updateDriveComboVisibility(); + + if (onTableCreated != null) { + onTableCreated.accept(tab.getFileTable()); + } + + SwingUtilities.invokeLater(() -> { + if (tab.getFileTable() != null) { + tab.getFileTable().requestFocusInWindow(); + } + tab.ensureRenderers(); + }); + } + + private String getFtpTabTitle(String ftpPath) { + if (ftpPath == null) return "FTP"; + try { + String withoutProtocol = ftpPath.startsWith("ftp://") ? ftpPath.substring(6) : ftpPath; + int pathSep = withoutProtocol.indexOf('/'); + String hostPart = pathSep >= 0 ? withoutProtocol.substring(0, pathSep) : withoutProtocol; + int portSep = hostPart.indexOf(':'); + String host = portSep >= 0 ? hostPart.substring(0, portSep) : hostPart; + return "FTP: " + host; + } catch (Exception e) { + return "FTP"; + } + } + + private void updateDriveComboVisibility() { + FilePanelTab current = getCurrentTab(); + if (current != null && current.isFtpTab()) { + driveCombo.setVisible(false); + driveInfoLabel.setVisible(false); + } else { + driveCombo.setVisible(true); + driveInfoLabel.setVisible(true); + } + revalidate(); + repaint(); + } + /** * Provide AppConfig so tabs can persist/retrieve sort settings */ @@ -441,8 +505,13 @@ public class FilePanel extends JPanel { for (int i = 0; i < tabbedPane.getTabCount(); i++) { Component c = tabbedPane.getComponentAt(i); if (c instanceof FilePanelTab t) { - File dir = t.getCurrentDirectory(); - paths.add(dir != null ? dir.getAbsolutePath() : System.getProperty("user.home")); + if (t.isFtpTab() && t.getFtpProfile() != null) { + String ftpPath = FtpService.buildFtpUrl(t.getFtpProfile(), t.getFtpCurrentPath()); + paths.add(ftpPath); + } else { + File dir = t.getCurrentDirectory(); + paths.add(dir != null ? dir.getAbsolutePath() : System.getProperty("user.home")); + } } } return paths; @@ -489,20 +558,35 @@ public class FilePanel extends JPanel { for (int i = 0; i < paths.size(); i++) { String p = paths.get(i); - ViewMode mode = ViewMode.FULL; - if (viewModes != null && i < viewModes.size()) { - try { - mode = ViewMode.valueOf(viewModes.get(i)); - } catch (IllegalArgumentException ex) { - mode = ViewMode.FULL; + if (p != null && p.startsWith("ftp://")) { + // FTP tab: look up matching profile + FtpProfile profile = null; + if (appConfig != null) { + profile = FtpService.findProfileForUrl(p, appConfig); } + if (profile != null) { + addFtpTab(p, profile); + } else { + // Profile not found, skip this tab + continue; + } + } else { + ViewMode mode = ViewMode.FULL; + if (viewModes != null && i < viewModes.size()) { + try { + mode = ViewMode.valueOf(viewModes.get(i)); + } catch (IllegalArgumentException ex) { + mode = ViewMode.FULL; + } + } + addNewTabWithMode(p, mode, false); } - addNewTabWithMode(p, mode, false); // Restore focus to the specific item if provided - if (focusedItems != null && i < focusedItems.size() && focusedItems.get(i) != null) { + int tabIndex = tabbedPane.getTabCount() - 1; + if (tabIndex >= 0 && focusedItems != null && i < focusedItems.size() && focusedItems.get(i) != null) { final String focusName = focusedItems.get(i); - final FilePanelTab currentTab = (FilePanelTab) tabbedPane.getComponentAt(i); + final FilePanelTab currentTab = (FilePanelTab) tabbedPane.getComponentAt(tabIndex); SwingUtilities.invokeLater(() -> currentTab.selectItem(focusName, requestFocus)); } } @@ -515,6 +599,7 @@ public class FilePanel extends JPanel { updatePathField(); updateTabStyles(); + updateDriveComboVisibility(); } private void populateDrives() { @@ -695,6 +780,25 @@ public class FilePanel extends JPanel { return new Color((rgb >> 16) & 0xFF, (rgb >> 8) & 0xFF, rgb & 0xFF); } + /** + * Remove all FTP tabs from the panel. + */ + public void closeAllFtpTabs() { + for (int i = tabbedPane.getTabCount() - 1; i >= 0; i--) { + Component comp = tabbedPane.getComponentAt(i); + if (comp instanceof FilePanelTab tab && tab.isFtpTab()) { + if (tabbedPane.getTabCount() > 1) { + tabbedPane.removeTabAt(i); + } else { + // If it's the last tab, reset it to home instead of removing + tab.loadDirectory(new File(System.getProperty("user.home"))); + } + } + } + updatePathField(); + updateTabStyles(); + } + /** * Remove the current tab */ diff --git a/src/main/java/cz/kamma/kfmanager/ui/FilePanelTab.java b/src/main/java/cz/kamma/kfmanager/ui/FilePanelTab.java index 39e8058..03aa5bb 100644 --- a/src/main/java/cz/kamma/kfmanager/ui/FilePanelTab.java +++ b/src/main/java/cz/kamma/kfmanager/ui/FilePanelTab.java @@ -2,9 +2,11 @@ package cz.kamma.kfmanager.ui; import cz.kamma.kfmanager.MainApp; import cz.kamma.kfmanager.model.FileItem; +import cz.kamma.kfmanager.model.FtpProfile; import cz.kamma.kfmanager.config.AppConfig; import cz.kamma.kfmanager.service.ClipboardService; import cz.kamma.kfmanager.service.FileOperations; +import cz.kamma.kfmanager.service.FtpService; import javax.swing.*; import javax.swing.filechooser.FileSystemView; @@ -93,15 +95,56 @@ public class FilePanelTab extends JPanel { private JTextField filterTextField; private JPanel filterPanel; private boolean searchModeActive = false; - + // FTP support + private boolean isFtpTab = false; + private FtpProfile ftpProfile; + private String ftpCurrentPath; + public FilePanelTab(String initialPath) { this(initialPath, true); } - + public FilePanelTab(String initialPath, boolean requestFocus) { - this.currentDirectory = new File(initialPath); - initComponents(); - loadDirectory(currentDirectory, true, requestFocus); + if (initialPath != null && initialPath.startsWith("ftp://")) { + this.isFtpTab = true; + this.ftpProfile = FtpService.findProfileForUrl(initialPath, null); + this.ftpCurrentPath = extractFtpPath(initialPath); + this.currentDirectory = new File(initialPath); + } else { + this.isFtpTab = false; + this.ftpProfile = null; + this.currentDirectory = new File(initialPath != null ? initialPath : System.getProperty("user.home")); + } + if (initialPath != null && initialPath.startsWith("ftp://")) { + initComponents(); + loadFtpDirectory(ftpCurrentPath, true, requestFocus); + } else { + initComponents(); + loadDirectory(currentDirectory, true, requestFocus); + } + } + + private static String extractFtpPath(String url) { + if (url == null) return "/"; + String withoutProtocol = url.startsWith("ftp://") ? url.substring(6) : url; + int slashIdx = withoutProtocol.indexOf('/'); + return slashIdx >= 0 ? withoutProtocol.substring(slashIdx) : "/"; + } + + public boolean isFtpTab() { + return isFtpTab; + } + + public FtpProfile getFtpProfile() { + return ftpProfile; + } + + public String getFtpCurrentPath() { + return ftpCurrentPath; + } + + public AppConfig getAppConfigRef() { + return persistedConfig; } /** Start inline rename for currently selected item (if single selection). */ @@ -1508,10 +1551,92 @@ public class FilePanelTab extends JPanel { } } + // --- FTP directory loading --- + + /** + * Load an FTP directory into the table. + */ + public void loadFtpDirectory(final String remotePath, final boolean autoSelectFirst, final boolean requestFocus) { + SwingUtilities.invokeLater(() -> { + try { + if (ftpProfile == null) { + JOptionPane.showMessageDialog(FilePanelTab.this, + "No FTP profile configured for this tab.", "FTP Error", JOptionPane.ERROR_MESSAGE); + return; + } + + List items = FtpService.listDirectory(ftpProfile, remotePath); + this.ftpCurrentPath = remotePath; + String ftpUrl = FtpService.buildFtpUrl(ftpProfile, remotePath); + this.currentDirectory = new File(ftpUrl); + + // Save copy of all items for filtering + allItems.clear(); + allItems.addAll(items); + + // Apply filter if search mode is active + List displayItems = (searchModeActive && filterTextField != null) + ? applyFilter(items, filterTextField.getText()) + : items; + + tableModel.setItems(displayItems); + if (sortColumn >= 0) { + sortItemsByColumn(sortColumn, sortAscending); + tableModel.fireTableDataChanged(); + } + + SwingUtilities.invokeLater(() -> { + if (viewMode == ViewMode.BRIEF) { + tableModel.calculateBriefLayout(); + tableModel.fireTableStructureChanged(); + } else { + tableModel.fireTableDataChanged(); + } + updateColumnRenderers(); + updateColumnWidths(); + fileTable.revalidate(); + fileTable.repaint(); + + if (autoSelectFirst && fileTable.getRowCount() > 0) { + fileTable.setRowSelectionInterval(0, 0); + fileTable.scrollRectToVisible(fileTable.getCellRect(0, 0, true)); + } + + if (requestFocus) { + try { fileTable.requestFocusInWindow(); } catch (Exception ignore) {} + } + updateStatus(); + }); + + if (onDirectoryChanged != null) { + onDirectoryChanged.run(); + } + + } catch (IOException e) { + JOptionPane.showMessageDialog(FilePanelTab.this, + "Failed to list directory: " + e.getMessage(), "FTP Error", JOptionPane.ERROR_MESSAGE); + } + }); + } + + /** + * Navigate up one directory in FTP. + */ + public void navigateFtpUp() { + if (ftpCurrentPath == null || ftpCurrentPath.equals("/")) return; + String parentPath = FtpService.getParentPath(ftpCurrentPath); + loadFtpDirectory(parentPath, true, true); + } + /** * Refresh the current directory while attempting to preserve selection and focus. */ public void refresh(boolean requestFocus) { + if (isFtpTab) { + loadFtpDirectory(ftpCurrentPath, false, requestFocus); + return; + } + List newItems = createFileItemList(currentDirectory); long now = System.currentTimeMillis(); @@ -1798,6 +1923,16 @@ public class FilePanelTab extends JPanel { } if (item == null) return; + + if (item.isFtp()) { + if (item.getName().equals("..")) { + navigateFtpUp(); + } else if (item.isDirectory()) { + String entryPath = item.getFtpPath(); + loadFtpDirectory(entryPath, true, true); + } + return; + } if (item.getName().equals("..")) { navigateUp(); @@ -2321,7 +2456,19 @@ public class FilePanelTab extends JPanel { item = tableModel.getItem(row); } - if (item == null) return; + if (item == null) return; + + if (item.isFtp()) { + // FTP: handle navigation only (no archive support for FTP) + if (item.getName().equals("..")) { + navigateFtpUp(); + } else if (item.isDirectory()) { + String entryPath = item.getFtpPath() + "/" + item.getName(); + loadFtpDirectory(entryPath, true, true); + } + // FTP files: no action on double-click + return; + } if (item.getName().equals("..")) { navigateUp(); @@ -2737,7 +2884,13 @@ public class FilePanelTab extends JPanel { }); } - public void navigateUp() { + public void navigateUp() { + // FTP: navigate up in remote path + if (isFtpTab && ftpProfile != null) { + navigateFtpUp(); + return; + } + // If we're currently browsing an extracted archive root, navigate back to the // original archive's parent and select the archive file. try { @@ -4338,20 +4491,32 @@ public class FilePanelTab extends JPanel { item = getItem(rowIndex); } - if (item == null) return; + if (item == null) return; - // Perform rename using FileOperations and refresh the directory + // Perform rename try { - cz.kamma.kfmanager.service.FileOperations.rename(item.getFile(), newName); - // reload current directory to reflect updated names - FilePanelTab.this.loadDirectory(FilePanelTab.this.getCurrentDirectory()); - // After reload, select the renamed item and focus the table - SwingUtilities.invokeLater(() -> { - try { - FilePanelTab.this.selectItem(newName); - FilePanelTab.this.getFileTable().requestFocusInWindow(); - } catch (Exception ignore) {} - }); + if (item.isFtp()) { + FtpService.renameOnFtp(item.getFtpProfile(), item.getFtpPath(), newName); + String newFtpPath = FtpService.getParentPath(item.getFtpPath()); + loadFtpDirectory(newFtpPath, false, false); + SwingUtilities.invokeLater(() -> { + try { + FilePanelTab.this.selectItem(newName); + FilePanelTab.this.getFileTable().requestFocusInWindow(); + } catch (Exception ignore) {} + }); + } else { + cz.kamma.kfmanager.service.FileOperations.rename(item.getFile(), newName); + // reload current directory to reflect updated names + FilePanelTab.this.loadDirectory(FilePanelTab.this.getCurrentDirectory()); + // After reload, select the renamed item and focus the table + SwingUtilities.invokeLater(() -> { + try { + FilePanelTab.this.selectItem(newName); + FilePanelTab.this.getFileTable().requestFocusInWindow(); + } catch (Exception ignore) {} + }); + } } catch (Exception ex) { // show error to user try { diff --git a/src/main/java/cz/kamma/kfmanager/ui/FtpProfileEditDialog.java b/src/main/java/cz/kamma/kfmanager/ui/FtpProfileEditDialog.java new file mode 100644 index 0000000..59be2ab --- /dev/null +++ b/src/main/java/cz/kamma/kfmanager/ui/FtpProfileEditDialog.java @@ -0,0 +1,155 @@ +package cz.kamma.kfmanager.ui; + +import cz.kamma.kfmanager.config.AppConfig; +import cz.kamma.kfmanager.model.FtpProfile; + +import javax.swing.*; +import java.awt.*; +import java.awt.event.ActionEvent; +import java.awt.event.KeyEvent; + +/** + * Dialog for creating or editing a single FTP profile + */ +public class FtpProfileEditDialog extends JDialog { + + private JTextField nameField; + private JTextField hostField; + private JSpinner portSpinner; + private JTextField usernameField; + private JPasswordField passwordField; + private JCheckBox passiveModeCheck; + + private FtpProfile profile; + private boolean saved = false; + + public FtpProfileEditDialog(Window parent, AppConfig config, FtpProfile existingProfile) { + super(parent, existingProfile == null ? "New FTP Profile" : "Edit FTP Profile", ModalityType.APPLICATION_MODAL); + this.profile = existingProfile != null ? existingProfile : new FtpProfile(); + if (this.profile.getId() == null && existingProfile == null) { + this.profile.setId(config.generateFtpProfileId()); + } + + initComponents(); + loadProfile(); + pack(); + setLocationRelativeTo(parent); + } + + private void initComponents() { + setLayout(new BorderLayout(10, 10)); + JPanel formPanel = new JPanel(new GridBagLayout()); + formPanel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); + GridBagConstraints gbc = new GridBagConstraints(); + gbc.insets = new Insets(4, 4, 4, 4); + gbc.anchor = GridBagConstraints.WEST; + + // Name + gbc.gridx = 0; gbc.gridy = 0; gbc.weightx = 0; + formPanel.add(new JLabel("Name:"), gbc); + gbc.gridx = 1; gbc.weightx = 1; gbc.fill = GridBagConstraints.HORIZONTAL; + nameField = new JTextField(20); + formPanel.add(nameField, gbc); + + // Host + gbc.gridx = 0; gbc.gridy = 1; gbc.weightx = 0; + formPanel.add(new JLabel("Host:"), gbc); + gbc.gridx = 1; gbc.weightx = 1; gbc.fill = GridBagConstraints.HORIZONTAL; + hostField = new JTextField(20); + formPanel.add(hostField, gbc); + + // Port + gbc.gridx = 0; gbc.gridy = 2; gbc.weightx = 0; + formPanel.add(new JLabel("Port:"), gbc); + gbc.gridx = 1; gbc.weightx = 0; gbc.fill = GridBagConstraints.NONE; + SpinnerModel portModel = new javax.swing.SpinnerNumberModel(21, 1, 65535, 1); + portSpinner = new JSpinner(portModel); + portSpinner.setPreferredSize(new Dimension(80, portSpinner.getPreferredSize().height)); + formPanel.add(portSpinner, gbc); + + // Username + gbc.gridx = 0; gbc.gridy = 3; gbc.weightx = 0; + formPanel.add(new JLabel("Username:"), gbc); + gbc.gridx = 1; gbc.weightx = 1; gbc.fill = GridBagConstraints.HORIZONTAL; + usernameField = new JTextField(20); + formPanel.add(usernameField, gbc); + + // Password + gbc.gridx = 0; gbc.gridy = 4; gbc.weightx = 0; + formPanel.add(new JLabel("Password:"), gbc); + gbc.gridx = 1; gbc.weightx = 1; gbc.fill = GridBagConstraints.HORIZONTAL; + passwordField = new JPasswordField(20); + formPanel.add(passwordField, gbc); + + // Passive mode + gbc.gridx = 0; gbc.gridy = 5; gbc.gridwidth = 2; gbc.anchor = GridBagConstraints.WEST; gbc.weightx = 0; + passiveModeCheck = new JCheckBox("Passive mode"); + passiveModeCheck.setSelected(true); + formPanel.add(passiveModeCheck, gbc); + + add(formPanel, BorderLayout.CENTER); + + // Buttons + JPanel btnPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT)); + JButton okButton = new JButton("OK"); + okButton.addActionListener(e -> saveProfile()); + + JButton cancelButton = new JButton("Cancel"); + cancelButton.addActionListener(e -> dispose()); + + btnPanel.add(okButton); + btnPanel.add(cancelButton); + add(btnPanel, BorderLayout.SOUTH); + + // Enter key to save, Escape key to cancel + getRootPane().setDefaultButton(okButton); + getRootPane().getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), "cancel"); + getRootPane().getActionMap().put("cancel", new AbstractAction() { + @Override + public void actionPerformed(ActionEvent e) { dispose(); } + }); + } + + private void loadProfile() { + nameField.setText(profile.getName()); + hostField.setText(profile.getHost()); + portSpinner.setValue(profile.getPort()); + usernameField.setText(profile.getUsername()); + passwordField.setText(profile.getPassword()); + passiveModeCheck.setSelected(profile.isPassiveMode()); + } + + private void saveProfile() { + String name = nameField.getText().trim(); + String host = hostField.getText().trim(); + + if (name.isEmpty()) { + JOptionPane.showMessageDialog(this, "Name is required.", "Validation Error", JOptionPane.WARNING_MESSAGE); + nameField.requestFocusInWindow(); + return; + } + if (host.isEmpty()) { + JOptionPane.showMessageDialog(this, "Host is required.", "Validation Error", JOptionPane.WARNING_MESSAGE); + hostField.requestFocusInWindow(); + return; + } + + profile.setName(name); + profile.setHost(host); + profile.setPort((Integer) portSpinner.getValue()); + profile.setUsername(usernameField.getText().trim()); + profile.setPassword(new String(passwordField.getPassword())); + profile.setPassiveMode(passiveModeCheck.isSelected()); + + saved = true; + dispose(); + } + + public boolean isSaved() { + return saved; + } + + public FtpProfile getProfile() { + return profile; + } +} diff --git a/src/main/java/cz/kamma/kfmanager/ui/FtpProfileManagerDialog.java b/src/main/java/cz/kamma/kfmanager/ui/FtpProfileManagerDialog.java new file mode 100644 index 0000000..57500c3 --- /dev/null +++ b/src/main/java/cz/kamma/kfmanager/ui/FtpProfileManagerDialog.java @@ -0,0 +1,151 @@ +package cz.kamma.kfmanager.ui; + +import cz.kamma.kfmanager.config.AppConfig; +import cz.kamma.kfmanager.model.FtpProfile; + +import javax.swing.*; +import javax.swing.table.DefaultTableModel; +import java.awt.*; +import java.util.List; +import java.util.function.Consumer; + +/** + * Dialog for managing FTP profiles (CRUD operations) + */ +public class FtpProfileManagerDialog extends JDialog { + + private JTable profileTable; + private DefaultTableModel tableModel; + private AppConfig config; + private Consumer onConnectRequested; + + public FtpProfileManagerDialog(Frame parent, AppConfig config) { + this(parent, config, null); + } + + public FtpProfileManagerDialog(Frame parent, AppConfig config, Consumer onConnectRequested) { + super(parent, "FTP Profiles", ModalityType.APPLICATION_MODAL); + this.config = config; + this.onConnectRequested = onConnectRequested; + + initComponents(); + loadProfiles(); + pack(); + setLocationRelativeTo(parent); + } + + private void initComponents() { + setLayout(new BorderLayout(10, 10)); + + // Table + String[] columns = {"Name", "Host", "Port", "Username"}; + tableModel = new DefaultTableModel(columns, 0) { + @Override + public boolean isCellEditable(int row, int column) { + return false; + } + }; + profileTable = new JTable(tableModel); + profileTable.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + profileTable.setRowHeight(24); + + JScrollPane scrollPane = new JScrollPane(profileTable); + add(scrollPane, BorderLayout.CENTER); + + // Buttons + JPanel btnPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 5, 0)); + + JButton newButton = new JButton("New"); + newButton.addActionListener(e -> createProfile()); + + JButton editButton = new JButton("Edit"); + editButton.addActionListener(e -> editProfile()); + + JButton deleteButton = new JButton("Delete"); + deleteButton.addActionListener(e -> deleteProfile()); + + JButton connectButton = new JButton("Connect"); + connectButton.setToolTipText("Connect to selected profile in current panel"); + connectButton.addActionListener(e -> connectToProfile()); + + JButton closeButton = new JButton("Close"); + closeButton.addActionListener(e -> dispose()); + + btnPanel.add(newButton); + btnPanel.add(editButton); + btnPanel.add(deleteButton); + btnPanel.add(connectButton); + btnPanel.add(closeButton); + add(btnPanel, BorderLayout.SOUTH); + } + + private void loadProfiles() { + tableModel.setRowCount(0); + List profiles = config.getFtpProfiles(); + for (FtpProfile p : profiles) { + tableModel.addRow(new Object[]{ + p.getName(), p.getHost(), p.getPort(), p.getUsername() + }); + } + } + + private void createProfile() { + FtpProfileEditDialog dialog = new FtpProfileEditDialog(this, config, null); + dialog.setVisible(true); + if (dialog.isSaved()) { + List profiles = config.getFtpProfiles(); + profiles.add(dialog.getProfile()); + config.saveFtpProfiles(profiles); + loadProfiles(); + } + } + + private void editProfile() { + int row = profileTable.getSelectedRow(); + if (row < 0) { + JOptionPane.showMessageDialog(this, "Select a profile to edit.", "No Selection", JOptionPane.INFORMATION_MESSAGE); + return; + } + List profiles = config.getFtpProfiles(); + FtpProfile profile = profiles.get(row); + FtpProfileEditDialog dialog = new FtpProfileEditDialog(this, config, profile); + dialog.setVisible(true); + if (dialog.isSaved()) { + profiles.set(row, dialog.getProfile()); + config.saveFtpProfiles(profiles); + loadProfiles(); + } + } + + private void deleteProfile() { + int row = profileTable.getSelectedRow(); + if (row < 0) { + JOptionPane.showMessageDialog(this, "Select a profile to delete.", "No Selection", JOptionPane.INFORMATION_MESSAGE); + return; + } + int result = JOptionPane.showConfirmDialog(this, + "Delete profile \"" + profileTable.getValueAt(row, 0) + "\"?", + "Confirm Delete", JOptionPane.YES_NO_OPTION); + if (result != JOptionPane.YES_OPTION) return; + + List profiles = config.getFtpProfiles(); + profiles.remove(row); + config.saveFtpProfiles(profiles); + loadProfiles(); + } + + private void connectToProfile() { + int row = profileTable.getSelectedRow(); + if (row < 0) { + JOptionPane.showMessageDialog(this, "Select a profile to connect.", "No Selection", JOptionPane.INFORMATION_MESSAGE); + return; + } + List profiles = config.getFtpProfiles(); + FtpProfile profile = profiles.get(row); + dispose(); + + if (onConnectRequested != null) { + onConnectRequested.accept(profile); + } + } +} diff --git a/src/main/java/cz/kamma/kfmanager/ui/MainWindow.java b/src/main/java/cz/kamma/kfmanager/ui/MainWindow.java index 9e691b1..985614c 100644 --- a/src/main/java/cz/kamma/kfmanager/ui/MainWindow.java +++ b/src/main/java/cz/kamma/kfmanager/ui/MainWindow.java @@ -3,14 +3,14 @@ package cz.kamma.kfmanager.ui; import cz.kamma.kfmanager.MainApp; import cz.kamma.kfmanager.config.AppConfig; import cz.kamma.kfmanager.model.FileItem; +import cz.kamma.kfmanager.model.FtpProfile; import cz.kamma.kfmanager.service.ClipboardService; import cz.kamma.kfmanager.service.FileOperations; import cz.kamma.kfmanager.service.FileOperationQueue; +import cz.kamma.kfmanager.service.FtpService; import javax.swing.*; import javax.swing.filechooser.FileSystemView; -import javax.swing.event.AncestorListener; -import javax.swing.event.AncestorEvent; import java.awt.*; import java.awt.image.BufferedImage; import java.awt.datatransfer.DataFlavor; @@ -986,14 +986,21 @@ public class MainWindow extends JFrame { refreshItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_F5, InputEvent.CTRL_DOWN_MASK)); refreshItem.addActionListener(e -> refreshPanels()); - JMenuItem queueItem = new JMenuItem("Operations Queue..."); + JMenuItem queueItem = new JMenuItem("Operations Queue..."); queueItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_Q, InputEvent.ALT_DOWN_MASK)); queueItem.addActionListener(e -> OperationQueueDialog.showQueue(this)); - - JMenuItem exitItem = new JMenuItem("Exit"); + + JMenuItem ftpConnectItem = new JMenuItem("Connect to FTP..."); + ftpConnectItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_F, InputEvent.CTRL_DOWN_MASK)); + ftpConnectItem.addActionListener(e -> showFtpConnectDialog()); + + JMenuItem ftpProfileItem = new JMenuItem("FTP Profiles..."); + ftpProfileItem.addActionListener(e -> showFtpProfileManagerDialog()); + + JMenuItem exitItem = new JMenuItem("Exit"); exitItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_F10, 0)); exitItem.addActionListener(e -> saveConfigAndExit()); - + fileMenu.add(searchItem); fileMenu.add(selectAllItem); fileMenu.add(selectWildcardItem); @@ -1001,6 +1008,9 @@ public class MainWindow extends JFrame { fileMenu.add(refreshItem); fileMenu.add(queueItem); fileMenu.addSeparator(); + fileMenu.add(ftpConnectItem); + fileMenu.add(ftpProfileItem); + fileMenu.addSeparator(); fileMenu.add(exitItem); // View menu @@ -1349,11 +1359,16 @@ public class MainWindow extends JFrame { KeyStroke.getKeyStroke(KeyEvent.VK_T, InputEvent.CTRL_DOWN_MASK), JComponent.WHEN_IN_FOCUSED_WINDOW); - // Ctrl+W - Close current tab + // Ctrl+W - Close current tab rootPane.registerKeyboardAction(e -> closeCurrentTabInActivePanel(), KeyStroke.getKeyStroke(KeyEvent.VK_W, InputEvent.CTRL_DOWN_MASK), JComponent.WHEN_IN_FOCUSED_WINDOW); + // Ctrl+F - Connect to FTP + rootPane.registerKeyboardAction(e -> showFtpConnectDialog(), + KeyStroke.getKeyStroke(KeyEvent.VK_F, InputEvent.CTRL_DOWN_MASK), + JComponent.WHEN_IN_FOCUSED_WINDOW); + // Ctrl+` - Home directory rootPane.registerKeyboardAction(e -> { if (activePanel != null && activePanel.getCurrentTab() != null) { @@ -1749,7 +1764,7 @@ public class MainWindow extends JFrame { commandLine.requestFocusInWindow(); } - /** + /** * Copy selected files to the opposite panel */ private void copyFiles() { @@ -1762,37 +1777,73 @@ public class MainWindow extends JFrame { requestFocusInActivePanel(); return; } - - FilePanel targetPanel = (activePanel == leftPanel) ? rightPanel : leftPanel; - File targetDir = targetPanel.getCurrentDirectory(); - FilePanel sourcePanel = activePanel; - int result = showConfirmWithBackground( - "Copy %d items to:\n%s".formatted(selectedItems.size(), targetDir.getAbsolutePath()), + FilePanel targetPanel = (activePanel == leftPanel) ? rightPanel : leftPanel; + FilePanelTab targetTab = targetPanel.getCurrentTab(); + boolean targetIsFtp = targetTab != null && targetTab.isFtpTab(); + FilePanel sourcePanel = activePanel; + FilePanelTab sourceTab = sourcePanel.getCurrentTab(); + boolean sourceIsFtp = sourceTab != null && sourceTab.isFtpTab(); + + if (targetIsFtp && targetTab.getFtpProfile() != null) { + FtpProfile ftpProfile = targetTab.getFtpProfile(); + String targetPath = targetTab.getFtpCurrentPath(); + String msg = "Copy %d items to:\nftp://%s:%d%s".formatted( + selectedItems.size(), ftpProfile.getHost(), ftpProfile.getPort(), targetPath); + int result = showConfirmWithBackground(msg, "Copy"); + if (result == 0 || result == 1) { + boolean background = (result == 1); + if (background) { + addOperationToQueue("Copy", "Copy %d items to FTP".formatted(selectedItems.size()), + (cb) -> FileOperations.copyToFtp(selectedItems, ftpProfile, targetPath, cb), + () -> sourcePanel.unselectAll(), targetPanel); + } else { + performFileOperation((cb) -> FileOperations.copyToFtp(selectedItems, ftpProfile, targetPath, cb), + "Copy completed", false, true, () -> sourcePanel.unselectAll(), targetPanel); + } + } + } else if (sourceIsFtp) { + File targetDir = targetPanel.getCurrentDirectory(); + int result = showConfirmWithBackground( + "Copy %d items from FTP to:\n%s".formatted(selectedItems.size(), targetDir.getAbsolutePath()), "Copy"); - - if (result == 0 || result == 1) { - boolean background = (result == 1); - if (background) { - addOperationToQueue("Copy", "Copy %d items to %s".formatted(selectedItems.size(), targetDir.getName()), - (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); + + if (result == 0 || result == 1) { + boolean background = (result == 1); + if (background) { + addOperationToQueue("Copy", "Copy %d items from FTP".formatted(selectedItems.size()), + (cb) -> FileOperations.copyFromFtp(selectedItems, targetDir, cb), + () -> sourcePanel.unselectAll(), targetPanel); + } else { + performFileOperation((cb) -> FileOperations.copyFromFtp(selectedItems, targetDir, cb), + "Copy from FTP completed", false, true, () -> sourcePanel.unselectAll(), targetPanel); + } } } else { - if (activePanel != null && activePanel.getFileTable() != null) { - activePanel.getFileTable().requestFocusInWindow(); + File targetDir = targetPanel.getCurrentDirectory(); + int result = showConfirmWithBackground( + "Copy %d items to:\n%s".formatted(selectedItems.size(), targetDir.getAbsolutePath()), + "Copy"); + + if (result == 0 || result == 1) { + boolean background = (result == 1); + if (background) { + addOperationToQueue("Copy", "Copy %d items to %s".formatted(selectedItems.size(), targetDir.getName()), + (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); + } } } } - /** + /** * Move selected files to the opposite panel */ private void moveFiles() { @@ -1805,31 +1856,83 @@ public class MainWindow extends JFrame { requestFocusInActivePanel(); return; } - + FilePanel targetPanel = (activePanel == leftPanel) ? rightPanel : leftPanel; - File targetDir = targetPanel.getCurrentDirectory(); - - int result = showConfirmWithBackground( - "Move %d items to:\n%s".formatted(selectedItems.size(), targetDir.getAbsolutePath()), - "Move"); - - if (result == 0 || result == 1) { - boolean background = (result == 1); - if (background) { - addOperationToQueue("Move", "Move %d items to %s".formatted(selectedItems.size(), targetDir.getName()), - (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); + FilePanelTab targetTab = targetPanel.getCurrentTab(); + boolean targetIsFtp = targetTab != null && targetTab.isFtpTab(); + boolean sourceIsFtp = !selectedItems.isEmpty() && selectedItems.get(0).isFtp(); + + if (targetIsFtp && targetTab.getFtpProfile() != null) { + // Move to FTP (copy to FTP, then delete source) + FtpProfile ftpProfile = targetTab.getFtpProfile(); + String targetPath = targetTab.getFtpCurrentPath(); + String msg = "Move %d items to:\nftp://%s:%d%s".formatted( + selectedItems.size(), ftpProfile.getHost(), ftpProfile.getPort(), targetPath); + int result = showConfirmWithBackground(msg, "Move"); + if (result == 0 || result == 1) { + boolean background = (result == 1); + if (background) { + addOperationToQueue("Move", "Move %d items to FTP".formatted(selectedItems.size()), + (cb) -> { + FileOperations.copyToFtp(selectedItems, ftpProfile, targetPath, cb); + if (sourceIsFtp) { + FileOperations.deleteFromFtp(selectedItems, cb); + } else { + FileOperations.delete(selectedItems, cb); + } + }, activePanel, targetPanel); + } else { + performFileOperation((cb) -> { + FileOperations.copyToFtp(selectedItems, ftpProfile, targetPath, cb); + if (sourceIsFtp) { + FileOperations.deleteFromFtp(selectedItems, cb); + } else { + FileOperations.delete(selectedItems, cb); + } + }, "Move completed", false, true, activePanel, targetPanel); + } + } + } else if (sourceIsFtp) { + // Move from FTP to local (copy from FTP, then delete source) + File targetDir = targetPanel.getCurrentDirectory(); + int result = showConfirmWithBackground( + "Move %d items to:\n%s".formatted(selectedItems.size(), targetDir.getAbsolutePath()), + "Move"); + if (result == 0 || result == 1) { + boolean background = (result == 1); + if (background) { + addOperationToQueue("Move", "Move %d items from FTP".formatted(selectedItems.size()), + (cb) -> { + FileOperations.copyFromFtp(selectedItems, targetDir, cb); + FileOperations.deleteFromFtp(selectedItems, cb); + }, activePanel, targetPanel); + } else { + performFileOperation((cb) -> { + FileOperations.copyFromFtp(selectedItems, targetDir, cb); + FileOperations.deleteFromFtp(selectedItems, cb); + }, "Move completed", false, true, activePanel, targetPanel); + } } } else { - if (activePanel != null && activePanel.getFileTable() != null) { - activePanel.getFileTable().requestFocusInWindow(); + File targetDir = targetPanel.getCurrentDirectory(); + int result = showConfirmWithBackground( + "Move %d items to:\n%s".formatted(selectedItems.size(), targetDir.getAbsolutePath()), + "Move"); + + if (result == 0 || result == 1) { + boolean background = (result == 1); + if (background) { + addOperationToQueue("Move", "Move %d items to %s".formatted(selectedItems.size(), targetDir.getName()), + (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); + } } } } @@ -2103,13 +2206,13 @@ public class MainWindow extends JFrame { requestFocusInActivePanel(); return; } - FileItem item = selectedItems.getFirst(); + FileItem item = selectedItems.getFirst(); String newName = JOptionPane.showInputDialog(this, "New name:", item.getName()); if (newName != null && !newName.trim().isEmpty() && !newName.equals(item.getName())) { performFileOperation((callback) -> { - FileOperations.rename(item.getFile(), newName.trim()); + FileOperations.rename(item, newName.trim()); }, "Rename completed", false, activePanel); } else { if (activePanel != null && activePanel.getFileTable() != null) { @@ -2120,10 +2223,11 @@ public class MainWindow extends JFrame { } } - /** + /** * Create a new directory */ private void createNewDirectory() { + FilePanelTab currentTab = activePanel.getCurrentTab(); String initialValue = "New directory"; FileItem focusedItem = activePanel.getFocusedItem(); if (focusedItem != null && !focusedItem.getName().equals("..")) { @@ -2132,16 +2236,29 @@ public class MainWindow extends JFrame { String dirNameInput = JOptionPane.showInputDialog(this, "New directory name:", initialValue); - + if (dirNameInput != null && !dirNameInput.trim().isEmpty()) { final String dirName = dirNameInput.trim(); - performFileOperation((callback) -> { - FileOperations.createDirectory(activePanel.getCurrentDirectory(), dirName); - }, "Directory created", false, () -> { - if (activePanel != null && activePanel.getCurrentTab() != null) { - activePanel.getCurrentTab().selectItem(dirName); - } - }, activePanel); + if (currentTab != null && currentTab.isFtpTab() && currentTab.getFtpProfile() != null) { + final FtpProfile profile = currentTab.getFtpProfile(); + final String path = currentTab.getFtpCurrentPath(); + performFileOperation((callback) -> { + FileOperations.createDirectoryOnFtp(profile, path, dirName); + }, "Directory created", false, () -> { + if (currentTab != null) { + currentTab.loadFtpDirectory(path, false, false); + SwingUtilities.invokeLater(() -> currentTab.selectItem(dirName)); + } + }, activePanel); + } else { + performFileOperation((callback) -> { + FileOperations.createDirectory(activePanel.getCurrentDirectory(), dirName); + }, "Directory created", false, () -> { + if (activePanel != null && activePanel.getCurrentTab() != null) { + activePanel.getCurrentTab().selectItem(dirName); + } + }, activePanel); + } } else { if (activePanel != null && activePanel.getFileTable() != null) { activePanel.getFileTable().requestFocusInWindow(); @@ -2150,6 +2267,7 @@ public class MainWindow extends JFrame { } private void createNewFile() { + FilePanelTab currentTab = activePanel.getCurrentTab(); String initialValue = "New file.txt"; FileItem focusedItem = activePanel.getFocusedItem(); if (focusedItem != null && !focusedItem.getName().equals("..")) { @@ -2161,14 +2279,27 @@ public class MainWindow extends JFrame { if (fileNameInput != null && !fileNameInput.trim().isEmpty()) { final String fileName = fileNameInput.trim(); - performFileOperation((callback) -> { - FileOperations.createFile(activePanel.getCurrentDirectory(), fileName); - }, "File created", false, () -> { - if (activePanel != null && activePanel.getCurrentTab() != null) { - activePanel.getCurrentTab().selectItem(fileName); - editFile(); - } - }, activePanel); + if (currentTab != null && currentTab.isFtpTab() && currentTab.getFtpProfile() != null) { + final FtpProfile profile = currentTab.getFtpProfile(); + final String path = currentTab.getFtpCurrentPath(); + performFileOperation((callback) -> { + FileOperations.createFileOnFtp(profile, path, fileName); + }, "File created", false, () -> { + if (currentTab != null) { + currentTab.loadFtpDirectory(path, false, false); + SwingUtilities.invokeLater(() -> currentTab.selectItem(fileName)); + } + }, activePanel); + } else { + performFileOperation((callback) -> { + FileOperations.createFile(activePanel.getCurrentDirectory(), fileName); + }, "File created", false, () -> { + if (activePanel != null && activePanel.getCurrentTab() != null) { + activePanel.getCurrentTab().selectItem(fileName); + editFile(); + } + }, activePanel); + } } else { if (activePanel != null && activePanel.getFileTable() != null) { activePanel.getFileTable().requestFocusInWindow(); @@ -3380,6 +3511,11 @@ public class MainWindow extends JFrame { if (autoRefreshTimer != null) { autoRefreshTimer.stop(); } + + // Close all FTP tabs before saving configuration to prevent them from being restored + if (leftPanel != null) leftPanel.closeAllFtpTabs(); + if (rightPanel != null) rightPanel.closeAllFtpTabs(); + persistDividerPosition(); // Save window state config.saveWindowState(this); @@ -3486,13 +3622,61 @@ public class MainWindow extends JFrame { } int interval = config.getAutoRefreshInterval(); autoRefreshTimer = new Timer(interval, e -> { - if (leftPanel != null && leftPanel.getCurrentDirectory() != null) { - leftPanel.refresh(false); + if (leftPanel != null) { + FilePanelTab tab = leftPanel.getCurrentTab(); + if (tab != null && !tab.isFtpTab() && leftPanel.getCurrentDirectory() != null) { + leftPanel.refresh(false); + } } - if (rightPanel != null && rightPanel.getCurrentDirectory() != null) { - rightPanel.refresh(false); + if (rightPanel != null) { + FilePanelTab tab = rightPanel.getCurrentTab(); + if (tab != null && !tab.isFtpTab() && rightPanel.getCurrentDirectory() != null) { + rightPanel.refresh(false); + } } }); autoRefreshTimer.start(); } + + // --- FTP support --- + + private void showFtpConnectDialog() { + List profiles = config.getFtpProfiles(); + if (profiles.isEmpty()) { + JOptionPane.showMessageDialog(this, + "No FTP profiles configured.\nGo to File -> FTP Profiles to add one.", + "FTP Connection", JOptionPane.INFORMATION_MESSAGE); + return; + } + String[] names = profiles.stream().map(FtpProfile::getName).toArray(String[]::new); + String selectedName = (String) JOptionPane.showInputDialog( + this, "Select FTP profile to connect:", "Connect to FTP", + JOptionPane.QUESTION_MESSAGE, null, names, names[0]); + if (selectedName != null) { + profiles.stream() + .filter(p -> p.getName().equals(selectedName)) + .findFirst() + .ifPresent(this::connectToFtp); + } + } + + private void connectToFtp(FtpProfile profile) { + try { + // Test connection by listing root + FtpService.listDirectory(profile, "/"); + String ftpPath = FtpService.buildFtpUrl(profile, "/"); + if (activePanel != null) { + activePanel.addFtpTab(ftpPath, profile); + } + } catch (IOException e) { + JOptionPane.showMessageDialog(this, + "Failed to connect to FTP server: " + e.getMessage(), + "FTP Error", JOptionPane.ERROR_MESSAGE); + } + } + + private void showFtpProfileManagerDialog() { + FtpProfileManagerDialog dialog = new FtpProfileManagerDialog(this, config, this::connectToFtp); + dialog.setVisible(true); + } }