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);
+ }
}