2026-06-09 20:39:49 +02:00

4183 lines
171 KiB
Java

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 java.awt.*;
import java.awt.image.BufferedImage;
import java.awt.datatransfer.DataFlavor;
import java.awt.event.*;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
/**
* Main application window with two panels
*/
public class MainWindow extends JFrame {
private FilePanel leftPanel;
private FilePanel rightPanel;
private FilePanel activePanel;
private JSplitPane mainPanel;
private JPanel buttonPanel;
private JToolBar toolBar;
private JComboBox<String> commandLine;
private JLabel cmdLabel;
private AppConfig config;
private Timer autoRefreshTimer;
private int lastMainPanelExtent = -1;
private boolean applyingSavedDividerLocation = false;
private boolean wildcardDialogOpen = false;
private int commandHistoryIndex = -1;
public MainWindow() {
super("KF Manager v" + MainApp.APP_VERSION + " (" + MainApp.CURRENT_OS + ") - " + getCurrentUser());
// Set application icon
loadAppIcon();
// Load configuration
config = new AppConfig();
initComponents();
setupKeyBindings();
// Apply appearance from saved configuration at startup
applyAppearanceSettings();
// Restore window size and position from configuration
config.restoreWindowState(this);
setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
// Add listener to save configuration on exit
addWindowListener(new WindowAdapter() {
@Override
public void windowClosing(WindowEvent e) {
MainWindow.this.saveConfigAndExit();
}
});
// Refresh panels when application gains focus
addWindowFocusListener(new WindowAdapter() {
@Override
public void windowGainedFocus(WindowEvent e) {
refreshPanels();
}
});
// After start, set focus and selection to the active panel
String initialActiveSide = config.getActivePanel();
SwingUtilities.invokeLater(() -> {
if ("right".equalsIgnoreCase(initialActiveSide)) {
activePanel = rightPanel;
rightPanel.requestFocusOnCurrentTab();
} else {
activePanel = leftPanel;
leftPanel.requestFocusOnCurrentTab();
}
updateActivePanelBorder();
updateCommandLinePrompt();
});
// Setup auto-refresh timer from config
updateAutoRefreshTimer();
}
private static String getCurrentUser() {
String userName = System.getProperty("user.name");
return (userName == null || userName.isBlank()) ? "unknown user" : userName;
}
private void loadAppIcon() {
try {
java.net.URL iconURL = MainWindow.class.getResource("/icon.png");
if (iconURL != null) {
ImageIcon img = new ImageIcon(iconURL);
setIconImage(img.getImage());
}
} catch (Exception e) {
System.err.println("Could not load icon: " + e.getMessage());
}
}
private void initComponents() {
setLayout(new BorderLayout());
// Toolbar
createToolBar();
// Panel containing the two file panels with resizable divider
mainPanel = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT);
mainPanel.setContinuousLayout(true);
// Initially set resize weight based on saved proportion to maintain it during first layout/resize
mainPanel.setResizeWeight(getConfiguredDividerRatio());
// Disable default JSplitPane F6/Shift+F6 bindings which interfere with our Move/Rename actions
mainPanel.getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT).put(KeyStroke.getKeyStroke(KeyEvent.VK_F6, 0), "none");
mainPanel.getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT).put(KeyStroke.getKeyStroke(KeyEvent.VK_F6, InputEvent.SHIFT_DOWN_MASK), "none");
// Left panel - load path and ViewMode from configuration
String leftPath = config.getLeftPanelPath();
leftPanel = new FilePanel(leftPath);
leftPanel.setAppConfig(config);
// Provide a callback so tabs inside the panel can request switching panels with TAB
leftPanel.setSwitchPanelCallback(() -> switchPanelsFromChild());
leftPanel.setOnDirectoryChangedAll(() -> updateCommandLinePrompt());
leftPanel.setOnTableCreated(table -> {
setupFileTable(table, leftPanel);
});
setupFileTable(leftPanel.getFileTable(), leftPanel);
// Load and set ViewMode for left panel
try {
ViewMode leftViewMode = ViewMode.valueOf(config.getLeftPanelViewMode());
leftPanel.setViewMode(leftViewMode, false);
} catch (IllegalArgumentException e) {
// Default value FULL is already set
}
// Right panel - load path and ViewMode from configuration
String rightPath = config.getRightPanelPath();
rightPanel = new FilePanel(rightPath);
rightPanel.setAppConfig(config);
// Provide a callback so tabs inside the panel can request switching panels with TAB
rightPanel.setSwitchPanelCallback(() -> switchPanelsFromChild());
rightPanel.setOnDirectoryChangedAll(() -> updateCommandLinePrompt());
rightPanel.setOnTableCreated(table -> {
setupFileTable(table, rightPanel);
});
setupFileTable(rightPanel.getFileTable(), rightPanel);
leftPanel.setDriveSelectionTargetResolver(selectedDrive -> getPreferredDirectoryFromOppositePanel(selectedDrive, rightPanel));
rightPanel.setDriveSelectionTargetResolver(selectedDrive -> getPreferredDirectoryFromOppositePanel(selectedDrive, leftPanel));
// Load and set ViewMode for right panel
try {
ViewMode rightViewMode = ViewMode.valueOf(config.getRightPanelViewMode());
rightPanel.setViewMode(rightViewMode, false);
} catch (IllegalArgumentException e) {
// Default value FULL is already set
}
mainPanel.setLeftComponent(leftPanel);
mainPanel.setRightComponent(rightPanel);
// Restore divider position from configuration
SwingUtilities.invokeLater(() -> {
double savedPosition = config.getDividerPosition();
if (savedPosition >= 0.0 && savedPosition <= 1.0) {
mainPanel.setDividerLocation(savedPosition);
// Dynamically update resizeWeight to maintain current proportion during future window resizes
mainPanel.setResizeWeight(savedPosition);
} else {
// If it was saved as absolute pixels (historically), use as int
mainPanel.setDividerLocation((int) savedPosition);
persistDividerPosition();
}
updateDividerTooltip(mainPanel);
});
mainPanel.setOneTouchExpandable(true);
lastMainPanelExtent = getMainPanelExtent();
updateDividerTooltip(mainPanel);
// Listen for divider position changes to update tooltip
mainPanel.addPropertyChangeListener(JSplitPane.DIVIDER_LOCATION_PROPERTY, e -> {
int currentExtent = getMainPanelExtent();
if (!applyingSavedDividerLocation && currentExtent > 0 && currentExtent == lastMainPanelExtent) {
persistDividerPosition();
}
lastMainPanelExtent = currentExtent;
updateDividerTooltip(mainPanel);
// Immediately show tooltip during drag if possible
for (Component c : mainPanel.getComponents()) {
if (c != mainPanel.getLeftComponent() && c != mainPanel.getRightComponent() && c instanceof JComponent) {
MouseEvent postEvent = new MouseEvent(c, MouseEvent.MOUSE_MOVED, System.currentTimeMillis(), 0, 0, 0, 0, false);
ToolTipManager.sharedInstance().mouseMoved(postEvent);
}
}
});
// Re-apply configured ratio after each resize (Linux can otherwise keep absolute pixels).
mainPanel.addComponentListener(new ComponentAdapter() {
@Override
public void componentResized(ComponentEvent e) {
SwingUtilities.invokeLater(() -> applySavedDividerLocation());
}
});
add(mainPanel, BorderLayout.CENTER);
// Restore active panel from configuration
String savedActive = config.getActivePanel();
if ("right".equalsIgnoreCase(savedActive)) {
activePanel = rightPanel;
} else {
activePanel = leftPanel;
}
updateActivePanelBorder();
updateCommandLinePrompt();
// Restore saved tabs for both panels if present in configuration
try {
int leftCount = config.getLeftPanelTabCount();
if (leftCount > 0) {
java.util.List<String> paths = new java.util.ArrayList<>();
java.util.List<String> modes = new java.util.ArrayList<>();
for (int i = 0; i < leftCount; i++) {
String p = config.getLeftPanelTabPath(i);
if (p == null) p = System.getProperty("user.home");
paths.add(p);
modes.add(config.getLeftPanelTabViewMode(i));
}
int sel = config.getLeftPanelSelectedIndex();
leftPanel.restoreTabs(paths, modes, null, sel, false);
}
} catch (Exception ex) {
// ignore and keep default
}
try {
int rightCount = config.getRightPanelTabCount();
if (rightCount > 0) {
java.util.List<String> paths = new java.util.ArrayList<>();
java.util.List<String> modes = new java.util.ArrayList<>();
for (int i = 0; i < rightCount; i++) {
String p = config.getRightPanelTabPath(i);
if (p == null) p = System.getProperty("user.home");
paths.add(p);
modes.add(config.getRightPanelTabViewMode(i));
}
int sel = config.getRightPanelSelectedIndex();
rightPanel.restoreTabs(paths, modes, null, sel, false);
}
} catch (Exception ex) {
// ignore and keep default
}
// Global focus listener to track which panel is active based on focused component
KeyboardFocusManager.getCurrentKeyboardFocusManager().addPropertyChangeListener("permanentFocusOwner", evt -> {
Component focused = (Component) evt.getNewValue();
if (focused != null) {
if (SwingUtilities.isDescendingFrom(focused, leftPanel)) {
if (activePanel != leftPanel && leftPanel.isNonActivatingFocusOwner(focused)) {
return;
}
activePanel = leftPanel;
updateActivePanelBorder();
leftPanel.getFileTable().repaint();
rightPanel.getFileTable().repaint();
updateCommandLinePrompt();
} else if (SwingUtilities.isDescendingFrom(focused, rightPanel)) {
if (activePanel != rightPanel && rightPanel.isNonActivatingFocusOwner(focused)) {
return;
}
activePanel = rightPanel;
updateActivePanelBorder();
leftPanel.getFileTable().repaint();
rightPanel.getFileTable().repaint();
updateCommandLinePrompt();
}
}
});
// Click on panel anywhere should request focus to its table
leftPanel.addMouseListener(new MouseAdapter() {
@Override
public void mousePressed(MouseEvent e) {
leftPanel.getFileTable().requestFocusInWindow();
}
});
rightPanel.addMouseListener(new MouseAdapter() {
@Override
public void mousePressed(MouseEvent e) {
rightPanel.getFileTable().requestFocusInWindow();
}
});
// Container for everything below the file panels
JPanel bottomContainer = new JPanel(new BorderLayout());
// Command line panel
JPanel cmdPanel = new JPanel(new BorderLayout(5, 0));
cmdPanel.setBorder(BorderFactory.createEmptyBorder(2, 5, 0, 5));
cmdLabel = new JLabel();
cmdLabel.setFont(new Font("Monospaced", Font.BOLD, 12));
// Add context menu to cmdLabel
JPopupMenu cmdPopupMenu = new JPopupMenu();
JMenuItem copyPathItem = new JMenuItem("Copy path");
copyPathItem.addActionListener(e -> {
String path = cmdLabel.getToolTipText();
if (path != null && !path.isEmpty()) {
ClipboardService.copyTextToClipboard(path);
}
});
cmdPopupMenu.add(copyPathItem);
cmdLabel.setComponentPopupMenu(cmdPopupMenu);
cmdPanel.add(cmdLabel, BorderLayout.WEST);
updateCommandLinePrompt();
commandLine = new JComboBox<>();
commandLine.setEditable(true);
commandLine.setFont(new Font("Monospaced", Font.PLAIN, 12));
// Handle the editor component (usually a JTextField)
Component editorComp = commandLine.getEditor().getEditorComponent();
if (editorComp instanceof JTextField tf) {
tf.setFocusTraversalKeysEnabled(false);
tf.putClientProperty("JTextField.selectAllOnFocus", Boolean.FALSE);
tf.addActionListener(e -> executeCommand(tf.getText()));
tf.addFocusListener(new java.awt.event.FocusAdapter() {
@Override
public void focusGained(java.awt.event.FocusEvent e) {
// Force caret to the end and clear selection when focus is gained
SwingUtilities.invokeLater(() -> {
tf.setSelectionStart(tf.getText().length());
tf.setSelectionEnd(tf.getText().length());
tf.setCaretPosition(tf.getText().length());
});
}
});
// Enable standard clipboard operations (Cut, Copy, Paste) even if not focused initially
tf.getComponentPopupMenu(); // Ensure it has a menu or at least default actions works
// Context menu with Clipboard operations
JPopupMenu clipboardMenu = new JPopupMenu();
Action cutAction = new javax.swing.text.DefaultEditorKit.CutAction();
cutAction.putValue(Action.NAME, "Cut");
clipboardMenu.add(cutAction);
Action copyAction = new javax.swing.text.DefaultEditorKit.CopyAction();
copyAction.putValue(Action.NAME, "Copy");
clipboardMenu.add(copyAction);
Action pasteAction = new javax.swing.text.DefaultEditorKit.PasteAction();
pasteAction.putValue(Action.NAME, "Paste");
clipboardMenu.add(pasteAction);
clipboardMenu.addSeparator();
Action selectAllAction = new AbstractAction("Select All") {
@Override
public void actionPerformed(ActionEvent e) {
tf.selectAll();
}
};
clipboardMenu.add(selectAllAction);
tf.setComponentPopupMenu(clipboardMenu);
// Let the panels catch focus back if user presses ESC or TAB in command line
tf.addKeyListener(new KeyAdapter() {
@Override
public void keyPressed(KeyEvent e) {
if (e.getKeyCode() == KeyEvent.VK_ESCAPE) {
tf.setText("");
if (activePanel != null && activePanel.getFileTable() != null) {
activePanel.getFileTable().requestFocusInWindow();
}
e.consume();
} else if (e.getKeyCode() == KeyEvent.VK_TAB) {
activePanel.getFileTable().requestFocusInWindow();
e.consume();
} else if (e.getKeyCode() == KeyEvent.VK_ENTER && e.isControlDown() && e.isShiftDown()) {
copyFocusedToCommandLineAtCaret(tf, true);
e.consume();
} else if (e.getKeyCode() == KeyEvent.VK_ENTER && e.isControlDown()) {
copyFocusedToCommandLineAtCaret(tf, false);
e.consume();
} else if (e.getKeyCode() == KeyEvent.VK_E && e.isControlDown()) {
showCommandLineHistory();
e.consume();
} else if (!e.isControlDown() && !e.isAltDown() && !e.isMetaDown()) {
commandHistoryIndex = -1;
}
}
});
}
cmdPanel.add(commandLine, BorderLayout.CENTER);
// Load history from config
java.util.List<String> history = config.getCommandLineHistory();
for (int i = 0; i < history.size(); i++) {
commandLine.addItem(history.get(i));
}
commandLine.getEditor().setItem(""); // Ensure it starts empty
bottomContainer.add(cmdPanel, BorderLayout.NORTH);
// Bottom panel with buttons
createButtonPanel();
bottomContainer.add(buttonPanel, BorderLayout.SOUTH);
add(bottomContainer, BorderLayout.SOUTH);
// Menu
createMenuBar();
// Request focus for the active panel
SwingUtilities.invokeLater(() -> {
if (activePanel != null && activePanel.getFileTable() != null) {
activePanel.getFileTable().requestFocusInWindow();
}
});
}
/**
* Create toolbar with buttons for changing view mode
*/
private void createToolBar() {
if (toolBar == null) {
toolBar = new JToolBar();
toolBar.setFloatable(false);
toolBar.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
add(toolBar, BorderLayout.NORTH);
// Enable Drag and Drop for adding shortcuts
toolBar.setTransferHandler(new TransferHandler() {
@Override
public boolean canImport(TransferSupport support) {
return support.isDataFlavorSupported(DataFlavor.javaFileListFlavor);
}
@Override
@SuppressWarnings("unchecked")
public boolean importData(TransferSupport support) {
if (!canImport(support)) return false;
try {
List<File> files = (List<File>) support.getTransferable().getTransferData(DataFlavor.javaFileListFlavor);
if (files != null && !files.isEmpty()) {
if (files.size() == 1) {
showAddToolbarShortcutDialog(files.get(0));
} else {
// Add all as shortcuts with default names/icons
List<AppConfig.ToolbarShortcut> shortcuts = config.getToolbarShortcuts();
for (File f : files) {
String label = f.getName();
String command = f.getAbsolutePath();
String workDir = f.isDirectory() ? f.getAbsolutePath() : f.getParent();
shortcuts.add(new AppConfig.ToolbarShortcut(command, label, "", workDir));
}
config.saveToolbarShortcuts(shortcuts);
createToolBar(); // refresh
JOptionPane.showMessageDialog(MainWindow.this,
files.size() + " shortcuts added to toolbar.",
"Shortcuts Added", JOptionPane.INFORMATION_MESSAGE);
}
}
return true;
} catch (Exception e) {
return false;
}
}
});
}
toolBar.removeAll();
// Refresh button
JButton btnRefresh = new JButton();
btnRefresh.setIcon(createToolbarRefreshIcon());
setupMainToolbarButton(btnRefresh, "Refresh active panel", new Color(140, 190, 140));
btnRefresh.addActionListener(e -> {
if (activePanel != null && activePanel.getCurrentDirectory() != null) {
activePanel.refresh(true);
}
});
toolBar.add(btnRefresh);
toolBar.addSeparator();
// Button for BRIEF mode
JButton btnBrief = new JButton();
btnBrief.setIcon(createToolbarBriefIcon());
setupMainToolbarButton(btnBrief, "Brief mode - multiple columns (Ctrl+F1)", new Color(230, 180, 130));
btnBrief.addActionListener(e -> {
if (activePanel != null) {
activePanel.setViewMode(ViewMode.BRIEF);
}
});
// Button for FULL mode
JButton btnFull = new JButton();
btnFull.setIcon(createToolbarFullIcon());
setupMainToolbarButton(btnFull, "Full mode - full information (Ctrl+F2)", new Color(140, 170, 220));
btnFull.addActionListener(e -> {
if (activePanel != null) {
activePanel.setViewMode(ViewMode.FULL);
}
});
toolBar.add(btnBrief);
toolBar.add(btnFull);
toolBar.addSeparator();
// Search button
JButton btnSearch = new JButton();
btnSearch.setIcon(createToolbarSearchIcon());
setupMainToolbarButton(btnSearch, "Search files (Alt+F7)", new Color(200, 160, 200));
btnSearch.addActionListener(e -> showSearchDialog());
toolBar.add(btnSearch);
// Sync button
JButton btnSync = new JButton();
btnSync.setIcon(createToolbarCompareIcon());
setupMainToolbarButton(btnSync, "Compare directories", new Color(150, 200, 200));
btnSync.addActionListener(e -> showSyncDialog());
toolBar.add(btnSync);
// Multi-rename button
JButton btnMultiRename = new JButton();
btnMultiRename.setIcon(createToolbarMultiRenameIcon());
setupMainToolbarButton(btnMultiRename, "Multi-Rename Tool (Ctrl+M)", new Color(190, 160, 230));
btnMultiRename.addActionListener(e -> showMultiRenameDialog());
toolBar.add(btnMultiRename);
toolBar.addSeparator();
// Load custom shortcuts from config
List<AppConfig.ToolbarShortcut> shortcuts = config.getToolbarShortcuts();
int iconSize = config.getToolbarIconSize();
// Group shortcuts: directories will go to the right, others stay on the left
List<JButton> leftShortcuts = new ArrayList<>();
List<JButton> rightShortcuts = new ArrayList<>();
for (AppConfig.ToolbarShortcut s : shortcuts) {
JButton btn = new JButton();
setupShortcutButton(btn, s.label + " (" + s.command + ")");
boolean hasIcon = false;
boolean isDirectory = false;
File target = new File(s.command);
if (target.exists() && target.isDirectory()) {
isDirectory = true;
}
if (s.iconPath != null && !s.iconPath.isEmpty()) {
try {
File iconFile = new File(s.iconPath);
if (iconFile.exists()) {
btn.setIcon(new ImageIcon(new ImageIcon(s.iconPath).getImage().getScaledInstance(iconSize, iconSize, Image.SCALE_SMOOTH)));
hasIcon = true;
}
} catch (Exception ignore) {}
}
// If no custom icon, try to use the same icons as in file panels
if (!hasIcon) {
try {
if (target.exists()) {
Icon customIcon;
if (target.isDirectory()) {
customIcon = new ColoredFolderIcon(config.getFolderColor(), s.label);
} else {
customIcon = getToolbarShortcutIcon(target);
}
btn.setIcon(scaleIcon(customIcon, iconSize, iconSize));
hasIcon = true;
}
} catch (Exception ignore) {}
}
// If no icon found, use the label text
if (!hasIcon) {
btn.setText(s.label);
btn.setFont(btn.getFont().deriveFont(Font.BOLD, 12f));
}
btn.addActionListener(e -> {
// If command is a directory that exists, change active panel directory
File dir = new File(s.command);
if (dir.exists() && dir.isDirectory()) {
if (activePanel != null) {
activePanel.loadDirectory(dir);
}
} else {
executeNative(s.command, s.workingDir);
}
});
// Context menu for Edit/Delete
JPopupMenu shortcutPopup = new JPopupMenu();
JMenuItem editItem = new JMenuItem("Edit");
editItem.addActionListener(ae -> showEditToolbarShortcutDialog(s));
JMenuItem deleteItem = new JMenuItem("Delete");
deleteItem.addActionListener(ae -> {
int choice = JOptionPane.showConfirmDialog(MainWindow.this, "Remove this shortcut?", "Toolbar", JOptionPane.YES_NO_OPTION);
if (choice == JOptionPane.YES_OPTION) {
removeToolbarShortcut(s);
}
});
shortcutPopup.add(editItem);
shortcutPopup.add(deleteItem);
shortcutPopup.addSeparator();
JMenuItem moveLeftItem = new JMenuItem("Move <");
moveLeftItem.addActionListener(ae -> moveToolbarShortcut(s, -1));
JMenuItem moveRightItem = new JMenuItem("Move >");
moveRightItem.addActionListener(ae -> moveToolbarShortcut(s, 1));
shortcutPopup.add(moveLeftItem);
shortcutPopup.add(moveRightItem);
btn.addMouseListener(new MouseAdapter() {
@Override
public void mousePressed(MouseEvent e) {
if (e.isPopupTrigger()) {
shortcutPopup.show(e.getComponent(), e.getX(), e.getY());
}
}
@Override
public void mouseReleased(MouseEvent e) {
if (e.isPopupTrigger()) {
shortcutPopup.show(e.getComponent(), e.getX(), e.getY());
}
}
});
if (isDirectory) {
rightShortcuts.add(btn);
} else {
leftShortcuts.add(btn);
}
}
// Add non-directory shortcuts to the left
for (JButton b : leftShortcuts) {
toolBar.add(b);
}
// Push directory shortcuts to the right
toolBar.add(javax.swing.Box.createHorizontalGlue());
// Add directory shortcuts to the right
for (JButton b : rightShortcuts) {
toolBar.add(b);
}
toolBar.revalidate();
toolBar.repaint();
}
private Icon getToolbarShortcutIcon(File target) {
FileSpecificIcon.Type fileType = FileSpecificIcon.getFileType(target.getName());
boolean isExecutableWithEmbeddedIcon = false;
if (target.isFile()) {
if (MainApp.CURRENT_OS == MainApp.OS.WINDOWS) {
isExecutableWithEmbeddedIcon = fileType == FileSpecificIcon.Type.EXEC;
} else if (MainApp.CURRENT_OS == MainApp.OS.LINUX) {
isExecutableWithEmbeddedIcon = fileType == FileSpecificIcon.Type.EXEC || target.canExecute();
}
}
if (isExecutableWithEmbeddedIcon) {
try {
Icon systemIcon = FileSystemView.getFileSystemView().getSystemIcon(target);
if (systemIcon != null) {
return systemIcon;
}
} catch (Exception ignored) {
// Fallback to default file-type icon
}
}
return new FileSpecificIcon(fileType);
}
private Icon scaleIcon(Icon icon, int width, int height) {
if (icon == null) return null;
if (icon.getIconWidth() == width && icon.getIconHeight() == height) {
return icon;
}
BufferedImage img = new BufferedImage(icon.getIconWidth(), icon.getIconHeight(), BufferedImage.TYPE_INT_ARGB);
Graphics g = img.getGraphics();
try {
icon.paintIcon(null, g, 0, 0);
} finally {
g.dispose();
}
return new ImageIcon(img.getScaledInstance(width, height, Image.SCALE_SMOOTH));
}
private Icon createToolbarSearchIcon() {
return new Icon() {
private final int w = 16;
private final int h = 16;
@Override
public int getIconWidth() {
return w;
}
@Override
public int getIconHeight() {
return h;
}
@Override
public void paintIcon(Component c, Graphics g, int x, int y) {
Graphics2D g2 = (Graphics2D) g.create();
try {
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2.setColor(c != null ? c.getForeground() : Color.DARK_GRAY);
g2.setStroke(new BasicStroke(2f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
int cx = x + 6;
int cy = y + 6;
int r = 4;
g2.drawOval(cx - r, cy - r, r * 2, r * 2);
g2.drawLine(cx + r - 1, cy + r - 1, x + 14, y + 14);
} finally {
g2.dispose();
}
}
};
}
private Icon createToolbarRefreshIcon() {
return new Icon() {
private final int w = 16;
private final int h = 16;
@Override
public int getIconWidth() {
return w;
}
@Override
public int getIconHeight() {
return h;
}
@Override
public void paintIcon(Component c, Graphics g, int x, int y) {
Graphics2D g2 = (Graphics2D) g.create();
try {
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2.setColor(c != null ? c.getForeground() : Color.DARK_GRAY);
g2.setStroke(new BasicStroke(2f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
g2.drawArc(x + 2, y + 2, 10, 10, 35, 255);
g2.drawLine(x + 11, y + 2, x + 14, y + 3);
g2.drawLine(x + 11, y + 2, x + 12, y + 5);
} finally {
g2.dispose();
}
}
};
}
private Icon createToolbarBriefIcon() {
return new Icon() {
private final int w = 16;
private final int h = 16;
@Override
public int getIconWidth() {
return w;
}
@Override
public int getIconHeight() {
return h;
}
@Override
public void paintIcon(Component c, Graphics g, int x, int y) {
Graphics2D g2 = (Graphics2D) g.create();
try {
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2.setColor(c != null ? c.getForeground() : Color.DARK_GRAY);
g2.setStroke(new BasicStroke(2f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
// Three vertical columns to represent compact brief layout.
int colW = 3;
int gap = 2;
int startX = x + 1;
int top = y + 2;
int height = 12;
for (int i = 0; i < 3; i++) {
int cx = startX + i * (colW + gap);
g2.drawRoundRect(cx, top, colW, height, 2, 2);
}
} finally {
g2.dispose();
}
}
};
}
private Icon createToolbarFullIcon() {
return new Icon() {
private final int w = 16;
private final int h = 16;
@Override
public int getIconWidth() {
return w;
}
@Override
public int getIconHeight() {
return h;
}
@Override
public void paintIcon(Component c, Graphics g, int x, int y) {
Graphics2D g2 = (Graphics2D) g.create();
try {
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2.setColor(c != null ? c.getForeground() : Color.DARK_GRAY);
g2.setStroke(new BasicStroke(2f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
// List rows with a side column to represent full-detail table layout.
g2.drawRoundRect(x + 1, y + 2, 4, 12, 2, 2);
g2.drawLine(x + 7, y + 4, x + 14, y + 4);
g2.drawLine(x + 7, y + 8, x + 14, y + 8);
g2.drawLine(x + 7, y + 12, x + 14, y + 12);
} finally {
g2.dispose();
}
}
};
}
private Icon createToolbarCompareIcon() {
return new Icon() {
private final int w = 16;
private final int h = 16;
@Override
public int getIconWidth() {
return w;
}
@Override
public int getIconHeight() {
return h;
}
@Override
public void paintIcon(Component c, Graphics g, int x, int y) {
Graphics2D g2 = (Graphics2D) g.create();
try {
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2.setColor(c != null ? c.getForeground() : Color.DARK_GRAY);
g2.setStroke(new BasicStroke(2f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
// Two panes side by side with opposing arrows: directory compare.
g2.drawRoundRect(x + 1, y + 2, 5, 11, 2, 2);
g2.drawRoundRect(x + 10, y + 2, 5, 11, 2, 2);
g2.drawLine(x + 7, y + 6, x + 9, y + 6);
g2.drawLine(x + 8, y + 5, x + 9, y + 6);
g2.drawLine(x + 8, y + 7, x + 9, y + 6);
g2.drawLine(x + 9, y + 10, x + 7, y + 10);
g2.drawLine(x + 8, y + 9, x + 7, y + 10);
g2.drawLine(x + 8, y + 11, x + 7, y + 10);
} finally {
g2.dispose();
}
}
};
}
private Icon createToolbarMultiRenameIcon() {
return new Icon() {
private final int w = 16;
private final int h = 16;
@Override
public int getIconWidth() {
return w;
}
@Override
public int getIconHeight() {
return h;
}
@Override
public void paintIcon(Component c, Graphics g, int x, int y) {
Graphics2D g2 = (Graphics2D) g.create();
try {
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2.setColor(c != null ? c.getForeground() : Color.DARK_GRAY);
g2.setStroke(new BasicStroke(2f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
// Two file rows + edit pencil to suggest bulk rename tool.
g2.drawRoundRect(x + 1, y + 2, 9, 4, 2, 2);
g2.drawRoundRect(x + 1, y + 9, 9, 4, 2, 2);
g2.drawLine(x + 11, y + 12, x + 15, y + 8);
g2.drawLine(x + 10, y + 13, x + 11, y + 12);
g2.drawLine(x + 14, y + 7, x + 15, y + 8);
} finally {
g2.dispose();
}
}
};
}
private void setupMainToolbarButton(JButton btn, String tooltip, Color color) {
int btnSize = config != null ? config.getToolbarButtonSize() : 35;
if (btnSize < 35) btnSize = 35;
btn.setPreferredSize(new Dimension(btnSize, btnSize));
btn.setMinimumSize(new Dimension(btnSize, btnSize));
btn.setMaximumSize(new Dimension(btnSize, btnSize));
btn.setToolTipText(tooltip);
btn.setFont(btn.getFont().deriveFont(Font.BOLD, 18f));
btn.setFocusPainted(false);
Color bg = config != null ? config.getBackgroundColor() : Color.WHITE;
boolean dark = isDark(bg);
Color foregroundColor = dark ? color.brighter() : color.darker();
btn.setForeground(foregroundColor);
btn.setBorder(BorderFactory.createEmptyBorder(3, 3, 3, 3));
btn.addMouseListener(new MouseAdapter() {
@Override
public void mouseEntered(MouseEvent e) {
btn.setBackground(color);
btn.setForeground(isDark(color) ? Color.WHITE : Color.BLACK);
btn.setOpaque(true);
}
@Override
public void mouseExited(MouseEvent e) {
btn.setBackground(null);
btn.setForeground(foregroundColor);
btn.setOpaque(false);
}
});
btn.setContentAreaFilled(false);
}
private void setupShortcutButton(JButton btn, String tooltip) {
int btnSize = config != null ? config.getToolbarButtonSize() : 35;
btn.setPreferredSize(new Dimension(btnSize, btnSize));
btn.setMinimumSize(new Dimension(btnSize, btnSize));
btn.setMaximumSize(new Dimension(btnSize, btnSize));
btn.setToolTipText(tooltip);
btn.setFocusable(false);
btn.setFocusPainted(false);
btn.setContentAreaFilled(false);
Color bg = config != null ? config.getBackgroundColor() : Color.WHITE;
boolean dark = isDark(bg);
btn.setBorder(BorderFactory.createEmptyBorder(3, 3, 3, 3));
btn.addMouseListener(new MouseAdapter() {
@Override
public void mouseEntered(MouseEvent e) {
btn.setBackground(dark ? new Color(80, 80, 80) : new Color(230, 230, 230));
btn.setOpaque(true);
}
@Override
public void mouseExited(MouseEvent e) {
btn.setOpaque(false);
}
});
}
private void showAddToolbarShortcutDialog(File file) {
showToolbarShortcutEditor(null, file);
}
private void showEditToolbarShortcutDialog(AppConfig.ToolbarShortcut shortcut) {
showToolbarShortcutEditor(shortcut, null);
}
private void showToolbarShortcutEditor(AppConfig.ToolbarShortcut existing, File file) {
String initialLabel = (existing != null) ? existing.label : (file != null ? file.getName() : "");
String initialCmd = (existing != null) ? existing.command : (file != null ? file.getAbsolutePath() : "");
String initialWorkDir = (existing != null) ? existing.workingDir :
(file != null ? (file.isDirectory() ? file.getAbsolutePath() : file.getParent()) : "");
String initialIcon = (existing != null) ? existing.iconPath : "";
JTextField labelField = new JTextField(initialLabel);
JTextField cmdField = new JTextField(initialCmd);
JTextField workingDirField = new JTextField(initialWorkDir);
JTextField iconField = new JTextField(initialIcon);
JPanel panel = new JPanel(new GridLayout(0, 1));
panel.add(new JLabel("Label:"));
panel.add(labelField);
panel.add(new JLabel("Command:"));
panel.add(cmdField);
panel.add(new JLabel("Working directory:"));
JPanel workDirPanel = new JPanel(new BorderLayout());
workDirPanel.add(workingDirField, BorderLayout.CENTER);
JButton browseWorkDir = new JButton("...");
browseWorkDir.addActionListener(e -> {
JFileChooser chooser = new JFileChooser(workingDirField.getText());
chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY);
if (FileChooserUtils.showOpenDialog(this, chooser, config) == JFileChooser.APPROVE_OPTION) {
workingDirField.setText(chooser.getSelectedFile().getAbsolutePath());
}
});
workDirPanel.add(browseWorkDir, BorderLayout.EAST);
panel.add(workDirPanel);
panel.add(new JLabel("Icon path (optional):"));
JPanel iconPanel = new JPanel(new BorderLayout());
iconPanel.add(iconField, BorderLayout.CENTER);
JButton browseIcon = new JButton("...");
browseIcon.addActionListener(e -> {
String startPath = iconField.getText().trim();
if (startPath.isEmpty()) {
startPath = workingDirField.getText().trim();
if (startPath.isEmpty()) {
startPath = cmdField.getText().trim();
}
}
JFileChooser chooser = new JFileChooser(startPath);
if (FileChooserUtils.showOpenDialog(this, chooser, config) == JFileChooser.APPROVE_OPTION) {
iconField.setText(chooser.getSelectedFile().getAbsolutePath());
}
});
iconPanel.add(browseIcon, BorderLayout.EAST);
panel.add(iconPanel);
String title = (existing != null) ? "Edit Toolbar Shortcut" : "Add Toolbar Shortcut";
cz.kamma.kfmanager.MainApp.applyReflectiveCaretColor(panel);
int result = JOptionPane.showConfirmDialog(this, panel, title, JOptionPane.OK_CANCEL_OPTION);
if (result == JOptionPane.OK_OPTION) {
String label = labelField.getText().trim();
String command = cmdField.getText().trim();
String icon = iconField.getText().trim();
String workDir = workingDirField.getText().trim();
if (!command.isEmpty()) {
if (label.isEmpty()) label = (file != null ? file.getName() : "Shortcut");
List<AppConfig.ToolbarShortcut> shortcuts = config.getToolbarShortcuts();
if (existing != null) {
// Find and replace
for (int i = 0; i < shortcuts.size(); i++) {
AppConfig.ToolbarShortcut s = shortcuts.get(i);
// Identify by original command/label if they haven't changed yet in memory
if (s.command.equals(existing.command) && s.label.equals(existing.label)) {
shortcuts.set(i, new AppConfig.ToolbarShortcut(command, label, icon, workDir));
break;
}
}
} else {
shortcuts.add(new AppConfig.ToolbarShortcut(command, label, icon, workDir));
}
config.saveToolbarShortcuts(shortcuts);
createToolBar(); // refresh
}
}
}
private void removeToolbarShortcut(AppConfig.ToolbarShortcut shortcut) {
List<AppConfig.ToolbarShortcut> shortcuts = config.getToolbarShortcuts();
shortcuts.removeIf(s -> s.command.equals(shortcut.command) && s.label.equals(shortcut.label));
config.saveToolbarShortcuts(shortcuts);
createToolBar();
}
private void moveToolbarShortcut(AppConfig.ToolbarShortcut shortcut, int direction) {
List<AppConfig.ToolbarShortcut> shortcuts = config.getToolbarShortcuts();
int index = -1;
for (int i = 0; i < shortcuts.size(); i++) {
AppConfig.ToolbarShortcut s = shortcuts.get(i);
if (s.command.equals(shortcut.command) && s.label.equals(shortcut.label)) {
index = i;
break;
}
}
if (index != -1) {
boolean isDir = new File(shortcut.command).isDirectory() && new File(shortcut.command).exists();
int targetIndex = -1;
if (direction < 0) { // move left
for (int i = index - 1; i >= 0; i--) {
File f = new File(shortcuts.get(i).command);
if ((f.exists() && f.isDirectory()) == isDir) {
targetIndex = i;
break;
}
}
} else { // move right
for (int i = index + 1; i < shortcuts.size(); i++) {
File f = new File(shortcuts.get(i).command);
if ((f.exists() && f.isDirectory()) == isDir) {
targetIndex = i;
break;
}
}
}
if (targetIndex != -1) {
AppConfig.ToolbarShortcut s = shortcuts.remove(index);
shortcuts.add(targetIndex, s);
config.saveToolbarShortcuts(shortcuts);
createToolBar();
}
}
}
/**
* Create button panel (like Total Commander)
*/
private void createButtonPanel() {
buttonPanel = new JPanel(new GridLayout(1, 9, 5, 5));
buttonPanel.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
JButton btnView = new JButton("F3 View");
btnView.addActionListener(e -> viewFile());
JButton btnEdit = new JButton("F4 Edit");
btnEdit.addActionListener(e -> editFile());
JButton btnCopy = new JButton("F5 Copy");
btnCopy.addActionListener(e -> copyFiles());
JButton btnMove = new JButton("F6 Move");
btnMove.addActionListener(e -> moveFiles());
JButton btnNewDir = new JButton("F7 New Dir");
btnNewDir.addActionListener(e -> createNewDirectory());
JButton btnDelete = new JButton("F8 Delete");
btnDelete.addActionListener(e -> deleteFiles());
JButton btnTerminal = new JButton("F9 Terminal");
btnTerminal.addActionListener(e -> openTerminal());
JButton btnExit = new JButton("F10 Exit");
btnExit.addActionListener(e -> saveConfigAndExit());
buttonPanel.add(btnView);
buttonPanel.add(btnEdit);
buttonPanel.add(btnCopy);
buttonPanel.add(btnMove);
buttonPanel.add(btnNewDir);
buttonPanel.add(btnDelete);
buttonPanel.add(btnTerminal);
buttonPanel.add(btnExit);
}
/**
* Create menu bar
*/
private void createMenuBar() {
JMenuBar menuBar = new JMenuBar();
// File menu
JMenu fileMenu = new JMenu("File");
JMenuItem searchItem = new JMenuItem("Search...");
searchItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_F7, InputEvent.ALT_DOWN_MASK));
searchItem.addActionListener(e -> showSearchDialog());
JMenuItem selectAllItem = new JMenuItem("Invert Selection");
selectAllItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_A, InputEvent.CTRL_DOWN_MASK));
selectAllItem.addActionListener(e -> {
if (activePanel != null) activePanel.invertSelection();
});
JMenuItem selectWildcardItem = new JMenuItem("Select by wildcard...");
selectWildcardItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_PLUS, 0));
selectWildcardItem.addActionListener(e -> showWildcardSelectDialog());
JMenuItem compareItem = new JMenuItem("Compare Files");
compareItem.addActionListener(e -> compareFiles());
JMenuItem multiRenameItem = new JMenuItem("Multi-Rename Tool...");
multiRenameItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_M, InputEvent.CTRL_DOWN_MASK));
multiRenameItem.addActionListener(e -> showMultiRenameDialog());
JMenuItem refreshItem = new JMenuItem("Refresh");
refreshItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_F5, InputEvent.CTRL_DOWN_MASK));
refreshItem.addActionListener(e -> refreshPanels());
JMenuItem queueItem = new JMenuItem("Operations Queue...");
queueItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_Q, InputEvent.ALT_DOWN_MASK));
queueItem.addActionListener(e -> OperationQueueDialog.showQueue(this));
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);
fileMenu.add(compareItem);
fileMenu.add(multiRenameItem);
fileMenu.add(refreshItem);
fileMenu.add(queueItem);
fileMenu.addSeparator();
fileMenu.add(ftpConnectItem);
fileMenu.add(ftpProfileItem);
fileMenu.addSeparator();
fileMenu.add(exitItem);
// View menu
JMenu viewMenu = new JMenu("View");
JMenuItem fullViewItem = new JMenuItem("Full details");
fullViewItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_F1, InputEvent.CTRL_DOWN_MASK));
fullViewItem.addActionListener(e -> setActiveViewMode(ViewMode.FULL));
JMenuItem briefViewItem = new JMenuItem("Names only");
briefViewItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_F2, InputEvent.CTRL_DOWN_MASK));
briefViewItem.addActionListener(e -> setActiveViewMode(ViewMode.BRIEF));
viewMenu.add(fullViewItem);
viewMenu.add(briefViewItem);
// Settings menu
JMenu settingsMenu = new JMenu("Settings");
settingsMenu.setMnemonic(KeyEvent.VK_O);
settingsMenu.addMouseListener(new MouseAdapter() {
@Override
public void mousePressed(MouseEvent e) {
if (SwingUtilities.isLeftMouseButton(e)) {
// Close the menu first to release focus
MenuSelectionManager.defaultManager().clearSelectedPath();
showSettingsDialog();
}
}
});
// Add key binding for Alt+O since JMenu doesn't support accelerators directly
settingsMenu.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(
KeyStroke.getKeyStroke(KeyEvent.VK_O, InputEvent.ALT_DOWN_MASK), "openSettings");
settingsMenu.getActionMap().put("openSettings", new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
showSettingsDialog();
}
});
// Help menu
JMenu helpMenu = new JMenu("Help");
JMenuItem aboutItem = new JMenuItem("About");
aboutItem.addActionListener(e -> showAboutDialog());
helpMenu.add(aboutItem);
menuBar.add(fileMenu);
menuBar.add(viewMenu);
menuBar.add(settingsMenu);
menuBar.add(helpMenu);
setJMenuBar(menuBar);
}
/**
* Show settings dialog and apply appearance changes
*/
private void showSettingsDialog() {
SettingsDialog dlg = new SettingsDialog(this, config, () -> applyAppearanceSettings());
dlg.setVisible(true);
// After dialog closed, ensure appearance applied
applyAppearanceSettings();
requestFocusInActivePanel();
}
/**
* Apply appearance settings (font/colors) from config to UI components.
*/
private void applyAppearanceSettings() {
updateAutoRefreshTimer();
Font gfont = config.getGlobalFont();
// Apply to toolbars, buttons and tables
SwingUtilities.invokeLater(() -> {
if (gfont != null) {
for (Component c : getContentPane().getComponents()) {
c.setFont(gfont);
}
}
// Apply to panels (font, colors, and layout refresh)
if (leftPanel != null) {
if (gfont != null) leftPanel.applyGlobalFont(gfont);
}
if (rightPanel != null) {
if (gfont != null) rightPanel.applyGlobalFont(gfont);
}
});
Color bg = config.getBackgroundColor();
if (bg != null) {
SwingUtilities.invokeLater(() -> {
updateComponentBackground(getContentPane(), bg);
if (leftPanel != null) leftPanel.applyBackgroundColor(bg);
if (rightPanel != null) rightPanel.applyBackgroundColor(bg);
// Update command line colors
if (commandLine != null) {
Component ed = commandLine.getEditor().getEditorComponent();
if (ed instanceof JTextField tf) {
tf.setBackground(bg);
boolean dark = isDark(bg);
tf.setForeground(dark ? Color.WHITE : Color.BLACK);
Color selColor = config.getSelectionColor();
if (selColor != null) {
tf.setSelectionColor(selColor);
}
tf.setCaretColor(dark ? Color.WHITE : Color.BLACK);
}
}
});
}
Color sel = config.getSelectionColor();
if (sel != null) {
if (leftPanel != null) leftPanel.applySelectionColor(sel);
if (rightPanel != null) rightPanel.applySelectionColor(sel);
// Apply selection color to command line editor for selection
if (commandLine != null) {
Component ed = commandLine.getEditor().getEditorComponent();
if (ed instanceof JTextField tf) {
tf.setSelectionColor(sel);
Color fieldBg = tf.getBackground();
boolean darkField = isDark(fieldBg);
tf.setCaretColor(darkField ? Color.WHITE : Color.BLACK);
tf.setSelectedTextColor(Color.WHITE); // Ensure selected text is readable on selection bg
}
}
// Ensure the active panel border uses the updated configuration color immediately
SwingUtilities.invokeLater(() -> updateActivePanelBorder());
}
Color mark = config.getMarkedColor();
if (mark != null) {
if (leftPanel != null) leftPanel.applyMarkedColor(mark);
if (rightPanel != null) rightPanel.applyMarkedColor(mark);
}
// Refresh toolbar if sizes changed
SwingUtilities.invokeLater(() -> createToolBar());
// Re-propagate AppConfig to panels so they can pick up sorting and other config changes
SwingUtilities.invokeLater(() -> {
try {
if (leftPanel != null) leftPanel.setAppConfig(config);
} catch (Exception ignore) {}
try {
if (rightPanel != null) rightPanel.setAppConfig(config);
} catch (Exception ignore) {}
});
}
private void updateComponentBackground(Container container, Color bg) {
if (container == null) return;
container.setBackground(bg);
boolean dark = isDark(bg);
Color selColor = config != null ? config.getSelectionColor() : null;
for (Component c : container.getComponents()) {
if (c instanceof JPanel || c instanceof JToolBar || c instanceof JScrollPane || c instanceof JViewport ||
c instanceof JTabbedPane || c instanceof JButton || c instanceof JSplitPane ||
c instanceof JList || c instanceof JComboBox || c instanceof JTable) {
c.setBackground(bg);
if (c instanceof JTable t) {
if (t.getTableHeader() != null) {
t.getTableHeader().setBackground(bg);
t.getTableHeader().setForeground(dark ? Color.WHITE : Color.BLACK);
}
}
}
if (c instanceof JLabel || c instanceof JCheckBox || c instanceof JRadioButton ||
c instanceof JButton || c instanceof JComboBox || c instanceof JList) {
c.setForeground(dark ? Color.WHITE : Color.BLACK);
}
if (c instanceof javax.swing.text.JTextComponent tc) {
tc.setBackground(bg);
tc.setForeground(dark ? Color.WHITE : Color.BLACK);
tc.setCaretColor(dark ? Color.WHITE : Color.BLACK);
if (selColor != null) {
tc.setSelectionColor(selColor);
}
}
if (c instanceof JComboBox<?> cb) {
Component ed = cb.getEditor().getEditorComponent();
if (ed instanceof javax.swing.text.JTextComponent tc) {
tc.setBackground(bg);
tc.setForeground(dark ? Color.WHITE : Color.BLACK);
tc.setCaretColor(dark ? Color.WHITE : Color.BLACK);
if (selColor != null) {
tc.setSelectionColor(selColor);
}
}
}
if (c instanceof Container container1) {
updateComponentBackground(container1, bg);
}
}
}
private boolean isDark(Color c) {
if (c == null) return false;
double darkness = 1 - (0.299 * c.getRed() + 0.587 * c.getGreen() + 0.114 * c.getBlue()) / 255;
return darkness >= 0.5;
}
/**
* Setup keyboard shortcuts
*/
private void setupKeyBindings() {
JRootPane rootPane = getRootPane();
// F3 - Viewer
rootPane.registerKeyboardAction(e -> viewFile(),
KeyStroke.getKeyStroke(KeyEvent.VK_F3, 0),
JComponent.WHEN_IN_FOCUSED_WINDOW);
// F4 - Editor
rootPane.registerKeyboardAction(e -> editFile(),
KeyStroke.getKeyStroke(KeyEvent.VK_F4, 0),
JComponent.WHEN_IN_FOCUSED_WINDOW);
// F5 - Copy
rootPane.registerKeyboardAction(e -> copyFiles(),
KeyStroke.getKeyStroke(KeyEvent.VK_F5, 0),
JComponent.WHEN_IN_FOCUSED_WINDOW);
// Alt+F5 - Zip
rootPane.registerKeyboardAction(e -> zipFiles(),
KeyStroke.getKeyStroke(KeyEvent.VK_F5, InputEvent.ALT_DOWN_MASK),
JComponent.WHEN_IN_FOCUSED_WINDOW);
// Alt+F9 - Unzip
rootPane.registerKeyboardAction(e -> unzipFiles(),
KeyStroke.getKeyStroke(KeyEvent.VK_F9, InputEvent.ALT_DOWN_MASK),
JComponent.WHEN_IN_FOCUSED_WINDOW);
// Alt+O - Settings
rootPane.registerKeyboardAction(e -> showSettingsDialog(),
KeyStroke.getKeyStroke(KeyEvent.VK_O, InputEvent.ALT_DOWN_MASK),
JComponent.WHEN_IN_FOCUSED_WINDOW);
// F6 - Move
rootPane.registerKeyboardAction(e -> moveFiles(),
KeyStroke.getKeyStroke(KeyEvent.VK_F6, 0),
JComponent.WHEN_IN_FOCUSED_WINDOW);
// Shift+F6 - Rename (alternative to F9)
rootPane.registerKeyboardAction(e -> renameFile(),
KeyStroke.getKeyStroke(KeyEvent.VK_F6, InputEvent.SHIFT_DOWN_MASK),
JComponent.WHEN_IN_FOCUSED_WINDOW);
// F7 - New directory
rootPane.registerKeyboardAction(e -> createNewDirectory(),
KeyStroke.getKeyStroke(KeyEvent.VK_F7, 0),
JComponent.WHEN_IN_FOCUSED_WINDOW);
// Shift+F4 - New file
rootPane.registerKeyboardAction(e -> createNewFile(),
KeyStroke.getKeyStroke(KeyEvent.VK_F4, InputEvent.SHIFT_DOWN_MASK),
JComponent.WHEN_IN_FOCUSED_WINDOW);
// F8 - Delete
rootPane.registerKeyboardAction(e -> deleteFiles(),
KeyStroke.getKeyStroke(KeyEvent.VK_F8, 0),
JComponent.WHEN_IN_FOCUSED_WINDOW);
// F9 - Open terminal
rootPane.registerKeyboardAction(e -> openTerminal(),
KeyStroke.getKeyStroke(KeyEvent.VK_F9, 0),
JComponent.WHEN_IN_FOCUSED_WINDOW);
// Ctrl+TAB - switch tabs in active panel
rootPane.registerKeyboardAction(e -> {
if (activePanel != null) {
activePanel.nextTab();
}
}, KeyStroke.getKeyStroke(KeyEvent.VK_TAB, InputEvent.CTRL_DOWN_MASK),
JComponent.WHEN_IN_FOCUSED_WINDOW);
// ESC - global escape to return focus to panels
rootPane.registerKeyboardAction(e -> {
boolean textCleared = false;
Object currentItem = commandLine.getEditor().getItem();
if (currentItem != null && !currentItem.toString().isEmpty()) {
commandLine.getEditor().setItem("");
commandHistoryIndex = -1;
textCleared = true;
}
// If we just cleared text, or if focus is not in any table, return focus to active panel
Component focusOwner = KeyboardFocusManager.getCurrentKeyboardFocusManager().getFocusOwner();
boolean inTable = (focusOwner instanceof JTable);
if (textCleared || !inTable) {
if (activePanel != null && activePanel.getFileTable() != null) {
activePanel.getFileTable().requestFocusInWindow();
}
}
}, KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0),
JComponent.WHEN_IN_FOCUSED_WINDOW);
// Delete key - global delete binding (also added per-table)
rootPane.registerKeyboardAction(e -> deleteFiles(),
KeyStroke.getKeyStroke(KeyEvent.VK_DELETE, 0),
JComponent.WHEN_IN_FOCUSED_WINDOW);
// Shift+Delete - treat as delete as well
rootPane.registerKeyboardAction(e -> deleteFiles(),
KeyStroke.getKeyStroke(KeyEvent.VK_DELETE, InputEvent.SHIFT_DOWN_MASK),
JComponent.WHEN_IN_FOCUSED_WINDOW);
// No direct F9 keyboard binding: inline rename should only be triggered by Shift+F6
// TAB - switch panel (global) - works even when additional tabs are opened
rootPane.registerKeyboardAction(e -> switchPanels(),
KeyStroke.getKeyStroke(KeyEvent.VK_TAB, 0),
JComponent.WHEN_IN_FOCUSED_WINDOW);
// Alt+F1 - Select drive for left panel
rootPane.registerKeyboardAction(e -> selectDriveForLeftPanel(),
KeyStroke.getKeyStroke(KeyEvent.VK_F1, InputEvent.ALT_DOWN_MASK),
JComponent.WHEN_IN_FOCUSED_WINDOW);
// Alt+F2 - Select drive for right panel
rootPane.registerKeyboardAction(e -> selectDriveForRightPanel(),
KeyStroke.getKeyStroke(KeyEvent.VK_F2, InputEvent.ALT_DOWN_MASK),
JComponent.WHEN_IN_FOCUSED_WINDOW);
// Alt+F7 - Search (changed from Ctrl+F)
rootPane.registerKeyboardAction(e -> showSearchDialog(),
KeyStroke.getKeyStroke(KeyEvent.VK_F7, InputEvent.ALT_DOWN_MASK),
JComponent.WHEN_IN_FOCUSED_WINDOW);
// Ctrl+M - Multi-Rename Tool
rootPane.registerKeyboardAction(e -> showMultiRenameDialog(),
KeyStroke.getKeyStroke(KeyEvent.VK_M, InputEvent.CTRL_DOWN_MASK),
JComponent.WHEN_IN_FOCUSED_WINDOW);
// Ctrl+F1 - Full details
rootPane.registerKeyboardAction(e -> setActiveViewMode(ViewMode.FULL),
KeyStroke.getKeyStroke(KeyEvent.VK_F1, InputEvent.CTRL_DOWN_MASK),
JComponent.WHEN_IN_FOCUSED_WINDOW);
// Ctrl+F2 - Names only
rootPane.registerKeyboardAction(e -> setActiveViewMode(ViewMode.BRIEF),
KeyStroke.getKeyStroke(KeyEvent.VK_F2, InputEvent.CTRL_DOWN_MASK),
JComponent.WHEN_IN_FOCUSED_WINDOW);
// Ctrl+T - New tab
rootPane.registerKeyboardAction(e -> addNewTabToActivePanel(),
KeyStroke.getKeyStroke(KeyEvent.VK_T, InputEvent.CTRL_DOWN_MASK),
JComponent.WHEN_IN_FOCUSED_WINDOW);
// 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) {
activePanel.getCurrentTab().loadDirectory(new File(System.getProperty("user.home")));
}
}, KeyStroke.getKeyStroke(KeyEvent.VK_BACK_QUOTE, InputEvent.CTRL_DOWN_MASK),
JComponent.WHEN_IN_FOCUSED_WINDOW);
// Ctrl+C - Copy to clipboard
rootPane.registerKeyboardAction(e -> {
if (activePanel != null && activePanel.getCurrentTab() != null) {
activePanel.getCurrentTab().copyToClipboard(false);
}
}, KeyStroke.getKeyStroke(KeyEvent.VK_C, InputEvent.CTRL_DOWN_MASK),
JComponent.WHEN_IN_FOCUSED_WINDOW);
// Ctrl+X - Cut to clipboard
rootPane.registerKeyboardAction(e -> {
if (activePanel != null && activePanel.getCurrentTab() != null) {
activePanel.getCurrentTab().copyToClipboard(true);
}
}, KeyStroke.getKeyStroke(KeyEvent.VK_X, InputEvent.CTRL_DOWN_MASK),
JComponent.WHEN_IN_FOCUSED_WINDOW);
// Ctrl+V - Paste from clipboard
rootPane.registerKeyboardAction(e -> {
if (activePanel != null && activePanel.getCurrentTab() != null) {
activePanel.getCurrentTab().pasteFromClipboard();
}
}, KeyStroke.getKeyStroke(KeyEvent.VK_V, InputEvent.CTRL_DOWN_MASK),
JComponent.WHEN_IN_FOCUSED_WINDOW);
// Ctrl+Q - Quick View
rootPane.registerKeyboardAction(e -> toggleQuickView(),
KeyStroke.getKeyStroke(KeyEvent.VK_Q, InputEvent.CTRL_DOWN_MASK),
JComponent.WHEN_IN_FOCUSED_WINDOW);
// Ctrl+E - Command line history
rootPane.registerKeyboardAction(e -> showCommandLineHistory(),
KeyStroke.getKeyStroke(KeyEvent.VK_E, InputEvent.CTRL_DOWN_MASK),
JComponent.WHEN_IN_FOCUSED_WINDOW);
}
/**
* Update panel borders according to active panel
*/
private void updateActivePanelBorder() {
Color selColor = config.getSelectionColor();
if (selColor == null) selColor = new Color(184, 207, 229);
// Delegate active state visualization to the panels themselves
leftPanel.setActive(activePanel == leftPanel, selColor);
rightPanel.setActive(activePanel == rightPanel, selColor);
}
/**
* Switch between panels
*/
private void toggleQuickView() {
FilePanel opposite = (activePanel == leftPanel) ? rightPanel : leftPanel;
if (opposite.getViewMode() == ViewMode.INFO) {
opposite.setViewMode(opposite.getPreviousViewMode(), false);
} else {
opposite.setViewMode(ViewMode.INFO, false);
updateQuickViewInfo();
}
}
private void updateQuickViewInfo() {
FilePanel opposite = (activePanel == leftPanel) ? rightPanel : leftPanel;
if (opposite.getViewMode() == ViewMode.INFO) {
FileItem selected = activePanel.getFocusedItem();
opposite.setInfoItem(selected);
}
}
private void switchPanels() {
// Determine which panel currently (or recently) has focus by inspecting the focus owner.
java.awt.Component owner = java.awt.KeyboardFocusManager.getCurrentKeyboardFocusManager().getFocusOwner();
boolean ownerInLeft = false;
boolean ownerInRight = false;
if (owner != null) {
Component p = owner;
while (p != null) {
if (p == leftPanel) {
ownerInLeft = true;
break;
}
if (p == rightPanel) {
ownerInRight = true;
break;
}
p = p.getParent();
}
}
if (ownerInLeft) {
rightPanel.requestFocusOnCurrentTab();
activePanel = rightPanel;
} else if (ownerInRight) {
leftPanel.requestFocusOnCurrentTab();
activePanel = leftPanel;
} else {
// Fallback to previous behavior based on activePanel
if (activePanel == leftPanel) {
rightPanel.requestFocusOnCurrentTab();
activePanel = rightPanel;
} else {
leftPanel.requestFocusOnCurrentTab();
activePanel = leftPanel;
}
}
// Update panel borders and force repaint so renderer can update focus colors
updateActivePanelBorder();
JTable lt = leftPanel.getFileTable();
JTable rt = rightPanel.getFileTable();
if (lt != null) lt.repaint();
if (rt != null) rt.repaint();
updateCommandLinePrompt();
}
/**
* Public wrapper so child components (tabs) can request a panel switch.
*/
public void switchPanelsFromChild() {
switchPanels();
}
public FilePanel getActivePanel() {
return activePanel;
}
private void updateCommandLinePrompt() {
if (cmdLabel == null) return;
FilePanel active = activePanel;
if (active == null) return;
String path = active.getCurrentPath();
String displayPath = path;
if (path.length() > 33) {
displayPath = path.substring(0, 15) + "..." + path.substring(path.length() - 15);
}
cmdLabel.setText(displayPath + ">");
cmdLabel.setToolTipText(path);
}
/**
* Attach all necessary listeners and handlers to a file table
*/
private void setupFileTable(JTable table, FilePanel panel) {
if (table == null) return;
addTabKeyHandler(table);
addCommandLineRedirect(table);
// Focus listener to track active panel and ensure selection
table.addFocusListener(new FocusAdapter() {
@Override
public void focusGained(FocusEvent e) {
activePanel = panel;
updateActivePanelBorder();
updateCommandLinePrompt();
// Ensure some row is selected
if (table.getSelectedRow() == -1 && table.getRowCount() > 0) {
table.setRowSelectionInterval(0, 0);
}
}
@Override
public void focusLost(FocusEvent e) {
table.repaint();
}
});
// Selection listener for Quick View updates
table.getSelectionModel().addListSelectionListener(e -> {
if (!e.getValueIsAdjusting() && activePanel == panel) {
updateQuickViewInfo();
}
});
}
/**
* Attach TAB handling to switch panels
*/
public void addTabKeyHandler(JTable table) {
if (table == null) return;
// Remove standard Swing TAB behavior
table.getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT)
.put(KeyStroke.getKeyStroke(KeyEvent.VK_TAB, 0), "switchPanel");
// Disable default F6/Shift+F6 bindings which interfere with our Move/Rename actions
table.getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT).put(KeyStroke.getKeyStroke(KeyEvent.VK_F6, 0), "none");
table.getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT).put(KeyStroke.getKeyStroke(KeyEvent.VK_F6, InputEvent.SHIFT_DOWN_MASK), "none");
table.getActionMap().put("switchPanel", new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
switchPanels();
}
});
// Add Ctrl+Tab handling at the table level
table.getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT)
.put(KeyStroke.getKeyStroke(KeyEvent.VK_TAB, InputEvent.CTRL_DOWN_MASK), "nextTab");
table.getActionMap().put("nextTab", new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
if (activePanel != null) {
activePanel.nextTab();
}
}
});
// Add F8 for deleting with higher priority than default Swing actions
table.getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT)
.put(KeyStroke.getKeyStroke(KeyEvent.VK_F8, 0), "deleteFiles");
table.getActionMap().put("deleteFiles", new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
deleteFiles();
}
});
// Also map Delete key to deleteFiles on the table level so it works when table has focus
table.getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT)
.put(KeyStroke.getKeyStroke(KeyEvent.VK_DELETE, 0), "deleteFiles");
// Also map Shift+Delete on table level
table.getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT)
.put(KeyStroke.getKeyStroke(KeyEvent.VK_DELETE, InputEvent.SHIFT_DOWN_MASK), "deleteFiles");
// Clipboard support (Ctrl+C, Ctrl+X, Ctrl+V)
table.getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT)
.put(KeyStroke.getKeyStroke(KeyEvent.VK_C, InputEvent.CTRL_DOWN_MASK), "clipboardCopy");
table.getActionMap().put("clipboardCopy", new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
if (activePanel != null && activePanel.getCurrentTab() != null) {
activePanel.getCurrentTab().copyToClipboard(false);
}
}
});
table.getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT)
.put(KeyStroke.getKeyStroke(KeyEvent.VK_X, InputEvent.CTRL_DOWN_MASK), "clipboardCut");
table.getActionMap().put("clipboardCut", new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
if (activePanel != null && activePanel.getCurrentTab() != null) {
activePanel.getCurrentTab().copyToClipboard(true);
}
}
});
table.getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT)
.put(KeyStroke.getKeyStroke(KeyEvent.VK_V, InputEvent.CTRL_DOWN_MASK), "clipboardPaste");
table.getActionMap().put("clipboardPaste", new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
if (activePanel != null && activePanel.getCurrentTab() != null) {
activePanel.getCurrentTab().pasteFromClipboard();
}
}
});
// Selection (Ctrl+A for Invert, + for Wildcard)
table.getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT)
.put(KeyStroke.getKeyStroke(KeyEvent.VK_A, InputEvent.CTRL_DOWN_MASK), "invertSelection");
table.getActionMap().put("invertSelection", new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
if (activePanel != null) {
activePanel.invertSelection();
}
}
});
table.getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT)
.put(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), "unselectAll");
table.getActionMap().put("unselectAll", new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
if (activePanel != null) {
activePanel.unselectAll();
}
}
});
table.getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT)
.put(KeyStroke.getKeyStroke('+'), "wildcardSelect");
table.getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT)
.put(KeyStroke.getKeyStroke(KeyEvent.VK_ADD, 0), "wildcardSelect");
table.getActionMap().put("wildcardSelect", new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
showWildcardSelectDialog();
}
});
}
/**
* Automatically focus command line when user starts typing on a table
*/
public void addCommandLineRedirect(JTable table) {
if (table == null) return;
// Use InputMap/ActionMap for Ctrl+Enter and Ctrl+Shift+Enter as KeyListener might be bypassed by JTable
table.getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT)
.put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, InputEvent.CTRL_DOWN_MASK), "copyNameToCmd");
table.getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT)
.put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, InputEvent.CTRL_DOWN_MASK | InputEvent.SHIFT_DOWN_MASK), "copyPathToCmd");
table.getActionMap().put("copyNameToCmd", new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
copyFocusedToCommandLine(false);
}
});
table.getActionMap().put("copyPathToCmd", new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
copyFocusedToCommandLine(true);
}
});
table.addKeyListener(new KeyAdapter() {
@Override
public void keyTyped(KeyEvent e) {
char c = e.getKeyChar();
// Transfer to command line only for printable characters and when no modifiers like Ctrl or Alt are pressed.
// This prevents focus jump on shortcuts like Ctrl+C, Ctrl+V, etc.
// Exclude '+' as it is used for wildcard selection.
if (c != KeyEvent.CHAR_UNDEFINED && c >= 32 && c != 127 && c != '+' && !e.isControlDown() && !e.isAltDown()) {
commandLine.requestFocusInWindow();
String current = commandLine.getEditor().getItem().toString();
commandLine.getEditor().setItem(current + c);
e.consume();
}
}
});
}
private void copyFocusedToCommandLine(boolean fullPath) {
FileItem focused = activePanel.getFocusedItem();
if (focused != null && !focused.getName().equals("..")) {
String toAdd = fullPath ? focused.getFile().getAbsolutePath() : focused.getName();
// If it contains spaces, wrap in quotes
if (toAdd.contains(" ")) {
toAdd = "\"" + toAdd + "\"";
}
String current = commandLine.getEditor().getItem().toString();
String newText;
if (!current.isEmpty() && !current.endsWith(" ")) {
newText = current + " " + toAdd;
} else {
newText = current + toAdd;
}
commandLine.getEditor().setItem(newText);
commandLine.requestFocusInWindow();
}
}
private void copyFocusedToCommandLineAtCaret(JTextField editor, boolean fullPath) {
if (editor == null || activePanel == null) return;
FileItem focused = activePanel.getFocusedItem();
if (focused == null || focused.getName().equals("..")) return;
String toAdd = fullPath ? focused.getFile().getAbsolutePath() : focused.getName();
if (toAdd.contains(" ")) {
toAdd = "\"" + toAdd + "\"";
}
String current = editor.getText();
int caret = editor.getCaretPosition();
if (caret < 0) caret = 0;
if (caret > current.length()) caret = current.length();
String before = current.substring(0, caret);
String after = current.substring(caret);
boolean addSpaceBefore = !before.isEmpty() && !Character.isWhitespace(before.charAt(before.length() - 1));
boolean addSpaceAfter = !after.isEmpty() && !Character.isWhitespace(after.charAt(0));
String insertion = (addSpaceBefore ? " " : "") + toAdd + (addSpaceAfter ? " " : "");
String newText = before + insertion + after;
editor.setText(newText);
int newCaret = before.length() + insertion.length();
editor.setCaretPosition(Math.min(newCaret, newText.length()));
commandLine.requestFocusInWindow();
}
/**
* Copy selected files to the opposite panel
*/
private void copyFiles() {
List<FileItem> selectedItems = activePanel.getSelectedItems();
if (selectedItems.isEmpty()) {
JOptionPane.showMessageDialog(this,
"No files selected",
"Copy",
JOptionPane.INFORMATION_MESSAGE);
requestFocusInActivePanel();
return;
}
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 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 {
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() {
List<FileItem> selectedItems = activePanel.getSelectedItems();
if (selectedItems.isEmpty()) {
JOptionPane.showMessageDialog(this,
"No files selected",
"Move",
JOptionPane.INFORMATION_MESSAGE);
requestFocusInActivePanel();
return;
}
FilePanel targetPanel = (activePanel == leftPanel) ? rightPanel : leftPanel;
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 {
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);
}
}
}
}
private int showConfirmWithBackground(String message, String title) {
Object[] options = {"OK", "Background (F2)", "Cancel"};
JOptionPane pane = new JOptionPane(message, JOptionPane.QUESTION_MESSAGE, JOptionPane.YES_NO_CANCEL_OPTION, null, options, options[0]);
JDialog dialog = pane.createDialog(this, title);
pane.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke(KeyEvent.VK_F2, 0), "background");
pane.getActionMap().put("background", new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
pane.setValue(options[1]);
dialog.dispose();
}
});
pane.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, KeyEvent.SHIFT_DOWN_MASK), "confirmShiftEnter");
pane.getActionMap().put("confirmShiftEnter", new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
pane.setValue(options[0]);
dialog.dispose();
}
});
dialog.setVisible(true);
// Ensure focus returns to main window after dialog
this.toFront();
requestFocusInActivePanel();
Object selectedValue = pane.getValue();
if (selectedValue == null) return 2; // Cancel
if (selectedValue.equals(options[0])) return 0; // OK
if (selectedValue.equals(options[1])) return 1; // Background
return 2; // Cancel
}
/**
* Delete selected files
*/
private void deleteFiles() {
List<FileItem> selectedItems = activePanel.getSelectedItems();
if (selectedItems.isEmpty()) {
JOptionPane.showMessageDialog(this,
"No files selected",
"Delete",
JOptionPane.INFORMATION_MESSAGE);
requestFocusInActivePanel();
return;
}
// remember current selection index so we can restore selection after deletion
final int rememberedIndex = (activePanel != null && activePanel.getCurrentTab() != null) ?
activePanel.getCurrentTab().getFocusedItemIndex() : -1;
StringBuilder message = new StringBuilder("Really delete following %d items?\n\n".formatted(selectedItems.size()));
for (int i = 0; i < Math.min(selectedItems.size(), 5); i++) {
message.append(selectedItems.get(i).getName()).append("\n");
}
if (selectedItems.size() > 5) {
message.append("... and %d more items.".formatted(selectedItems.size() - 5));
}
int result = showConfirmWithBackground(message.toString(), "Delete");
if (result == 0 || result == 1) {
boolean background = (result == 1);
if (background) {
addOperationToQueue("Delete", "Delete %d items".formatted(selectedItems.size()),
(cb) -> FileOperations.delete(selectedItems, cb), activePanel);
} else {
performFileOperation((callback) -> {
FileOperations.delete(selectedItems, callback);
}, "Delete completed", false, true, activePanel);
// After deletion and refresh, restore selection: move focus to the nearest higher item.
SwingUtilities.invokeLater(() -> {
try {
if (activePanel != null && activePanel.getCurrentTab() != null) {
// Use another invokeLater to ensure BRIEF mode layout is updated
SwingUtilities.invokeLater(() -> {
activePanel.getCurrentTab().selectItemByIndex(rememberedIndex);
});
}
} catch (Exception ignore) {}
});
}
} else {
requestFocusInActivePanel();
}
}
/**
* Zip selected files
*/
private void zipFiles() {
List<FileItem> selectedItems = activePanel.getSelectedItems();
if (selectedItems.isEmpty()) {
JOptionPane.showMessageDialog(this,
"No files selected",
"Zip",
JOptionPane.INFORMATION_MESSAGE);
requestFocusInActivePanel();
return;
}
String defaultName;
if (selectedItems.size() == 1) {
defaultName = selectedItems.getFirst().getName();
} else {
defaultName = activePanel.getCurrentDirectory().getName();
if (defaultName == null || defaultName.isEmpty() || defaultName.equals("/") || defaultName.endsWith(":")) {
defaultName = "archive";
}
}
if (defaultName.contains(".")) {
int lastDot = defaultName.lastIndexOf('.');
if (lastDot > 0) {
defaultName = defaultName.substring(0, lastDot);
}
}
defaultName += ".zip";
JPanel panel = new JPanel(new GridBagLayout());
GridBagConstraints gbc = new GridBagConstraints();
gbc.fill = GridBagConstraints.HORIZONTAL;
gbc.insets = new Insets(5, 5, 5, 5);
gbc.gridx = 0;
gbc.gridy = 0;
panel.add(new JLabel("Enter zip filename:"), gbc);
gbc.gridy = 1;
JTextField zipNameField = new JTextField(defaultName, 20);
panel.add(zipNameField, gbc);
gbc.gridy = 2;
JCheckBox encryptCheckBox = new JCheckBox("Encrypt with password");
encryptCheckBox.setMnemonic(KeyEvent.VK_E);
panel.add(encryptCheckBox, gbc);
gbc.gridy = 3;
JPasswordField passwordField = new JPasswordField(20);
passwordField.setEnabled(false);
panel.add(passwordField, gbc);
encryptCheckBox.addActionListener(e -> {
passwordField.setEnabled(encryptCheckBox.isSelected());
if (encryptCheckBox.isSelected()) {
passwordField.requestFocusInWindow();
}
});
int option = JOptionPane.showConfirmDialog(this, panel, "Zip archive", JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE);
if (option != JOptionPane.OK_OPTION || zipNameField.getText().trim().isEmpty()) {
requestFocusInActivePanel();
return;
}
String zipName = zipNameField.getText().trim();
if (!zipName.toLowerCase().endsWith(".zip")) {
zipName += ".zip";
}
String password = encryptCheckBox.isSelected() ? new String(passwordField.getPassword()) : null;
FilePanel targetPanel = (activePanel == leftPanel) ? rightPanel : leftPanel;
File targetDir = targetPanel.getCurrentDirectory();
File targetZip = new File(targetDir, zipName);
if (targetZip.exists()) {
int confirm = JOptionPane.showConfirmDialog(this,
"File already exists. Overwrite?", "Zip", JOptionPane.YES_NO_OPTION);
if (confirm != JOptionPane.YES_OPTION) {
if (activePanel != null && activePanel.getFileTable() != null) {
activePanel.getFileTable().requestFocusInWindow();
}
return;
}
}
final File finalTargetZip = targetZip;
final String finalPassword = password;
final FilePanel sourcePanel = activePanel;
int result = showConfirmWithBackground(
"Zip %d items to:\n%s".formatted(selectedItems.size(), targetZip.getAbsolutePath()),
"Zip");
if (result == 0 || result == 1) {
boolean background = (result == 1);
if (background) {
addOperationToQueue("Zip", "Zip %d items to %s".formatted(selectedItems.size(), finalTargetZip.getName()),
(cb) -> FileOperations.zip(selectedItems, finalTargetZip, finalPassword, cb), () -> sourcePanel.unselectAll(), targetPanel);
} else {
performFileOperation((callback) -> {
FileOperations.zip(selectedItems, finalTargetZip, finalPassword, callback);
}, "Zipped into " + zipName, false, true, () -> sourcePanel.unselectAll(), targetPanel);
}
} else {
requestFocusInActivePanel();
}
}
/**
* Extract selected archive
*/
private void unzipFiles() {
List<FileItem> selectedItems = activePanel.getSelectedItems();
if (selectedItems.isEmpty()) {
JOptionPane.showMessageDialog(this,
"No files selected",
"Extract",
JOptionPane.INFORMATION_MESSAGE);
requestFocusInActivePanel();
return;
}
File archiveFile = selectedItems.getFirst().getFile();
if (!FileOperations.isArchiveFile(archiveFile)) {
JOptionPane.showMessageDialog(this,
"Selected file is not a supported archive",
"Extract",
JOptionPane.ERROR_MESSAGE);
requestFocusInActivePanel();
return;
}
FilePanel targetPanel = (activePanel == leftPanel) ? rightPanel : leftPanel;
File targetDir = targetPanel.getCurrentDirectory();
final FilePanel sourcePanel = activePanel;
int result = showConfirmWithBackground(
"Extract %s to:\n%s".formatted(archiveFile.getName(), targetDir.getAbsolutePath()),
"Extract archive");
if (result == 0 || result == 1) {
boolean background = (result == 1);
if (background) {
addOperationToQueue("Extract", "Extract %s to %s".formatted(archiveFile.getName(), targetDir.getName()),
(cb) -> FileOperations.extractArchive(archiveFile, targetDir, cb), () -> sourcePanel.unselectAll(), targetPanel);
} else {
performFileOperation((callback) -> {
FileOperations.extractArchive(archiveFile, targetDir, callback);
}, "Extracted into " + targetDir.getName(), false, true, () -> sourcePanel.unselectAll(), targetPanel);
}
} else {
if (activePanel != null && activePanel.getFileTable() != null) {
activePanel.getFileTable().requestFocusInWindow();
}
}
}
/**
* Rename selected file
*/
private void renameFile() {
// Start inline rename in the active panel (it will handle validation)
if (activePanel != null) {
try {
activePanel.startInlineRename();
} catch (Exception ex) {
// Fallback to dialog-based rename if inline fails for some reason
List<FileItem> selectedItems = activePanel.getSelectedItems();
if (selectedItems.size() != 1) {
JOptionPane.showMessageDialog(this,
"Select one file to rename",
"Rename",
JOptionPane.INFORMATION_MESSAGE);
requestFocusInActivePanel();
return;
}
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, newName.trim());
}, "Rename completed", false, activePanel);
} else {
if (activePanel != null && activePanel.getFileTable() != null) {
activePanel.getFileTable().requestFocusInWindow();
}
}
}
}
}
/**
* 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("..")) {
initialValue = focusedItem.getName();
}
String dirNameInput = JOptionPane.showInputDialog(this,
"New directory name:",
initialValue);
if (dirNameInput != null && !dirNameInput.trim().isEmpty()) {
final String dirName = dirNameInput.trim();
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();
}
}
}
private void createNewFile() {
FilePanelTab currentTab = activePanel.getCurrentTab();
String initialValue = "New file.txt";
FileItem focusedItem = activePanel.getFocusedItem();
if (focusedItem != null && !focusedItem.getName().equals("..")) {
initialValue = focusedItem.getName();
}
String fileNameInput = JOptionPane.showInputDialog(this,
"New file name:",
initialValue);
if (fileNameInput != null && !fileNameInput.trim().isEmpty()) {
final String fileName = fileNameInput.trim();
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();
}
}
}
/**
* Show search dialog
*/
private void showSearchDialog() {
SearchDialog dialog = new SearchDialog(this, activePanel.getCurrentDirectory(), config);
dialog.addWindowListener(new java.awt.event.WindowAdapter() {
@Override
public void windowClosed(java.awt.event.WindowEvent e) {
requestFocusInActivePanel();
}
});
dialog.setVisible(true);
}
/**
* Show Sync Directories dialog
*/
private void showSyncDialog() {
File leftDir = leftPanel.getCurrentDirectory();
File rightDir = rightPanel.getCurrentDirectory();
SyncDirectoriesDialog dialog = new SyncDirectoriesDialog(this, leftDir, rightDir, config);
dialog.setVisible(true);
// After closing sync dialog, return focus to the active panel
requestFocusInActivePanel();
}
/**
* Show wildcard select dialog
*/
public void showWildcardSelectDialog() {
if (activePanel == null) return;
if (wildcardDialogOpen) return;
wildcardDialogOpen = true;
try {
WildcardSelectDialog dialog = new WildcardSelectDialog(this, pattern -> {
if (activePanel != null) {
activePanel.selectByWildcard(pattern);
}
});
dialog.setVisible(true);
} finally {
wildcardDialogOpen = false;
// Return focus to the active panel after dialog is closed
if (activePanel != null && activePanel.getFileTable() != null) {
activePanel.getFileTable().requestFocusInWindow();
}
}
}
private void showMultiRenameDialog() {
if (activePanel == null) {
return;
}
List<FileItem> selectedItems = activePanel.getSelectedItems();
if (selectedItems.isEmpty()) {
JOptionPane.showMessageDialog(this,
"Select files for multi-rename first.",
"Multi-Rename Tool",
JOptionPane.INFORMATION_MESSAGE);
requestFocusInActivePanel();
return;
}
List<FileItem> candidates = new ArrayList<>();
for (FileItem item : selectedItems) {
if (!"..".equals(item.getName())) {
candidates.add(item);
}
}
if (candidates.isEmpty()) {
requestFocusInActivePanel();
return;
}
List<MultiRenameDialog.RenamePlan> plans = MultiRenameDialog.showDialog(this, candidates);
if (plans.isEmpty()) {
requestFocusInActivePanel();
return;
}
// Validate collisions with existing files that are not being renamed in this batch.
java.util.Set<String> oldNamesLower = new java.util.HashSet<>();
for (MultiRenameDialog.RenamePlan plan : plans) {
oldNamesLower.add(plan.getOldName().toLowerCase());
}
for (MultiRenameDialog.RenamePlan plan : plans) {
FileItem item = plan.getItem();
if (item.isFtp()) {
continue;
}
File source = item.getFile();
if (source == null || source.getParentFile() == null) {
continue;
}
File target = new File(source.getParentFile(), plan.getNewName());
if (target.exists() && !oldNamesLower.contains(target.getName().toLowerCase())) {
JOptionPane.showMessageDialog(this,
"Target already exists: " + target.getName(),
"Multi-Rename Tool",
JOptionPane.ERROR_MESSAGE);
requestFocusInActivePanel();
return;
}
}
String[] firstRenamed = new String[] {plans.getFirst().getNewName()};
performFileOperation((callback) -> {
long total = plans.size();
long current = 0;
for (MultiRenameDialog.RenamePlan plan : plans) {
if (callback.isCancelled()) {
break;
}
FileOperations.rename(plan.getItem(), plan.getNewName());
current++;
callback.onProgress(current, total, plan.getOldName() + " -> " + plan.getNewName());
}
if (activePanel != null) {
syncTargetArchiveIfNeeded(activePanel, activePanel.getCurrentDirectory(), callback);
}
}, "Multi-rename completed", false, () -> {
if (activePanel != null && activePanel.getCurrentTab() != null && firstRenamed[0] != null) {
activePanel.getCurrentTab().selectItem(firstRenamed[0]);
}
}, activePanel);
}
/**
* Show the given file's parent directory in the panel that currently has focus
* and select the file in that panel.
*/
public void showFileInFocusedPanel(File file) {
if (file == null) return;
FilePanel target = getFocusedFilePanel();
final FilePanel chosen = target;
final File parentDir = file.getParentFile();
if (parentDir == null) return;
// Load directory and then select item by name on the EDT
SwingUtilities.invokeLater(() -> {
FilePanelTab tab = chosen.getCurrentTab();
if (tab != null) {
tab.loadDirectory(parentDir, false, true, () -> {
tab.selectItem(file.getName());
tab.getFileTable().requestFocusInWindow();
});
}
// mark this panel active and refresh borders
activePanel = chosen;
updateActivePanelBorder();
});
}
/**
* Show a file that is inside an archive in the focused panel.
*/
public void showArchiveFileInFocusedPanel(File archive, String entryName) {
if (archive == null || entryName == null) return;
FilePanel target = getFocusedFilePanel();
final FilePanel chosen = target;
SwingUtilities.invokeLater(() -> {
chosen.showArchiveFile(archive, entryName);
activePanel = chosen;
updateActivePanelBorder();
});
}
private FilePanel getFocusedFilePanel() {
java.awt.Component owner = java.awt.KeyboardFocusManager.getCurrentKeyboardFocusManager().getFocusOwner();
FilePanel target = null;
if (owner != null) {
Component p = owner;
while (p != null) {
if (p == leftPanel) {
target = leftPanel;
break;
}
if (p == rightPanel) {
target = rightPanel;
break;
}
p = p.getParent();
}
}
if (target == null) target = activePanel != null ? activePanel : leftPanel;
return target;
}
/**
* Show file in internal viewer
*/
private void viewFile() {
List<FileItem> selectedItems = activePanel.getSelectedItems();
if (selectedItems.isEmpty()) {
return;
}
FileItem item = selectedItems.getFirst();
if (item.isDirectory() || item.getName().equals("..")) {
return;
}
if (item.isFtp()) {
try {
File tempFile = Files.createTempFile("kf-ftp-view-", "-" + item.getName()).toFile();
tempFile.deleteOnExit();
performFileOperation((cb) -> {
cz.kamma.kfmanager.service.FtpService.downloadFile(item.getFtpProfile(), item.getFtpPath(), tempFile, cb);
}, "Download finished", false, false, () -> {
FileEditor viewer = new FileEditor(this, tempFile, item.getFtpPath(), config, true);
viewer.addWindowListener(new java.awt.event.WindowAdapter() {
@Override
public void windowClosed(java.awt.event.WindowEvent e) {
try { tempFile.delete(); } catch(Exception ignore) {}
requestFocusInActivePanel();
}
});
viewer.setVisible(true);
});
} catch (IOException e) {
JOptionPane.showMessageDialog(this, "Could not create temp file: " + e.getMessage(), "Error", JOptionPane.ERROR_MESSAGE);
}
return;
}
File file = item.getFile();
// Removed previous 10 MB limit: allow opening large files in the viewer (paged hex will stream large binaries).
FileEditor viewer = new FileEditor(this, file, config, true);
viewer.addWindowListener(new java.awt.event.WindowAdapter() {
@Override
public void windowClosed(java.awt.event.WindowEvent e) {
requestFocusInActivePanel();
}
});
viewer.setVisible(true);
}
/**
* Open file in internal editor
*/
private void editFile() {
List<FileItem> selectedItems = activePanel.getSelectedItems();
if (selectedItems.isEmpty()) {
return;
}
FileItem item = selectedItems.getFirst();
if (item.isDirectory() || item.getName().equals("..")) {
return;
}
if (item.isFtp()) {
try {
File tempFile = Files.createTempFile("kf-ftp-edit-", "-" + item.getName()).toFile();
tempFile.deleteOnExit();
performFileOperation((cb) -> {
cz.kamma.kfmanager.service.FtpService.downloadFile(item.getFtpProfile(), item.getFtpPath(), tempFile, cb);
}, "Download finished", false, false, () -> {
// Check if an external editor is configured
String ext = config.getExternalEditorPath();
if (ext != null && !ext.trim().isEmpty()) {
try {
java.util.List<String> cmd = new java.util.ArrayList<>();
cmd.add(ext);
cmd.add(tempFile.getAbsolutePath());
new ProcessBuilder(cmd).start();
// Optional: Monitor process to upload back on exit?
// For now internal editor is safer for auto-upload.
} catch (Exception ex) {
JOptionPane.showMessageDialog(this, "External editor failed: " + ex.getMessage(), "Error", JOptionPane.ERROR_MESSAGE);
}
}
FileEditor editor = new FileEditor(this, tempFile, item.getFtpPath(), config, false);
editor.setFtpDetails(item.getFtpProfile(), item.getFtpPath(), () -> {
// Success callback after upload
if (activePanel != null) activePanel.refresh(false);
});
editor.addWindowListener(new java.awt.event.WindowAdapter() {
@Override
public void windowClosed(java.awt.event.WindowEvent e) {
try { tempFile.delete(); } catch(Exception ignore) {}
requestFocusInActivePanel();
}
});
editor.setVisible(true);
});
} catch (IOException e) {
JOptionPane.showMessageDialog(this, "Could not create temp file: " + e.getMessage(), "Error", JOptionPane.ERROR_MESSAGE);
}
return;
}
File file = item.getFile();
FilePanelTab activeTab = activePanel != null ? activePanel.getCurrentTab() : null;
boolean fileInsideOpenedArchive = activeTab != null && activeTab.isDirectoryInsideOpenedArchive(file.getParentFile());
boolean forceInternalEditor = fileInsideOpenedArchive && isLikelyTextFile(file);
if (forceInternalEditor) {
FileEditor editor = new FileEditor(this, file, config, false);
// Archive will be synced automatically when user leaves it, not immediately after edit
editor.addWindowListener(new java.awt.event.WindowAdapter() {
@Override
public void windowClosed(java.awt.event.WindowEvent e) {
requestFocusInActivePanel();
}
});
editor.setVisible(true);
return;
}
// If an external editor is configured, try launching it with the file path
String ext = config.getExternalEditorPath();
if (ext != null && !ext.trim().isEmpty()) {
try {
java.util.List<String> cmd = new java.util.ArrayList<>();
cmd.add(ext);
cmd.add(file.getAbsolutePath());
new ProcessBuilder(cmd).start();
return;
} catch (Exception ex) {
// Fall back to internal editor if external fails
JOptionPane.showMessageDialog(this,
"Could not start external editor: " + ex.getMessage() + "\nUsing internal editor.",
"Error", JOptionPane.ERROR_MESSAGE);
}
}
// Removed previous 10 MB limit: allow opening large files in the editor. The editor may still choose hex/paged mode for large binaries.
FileEditor editor = new FileEditor(this, file, config, false);
editor.addWindowListener(new java.awt.event.WindowAdapter() {
@Override
public void windowClosed(java.awt.event.WindowEvent e) {
requestFocusInActivePanel();
}
});
editor.setVisible(true);
}
private boolean isLikelyTextFile(File file) {
if (file == null || !file.isFile()) {
return false;
}
int probeSize = 4096;
byte[] probe;
try {
long size = Files.size(file.toPath());
int toRead = (int) Math.min(probeSize, size);
probe = new byte[toRead];
try (java.io.InputStream in = Files.newInputStream(file.toPath())) {
int read = in.read(probe);
if (read < 0) {
return true;
}
if (read < toRead) {
byte[] trimmed = new byte[read];
System.arraycopy(probe, 0, trimmed, 0, read);
probe = trimmed;
}
}
} catch (Exception e) {
return false;
}
if (probe.length == 0) {
return true;
}
int nonPrintable = 0;
for (byte b : probe) {
int value = b & 0xFF;
if (value == 0) {
return false;
}
boolean printable = value == 9 || value == 10 || value == 13 || (value >= 32 && value <= 126);
if (!printable) {
nonPrintable++;
}
}
return nonPrintable <= Math.max(1, probe.length / 6);
}
private void syncArchiveAfterInternalEdit(File editedFile) {
try {
if (activePanel == null) {
return;
}
FilePanelTab activeTab = activePanel.getCurrentTab();
if (activeTab == null) {
return;
}
File parentDir = editedFile != null ? editedFile.getParentFile() : null;
if (parentDir == null || !activeTab.isDirectoryInsideOpenedArchive(parentDir)) {
return;
}
if (!activeTab.canSyncOpenedArchive()) {
throw new IOException("Updating this archive format is not supported");
}
activeTab.syncOpenedArchiveChanges(new FileOperations.ProgressCallback() {
@Override
public void onProgress(long current, long total, String fileName) {}
@Override
public void onFileProgress(long current, long total) {}
@Override
public boolean isCancelled() {
return false;
}
@Override
public FileOperations.OverwriteResponse confirmOverwrite(File source, File target) {
return FileOperations.OverwriteResponse.YES;
}
@Override
public FileOperations.ErrorResponse onError(File file, Exception e) {
return FileOperations.ErrorResponse.ABORT;
}
@Override
public FileOperations.SymlinkResponse confirmSymlink(File file) {
return FileOperations.SymlinkResponse.FOLLOW;
}
@Override
public String requestPassword(String archiveName) {
return null;
}
});
activePanel.refresh(false);
} catch (Exception ex) {
throw new RuntimeException("File saved, but archive sync failed: " + ex.getMessage(), ex);
}
}
/**
* Compare files from both panels
*/
private void compareFiles() {
if (leftPanel == null || rightPanel == null) return;
List<FileItem> leftSelection = leftPanel.getSelectedItems();
List<FileItem> rightSelection = rightPanel.getSelectedItems();
// Total Commander-like behavior: compare focused file from each panel.
// If exactly one item is selected in a panel, use that one as an override.
File leftFile = getCompareCandidate(leftPanel, leftSelection);
File rightFile = getCompareCandidate(rightPanel, rightSelection);
if (leftFile == null || rightFile == null) {
JOptionPane.showMessageDialog(this, "Please select a file in both panels to compare.", "Compare Files", JOptionPane.WARNING_MESSAGE);
return;
}
if (leftFile.isDirectory() || rightFile.isDirectory()) {
JOptionPane.showMessageDialog(this, "Comparison of directories is not supported.", "Compare Files", JOptionPane.WARNING_MESSAGE);
return;
}
FileComparisonDialog dlg = new FileComparisonDialog(this, leftFile, rightFile, config);
dlg.setVisible(true);
}
private File getCompareCandidate(FilePanel panel, List<FileItem> selected) {
if (panel == null) return null;
if (selected != null && selected.size() == 1) {
FileItem item = selected.getFirst();
if (item != null && !"..".equals(item.getName())) {
return item.getFile();
}
}
FileItem focused = panel.getFocusedItem();
if (focused != null && !"..".equals(focused.getName())) {
return focused.getFile();
}
return null;
}
/**
* Refresh both panels while preserving selection and active panel focus.
*/
private void refreshPanels() {
if (leftPanel != null && leftPanel.getCurrentDirectory() != null) {
leftPanel.refresh(activePanel == leftPanel);
}
if (rightPanel != null && rightPanel.getCurrentDirectory() != null) {
rightPanel.refresh(activePanel == rightPanel);
}
}
private File getPreferredDirectoryFromOppositePanel(File selectedDrive, FilePanel oppositePanel) {
if (selectedDrive == null || oppositePanel == null) return selectedDrive;
File oppositeDirectory = oppositePanel.getCurrentDirectory();
if (oppositeDirectory == null || !oppositeDirectory.exists() || !oppositeDirectory.isDirectory()) {
return selectedDrive;
}
return isWithinSelectedDrive(oppositeDirectory, selectedDrive) ? oppositeDirectory : selectedDrive;
}
private boolean isWithinSelectedDrive(File directory, File selectedDrive) {
try {
java.nio.file.Path dirPath = directory.toPath().toAbsolutePath().normalize();
java.nio.file.Path selectedPath = selectedDrive.toPath().toAbsolutePath().normalize();
if (dirPath.startsWith(selectedPath)) {
return true;
}
java.nio.file.Path dirRoot = dirPath.getRoot();
java.nio.file.Path selectedRoot = selectedPath.getRoot();
return dirRoot != null
&& selectedRoot != null
&& dirRoot.equals(selectedRoot)
&& selectedPath.equals(selectedRoot);
} catch (Exception ex) {
return false;
}
}
/**
* Set the view mode for the active panel
*/
private void setActiveViewMode(ViewMode mode) {
if (activePanel != null) {
activePanel.setViewMode(mode);
}
}
/**
* Show history of command line
*/
private void showCommandLineHistory() {
if (commandLine != null && commandLine.getItemCount() > 0) {
commandLine.requestFocusInWindow();
int count = commandLine.getItemCount();
commandHistoryIndex = (commandHistoryIndex + 1) % count;
String nextCommand = commandLine.getItemAt(commandHistoryIndex);
commandLine.getEditor().setItem(nextCommand);
Component editorComp = commandLine.getEditor().getEditorComponent();
if (editorComp instanceof JTextField tf) {
tf.setCaretPosition(tf.getText().length());
}
}
}
/**
* Add a new tab to the active panel
*/
private void addNewTabToActivePanel() {
if (activePanel != null) {
File currentDir = activePanel.getCurrentDirectory();
String path = currentDir != null ? currentDir.getAbsolutePath() : System.getProperty("user.home");
activePanel.addNewTab(path);
}
}
/**
* Close the current tab in the active panel
*/
private void closeCurrentTabInActivePanel() {
if (activePanel != null) {
activePanel.closeCurrentTab();
}
}
/**
* Open terminal in the current directory
*/
private void openTerminal() {
openTerminal(null);
}
/**
* Open terminal in the current directory, optionally executing a command (like a shell)
*/
/**
* Open terminal in the current directory, optionally executing a command (like a shell)
*/
private void openTerminal(String shellCommand) {
File currentDir = activePanel.getCurrentDirectory();
if (currentDir == null) {
currentDir = new File(System.getProperty("user.home"));
}
try {
if (MainApp.CURRENT_OS == MainApp.OS.WINDOWS) {
// Windows - open cmd.exe or the specific shell
if (shellCommand != null) {
new ProcessBuilder("cmd.exe", "/c", "start", shellCommand).directory(currentDir).start();
} else {
new ProcessBuilder("cmd.exe", "/c", "start", "cmd.exe").directory(currentDir).start();
}
} else if (MainApp.CURRENT_OS == MainApp.OS.MACOS) {
// macOS - open Terminal.app
new ProcessBuilder("open", "-a", "Terminal", currentDir.getAbsolutePath()).directory(currentDir).start();
} else {
// Linux - try common terminal emulators
List<String> terminals = new ArrayList<>();
// 1. Try $TERMINAL environment variable
String envTerminal = System.getenv("TERMINAL");
if (envTerminal != null && !envTerminal.isEmpty()) {
terminals.add(envTerminal);
}
// 2. Try xdg-terminal-exec (modern standard)
terminals.add("xdg-terminal-exec");
// 3. Common terminal emulators
terminals.addAll(Arrays.asList(
"x-terminal-emulator",
"gnome-terminal",
"konsole",
"xfce4-terminal",
"alacritty",
"kitty",
"foot",
"wezterm",
"mate-terminal",
"terminator",
"tilix",
"qterminal",
"urxvt",
"st",
"xterm"
));
boolean successfullyStarted = false;
for (String terminal : terminals) {
try {
List<String> args = new ArrayList<>();
args.add(terminal);
if (terminal.equals("gnome-terminal") || terminal.equals("xfce4-terminal") ||
terminal.equals("mate-terminal") || terminal.equals("terminator") ||
terminal.equals("tilix")) {
args.add("--working-directory=" + currentDir.getAbsolutePath());
if (shellCommand != null) {
args.add("--");
args.add(shellCommand);
args.add("-i");
}
} else if (terminal.equals("alacritty") || terminal.equals("foot")) {
args.add("--working-directory");
args.add(currentDir.getAbsolutePath());
if (shellCommand != null) {
args.add("-e");
args.add(shellCommand);
args.add("-i");
}
} else if (terminal.equals("kitty") || terminal.equals("wezterm")) {
// Kitty/Wezterm use 'start' or similar, but often just taking args
if (terminal.equals("kitty")) {
args.add("--directory");
args.add(currentDir.getAbsolutePath());
}
if (shellCommand != null) {
args.add(shellCommand);
args.add("-i");
}
} else if (terminal.equals("konsole") || terminal.equals("qterminal")) {
args.add("--workdir");
args.add(currentDir.getAbsolutePath());
if (shellCommand != null) {
args.add("-e");
args.add(shellCommand);
args.add("-i");
}
} else {
if (shellCommand != null) {
args.add("-e");
args.add(shellCommand);
args.add("-i");
}
}
new ProcessBuilder(args).directory(currentDir).start();
successfullyStarted = true;
break;
} catch (IOException e) {
// try next
}
}
if (!successfullyStarted) {
throw new Exception("Could not start any terminal emulator.");
}
}
} catch (Exception e) {
JOptionPane.showMessageDialog(this,
"Error opening terminal: " + e.getMessage(),
"Error",
JOptionPane.ERROR_MESSAGE);
}
}
private void executeCommand(String command) {
if (command == null || command.trim().isEmpty()) {
activePanel.getFileTable().requestFocusInWindow();
return;
}
String trimmed = command.trim();
// Add to history
addCommandToHistory(trimmed);
// Handle internal commands like 'cd'
if (trimmed.toLowerCase().startsWith("cd ") || trimmed.toLowerCase().equals("cd..") || trimmed.equals("..")) {
String targetPath = null;
if (trimmed.equals("..") || trimmed.toLowerCase().equals("cd..")) {
targetPath = "..";
} else {
targetPath = trimmed.substring(3).trim();
if (targetPath.startsWith("\"") && targetPath.endsWith("\"")) {
targetPath = targetPath.substring(1, targetPath.length() - 1);
}
}
if (activePanel != null) {
if ("..".equals(targetPath)) {
activePanel.navigateUp();
} else {
File targetDir = new File(activePanel.getCurrentDirectory(), targetPath);
if (targetDir.exists() && targetDir.isDirectory()) {
activePanel.loadDirectory(targetDir);
} else {
// try absolute path
targetDir = new File(targetPath);
if (targetDir.exists() && targetDir.isDirectory()) {
activePanel.loadDirectory(targetDir);
}
}
}
}
} else if (trimmed.equalsIgnoreCase("bash") || trimmed.equalsIgnoreCase("sh")) {
// Special handling for bash/sh to open a terminal with that shell
openTerminal(trimmed.toLowerCase());
} else {
// On Linux, run command line commands in an external terminal.
// This keeps command output visible and uses the active panel directory.
if (MainApp.CURRENT_OS == MainApp.OS.LINUX) {
openTerminalWithCommand(trimmed);
} else {
// Execute natively for other commands
executeNative(trimmed, null);
}
}
// Final prompt update after command execution (path might have changed)
updateCommandLinePrompt();
// Clear after execution and return focus
Component editorComp = commandLine.getEditor().getEditorComponent();
if (editorComp instanceof JTextField field) {
field.setText("");
} else {
commandLine.setSelectedItem("");
}
activePanel.getFileTable().requestFocusInWindow();
}
private void executeNative(String command, String workingDir) {
if (command == null || command.trim().isEmpty()) return;
File currentDir;
if (workingDir != null && !workingDir.trim().isEmpty()) {
currentDir = new File(workingDir);
} else {
currentDir = activePanel.getCurrentDirectory();
}
if (currentDir == null || !currentDir.exists()) {
currentDir = new File(System.getProperty("user.home"));
}
try {
String trimmedCmd = command.trim();
// Special case for Windows terminal commands - launch in a new window
if (MainApp.CURRENT_OS == MainApp.OS.WINDOWS) {
String cmdLower = trimmedCmd.toLowerCase();
if (cmdLower.equals("cmd") || cmdLower.equals("powershell") ||
cmdLower.equals("pwsh") || cmdLower.equals("wt")) {
new ProcessBuilder("cmd", "/c", "start", cmdLower).directory(currentDir).start();
return;
}
}
// Check if it's a file path that exists (and not a complex command)
if (!command.contains(" ") || (command.startsWith("\"") && command.endsWith("\"") && !command.substring(1, command.length()-1).contains("\""))) {
String path = command.startsWith("\"") ? command.substring(1, command.length()-1) : command;
File file = new File(path);
if (file.exists()) {
// Start executable files directly, otherwise use Desktop.open
if (file.canExecute() && !file.isDirectory()) {
new ProcessBuilder(file.getAbsolutePath()).directory(currentDir).start();
return;
}
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.OPEN)) {
Desktop.getDesktop().open(file);
return;
}
}
}
// Otherwise try running as a native process
List<String> cmdList = parseCommand(command);
// Check if command contains shell metacharacters
boolean hasShellMeta = command.matches(".*[|&<>$!;*?].*");
try {
if (hasShellMeta && (MainApp.CURRENT_OS == MainApp.OS.LINUX || MainApp.CURRENT_OS == MainApp.OS.MACOS)) {
new ProcessBuilder("sh", "-c", command).directory(currentDir).start();
} else {
new ProcessBuilder(cmdList).directory(currentDir).start();
}
} catch (IOException ex) {
// Fallback for different OS: try via shell
if (MainApp.CURRENT_OS == MainApp.OS.LINUX || MainApp.CURRENT_OS == MainApp.OS.MACOS) {
new ProcessBuilder("sh", "-c", command).directory(currentDir).start();
} else if (MainApp.CURRENT_OS == MainApp.OS.WINDOWS) {
new ProcessBuilder("cmd", "/c", command).directory(currentDir).start();
} else {
throw ex;
}
}
} catch (Exception e) {
JOptionPane.showMessageDialog(this,
"Error executing native command: " + e.getMessage(),
"Error",
JOptionPane.ERROR_MESSAGE);
}
}
private void openTerminalWithCommand(String command) {
if (command == null || command.trim().isEmpty()) return;
File currentDir = activePanel.getCurrentDirectory();
if (currentDir == null || !currentDir.exists()) {
currentDir = new File(System.getProperty("user.home"));
}
// Keep terminal open after running command so user can inspect output.
String shellScript = command + "; exec sh -i";
try {
List<String> terminals = new ArrayList<>();
String envTerminal = System.getenv("TERMINAL");
if (envTerminal != null && !envTerminal.isEmpty()) {
terminals.add(envTerminal);
}
terminals.add("xdg-terminal-exec");
terminals.addAll(Arrays.asList(
"x-terminal-emulator",
"gnome-terminal",
"konsole",
"xfce4-terminal",
"alacritty",
"kitty",
"foot",
"wezterm",
"mate-terminal",
"terminator",
"tilix",
"qterminal",
"urxvt",
"st",
"xterm"
));
boolean started = false;
for (String terminal : terminals) {
try {
List<String> args = new ArrayList<>();
args.add(terminal);
if (terminal.equals("gnome-terminal") || terminal.equals("xfce4-terminal") ||
terminal.equals("mate-terminal") || terminal.equals("terminator") ||
terminal.equals("tilix")) {
args.add("--working-directory=" + currentDir.getAbsolutePath());
args.add("--");
args.add("sh");
args.add("-c");
args.add(shellScript);
} else if (terminal.equals("alacritty") || terminal.equals("foot")) {
args.add("--working-directory");
args.add(currentDir.getAbsolutePath());
args.add("-e");
args.add("sh");
args.add("-c");
args.add(shellScript);
} else if (terminal.equals("kitty")) {
args.add("--directory");
args.add(currentDir.getAbsolutePath());
args.add("sh");
args.add("-c");
args.add(shellScript);
} else if (terminal.equals("konsole") || terminal.equals("qterminal")) {
args.add("--workdir");
args.add(currentDir.getAbsolutePath());
args.add("-e");
args.add("sh");
args.add("-c");
args.add(shellScript);
} else if (terminal.equals("wezterm")) {
args.add("start");
args.add("--cwd");
args.add(currentDir.getAbsolutePath());
args.add("sh");
args.add("-c");
args.add(shellScript);
} else if (terminal.equals("xdg-terminal-exec")) {
args.add("sh");
args.add("-c");
args.add(shellScript);
} else {
args.add("-e");
args.add("sh");
args.add("-c");
args.add(shellScript);
}
new ProcessBuilder(args).directory(currentDir).start();
started = true;
break;
} catch (IOException ignore) {
// Try next terminal command.
}
}
if (!started) {
throw new Exception("Could not start any terminal emulator.");
}
} catch (Exception e) {
JOptionPane.showMessageDialog(this,
"Error opening terminal for command execution: " + e.getMessage(),
"Error",
JOptionPane.ERROR_MESSAGE);
}
}
private List<String> parseCommand(String command) {
List<String> list = new ArrayList<>();
StringBuilder sb = new StringBuilder();
boolean inQuotes = false;
for (int i = 0; i < command.length(); i++) {
char c = command.charAt(i);
if (c == '\"') {
inQuotes = !inQuotes;
} else if (c == ' ' && !inQuotes) {
if (sb.length() > 0) {
list.add(sb.toString());
sb.setLength(0);
}
} else {
sb.append(c);
}
}
if (sb.length() > 0) {
list.add(sb.toString());
}
return list;
}
private void addCommandToHistory(String command) {
if (command == null || command.isEmpty()) return;
// Remove if already exists to move it to the top
for (int i = 0; i < commandLine.getItemCount(); i++) {
if (command.equals(commandLine.getItemAt(i))) {
commandLine.removeItemAt(i);
break;
}
}
commandLine.insertItemAt(command, 0);
commandHistoryIndex = -1;
// We don't necessarily want to select it here as it might interfere with the editor state
}
/**
* Show About dialog
*/
private void showAboutDialog() {
JOptionPane.showMessageDialog(this,
"""
KF Manager 1.0
Two-panel file manager
Developed by Kamma.cz
Keyboard shortcuts:
F5 - Copy
Alt+F5 - Zip
Alt+F9 - Unzip
F6 - Move
F7 - New directory
F8 - Delete
F9 - Open terminal
Shift+F6 - Rename
TAB - Switch panel
Ctrl+F - Search
Alt+O - Settings
Enter - Open directory
Backspace - Parent directory""",
"About",
JOptionPane.INFORMATION_MESSAGE);
requestFocusInActivePanel();
}
/**
* Execute file operation with error handling
*/
private void performFileOperation(FileOperation operation, String successMessage, boolean showBytes, FilePanel... panelsToRefresh) {
performFileOperation(operation, successMessage, showBytes, true, null, panelsToRefresh);
}
private void performFileOperation(FileOperation operation, String successMessage, boolean showBytes, boolean modal, FilePanel... panelsToRefresh) {
performFileOperation(operation, successMessage, showBytes, modal, null, panelsToRefresh);
}
/**
* Execute file operation with error handling and a task to run after completion and refresh.
*/
private void performFileOperation(FileOperation operation, String successMessage, boolean showBytes, Runnable postTask, FilePanel... panelsToRefresh) {
performFileOperation(operation, successMessage, showBytes, true, postTask, panelsToRefresh);
}
/**
* Execute file operation with error handling and a task to run after completion and refresh.
*/
private void performFileOperation(FileOperation operation, String successMessage, boolean showBytes, boolean modal, Runnable postTask, FilePanel... panelsToRefresh) {
ProgressDialog progressDialog = new ProgressDialog(this, "File Operation", modal);
FileOperations.ProgressCallback callback = new FileOperations.ProgressCallback() {
@Override
public void onProgress(long current, long total, String currentFile) {
progressDialog.updateProgress(current, total, currentFile);
}
@Override
public void onFileProgress(long current, long total) {
progressDialog.updateFileProgress(current, total);
}
@Override
public boolean isCancelled() {
return progressDialog.isCancelled();
}
@Override
public FileOperations.OverwriteResponse confirmOverwrite(File source, File destination) {
final FileOperations.OverwriteResponse[] result = new FileOperations.OverwriteResponse[1];
try {
SwingUtilities.invokeAndWait(() -> {
SimpleDateFormat sdf = new SimpleDateFormat("dd.MM.yyyy HH:mm:ss");
String message = (
"""
File already exists: %s
Source file:
Size: %s
Modified: %s
Existing file:
Size: %s
Modified: %s
Overwrite?""").formatted(
destination.getName(),
FileItem.formatSize(source.length()),
sdf.format(new Date(source.lastModified())),
FileItem.formatSize(destination.length()),
sdf.format(new Date(destination.lastModified()))
);
Object[] options = {"Yes", "Yes to All", "No", "No to All", "Cancel"};
int n = JOptionPane.showOptionDialog(progressDialog,
message,
"Overwrite Confirmation",
JOptionPane.DEFAULT_OPTION,
JOptionPane.QUESTION_MESSAGE,
null,
options,
options[0]);
switch (n) {
case 0: result[0] = FileOperations.OverwriteResponse.YES; break;
case 1: result[0] = FileOperations.OverwriteResponse.YES_TO_ALL; break;
case 2: result[0] = FileOperations.OverwriteResponse.NO; break;
case 3: result[0] = FileOperations.OverwriteResponse.NO_TO_ALL; break;
default:
result[0] = FileOperations.OverwriteResponse.CANCEL;
progressDialog.cancel();
break;
}
});
} catch (Exception e) {
result[0] = FileOperations.OverwriteResponse.CANCEL;
}
return result[0];
}
@Override
public String requestPassword(String archiveName) {
final String[] result = new String[1];
try {
SwingUtilities.invokeAndWait(() -> {
JPasswordField pf = new JPasswordField() {
@Override
public void addNotify() {
super.addNotify();
requestFocusInWindow();
}
};
Object[] message = {
"Enter password for " + archiveName,
pf
};
JOptionPane pane = new JOptionPane(message, JOptionPane.PLAIN_MESSAGE, JOptionPane.OK_CANCEL_OPTION);
JDialog dialog = pane.createDialog(progressDialog, "Password Required");
dialog.addWindowFocusListener(new WindowAdapter() {
@Override
public void windowGainedFocus(WindowEvent e) {
pf.requestFocusInWindow();
}
});
dialog.setVisible(true);
Object selectedValue = pane.getValue();
if (selectedValue != null && (Integer) selectedValue == JOptionPane.OK_OPTION) {
result[0] = new String(pf.getPassword());
} else {
result[0] = null;
}
});
} catch (Exception e) {
result[0] = null;
}
return result[0];
}
@Override
public FileOperations.SymlinkResponse confirmSymlink(File symlink) {
final FileOperations.SymlinkResponse[] result = new FileOperations.SymlinkResponse[1];
try {
SwingUtilities.invokeAndWait(() -> {
String message = (
"""
Symbolic link encountered: %s
What do you want to do?""").formatted(
symlink.getAbsolutePath()
);
Object[] options = {"Follow", "Follow All", "Ignore", "Ignore All", "Cancel"};
int n = JOptionPane.showOptionDialog(progressDialog,
message,
"Symlink Encountered",
JOptionPane.DEFAULT_OPTION,
JOptionPane.QUESTION_MESSAGE,
null,
options,
options[0]);
switch (n) {
case 0: result[0] = FileOperations.SymlinkResponse.FOLLOW; break;
case 1: result[0] = FileOperations.SymlinkResponse.FOLLOW_ALL; break;
case 2: result[0] = FileOperations.SymlinkResponse.IGNORE; break;
case 3: result[0] = FileOperations.SymlinkResponse.IGNORE_ALL; break;
default:
result[0] = FileOperations.SymlinkResponse.CANCEL;
progressDialog.cancel();
break;
}
});
} catch (Exception e) {
result[0] = FileOperations.SymlinkResponse.CANCEL;
}
return result[0];
}
@Override
public FileOperations.ErrorResponse onError(File file, Exception e) {
final FileOperations.ErrorResponse[] result = new FileOperations.ErrorResponse[1];
try {
SwingUtilities.invokeAndWait(() -> {
Object[] options = {"Skip", "Skip All", "Retry", "Abort"};
int n = JOptionPane.showOptionDialog(progressDialog,
"Error operating on file: " + file.getName() + "\n" + e.getMessage(),
"Error",
JOptionPane.DEFAULT_OPTION,
JOptionPane.ERROR_MESSAGE,
null,
options,
options[0]);
switch (n) {
case 0: result[0] = FileOperations.ErrorResponse.SKIP; break;
case 1: result[0] = FileOperations.ErrorResponse.SKIP_ALL; break;
case 2: result[0] = FileOperations.ErrorResponse.RETRY; break;
default:
result[0] = FileOperations.ErrorResponse.ABORT;
progressDialog.cancel();
break;
}
});
} catch (Exception ex) {
result[0] = FileOperations.ErrorResponse.ABORT;
}
return result[0];
}
};
// Run operation in a background thread
new Thread(() -> {
try {
operation.execute(callback);
SwingUtilities.invokeLater(() -> {
progressDialog.dispose();
// Force the window to front and request focus after modal dialog
MainWindow.this.toFront();
for (FilePanel panel : panelsToRefresh) {
panel.refresh(false);
}
if (postTask != null) {
SwingUtilities.invokeLater(postTask);
}
requestFocusInActivePanel();
if (callback.isCancelled()) {
JOptionPane.showMessageDialog(MainWindow.this, "Operation was cancelled by user.");
if (activePanel != null && activePanel.getFileTable() != null) {
activePanel.getFileTable().requestFocusInWindow();
}
}
});
} catch (Exception e) {
SwingUtilities.invokeLater(() -> {
progressDialog.dispose();
JOptionPane.showMessageDialog(MainWindow.this,
"Error: " + e.getMessage(),
"Error",
JOptionPane.ERROR_MESSAGE);
if (activePanel != null && activePanel.getFileTable() != null) {
activePanel.getFileTable().requestFocusInWindow();
}
});
}
}).start();
progressDialog.setVisible(true);
}
/**
* Show drive selector for left panel
*/
private void selectDriveForLeftPanel() {
// Open the drive dropdown in the left panel
if (leftPanel != null) leftPanel.showDrivePopup();
}
/**
* Show drive selector for right panel
*/
private void selectDriveForRightPanel() {
// Open the drive dropdown in the right panel
if (rightPanel != null) rightPanel.showDrivePopup();
}
/**
* Update divider tooltip with percentage sizes
*/
private void updateDividerTooltip(JSplitPane splitPane) {
if (splitPane == null) return;
// Get divider location in pixels
int location = splitPane.getDividerLocation();
int totalSize = splitPane.getOrientation() == JSplitPane.HORIZONTAL_SPLIT ?
splitPane.getWidth() : splitPane.getHeight();
int dividerSize = splitPane.getDividerSize();
int max = totalSize - dividerSize;
if (max > 0) {
double proportionalPosition = (double) location / max;
// Calculate percentages
int leftPercent = (int) Math.round(proportionalPosition * 100);
int rightPercent = 100 - leftPercent;
String firstLabel = splitPane.getOrientation() == JSplitPane.HORIZONTAL_SPLIT ? "Left" : "Top";
String secondLabel = splitPane.getOrientation() == JSplitPane.HORIZONTAL_SPLIT ? "Right" : "Bottom";
String tooltip = String.format("%s: %d%% | %s: %d%%", firstLabel, leftPercent, secondLabel, rightPercent);
splitPane.setToolTipText(tooltip);
// Set tooltip also on the divider component itself
Component left = splitPane.getLeftComponent();
Component right = splitPane.getRightComponent();
for (Component c : splitPane.getComponents()) {
if (c != left && c != right) {
if (c instanceof JComponent jc) {
jc.setToolTipText(tooltip);
ToolTipManager.sharedInstance().registerComponent(jc);
}
// Add mouse listener to the divider if not already present
boolean hasListener = false;
for (MouseMotionListener ml : c.getMouseMotionListeners()) {
if (ml instanceof DividerMouseHandler) {
hasListener = true;
((DividerMouseHandler)ml).updateTooltip(tooltip);
break;
}
}
if (!hasListener) {
DividerMouseHandler handler = new DividerMouseHandler(splitPane, tooltip);
c.addMouseListener(handler);
c.addMouseMotionListener(handler);
}
}
}
}
}
private class DividerMouseHandler extends MouseAdapter {
private final JSplitPane splitPane;
private String currentTooltip;
private JWindow tooltipWindow;
private JLabel tipLabel;
public DividerMouseHandler(JSplitPane splitPane, String tooltip) {
this.splitPane = splitPane;
this.currentTooltip = tooltip;
tooltipWindow = new JWindow();
tipLabel = new JLabel();
tipLabel.setFont(new Font("SansSerif", Font.BOLD, 14));
tipLabel.setBorder(BorderFactory.createEmptyBorder(5, 12, 5, 12));
tipLabel.setBackground(new Color(40, 40, 40)); // Very dark gray
tipLabel.setForeground(Color.WHITE);
tipLabel.setOpaque(true);
tooltipWindow.add(tipLabel);
tooltipWindow.pack();
tooltipWindow.setAlwaysOnTop(true);
}
public void updateTooltip(String tooltip) {
this.currentTooltip = tooltip;
if (tipLabel != null) {
tipLabel.setText(tooltip);
tooltipWindow.pack();
}
}
@Override
public void mouseReleased(MouseEvent e) {
tooltipWindow.setVisible(false);
persistDividerPosition();
}
@Override
public void mouseDragged(MouseEvent e) {
if (!tooltipWindow.isVisible()) {
showWindow(e);
}
updateLocation(e);
updateDividerTooltip(splitPane);
}
private void showWindow(MouseEvent e) {
tipLabel.setText(currentTooltip);
tooltipWindow.pack();
updateLocation(e);
tooltipWindow.setVisible(true);
}
private void updateLocation(MouseEvent e) {
Point p = e.getLocationOnScreen();
tooltipWindow.setLocation(p.x + 15, p.y + 15);
}
}
private void persistDividerPosition() {
if (mainPanel == null || config == null) return;
int location = mainPanel.getDividerLocation();
int extent = getMainPanelExtent();
if (extent <= 0) return;
double proportionalPosition = (double) location / extent;
proportionalPosition = Math.max(0.0, Math.min(1.0, proportionalPosition));
config.setDividerPosition(proportionalPosition);
mainPanel.setResizeWeight(proportionalPosition);
}
private void applySavedDividerLocation() {
if (mainPanel == null || config == null) return;
double ratio = getConfiguredDividerRatio();
applyingSavedDividerLocation = true;
try {
mainPanel.setResizeWeight(ratio);
mainPanel.setDividerLocation(ratio);
updateDividerTooltip(mainPanel);
} finally {
applyingSavedDividerLocation = false;
}
}
private double getConfiguredDividerRatio() {
if (config == null) return 0.5;
double savedPosition = config.getDividerPosition();
if (savedPosition >= 0.0 && savedPosition <= 1.0) return savedPosition;
return 0.5;
}
private int getMainPanelExtent() {
if (mainPanel == null) return -1;
int totalSize = mainPanel.getOrientation() == JSplitPane.HORIZONTAL_SPLIT ? mainPanel.getWidth() : mainPanel.getHeight();
return totalSize - mainPanel.getDividerSize();
}
/**
* Save configuration and exit application
*/
private void saveConfigAndExit() {
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);
// Save active panel
if (activePanel == leftPanel) {
config.setActivePanel("left");
} else if (activePanel == rightPanel) {
config.setActivePanel("right");
}
// Save current panel paths (for backward compatibility)
if (leftPanel.getCurrentDirectory() != null) {
config.setLeftPanelPath(leftPanel.getCurrentDirectory().getAbsolutePath());
}
if (rightPanel.getCurrentDirectory() != null) {
config.setRightPanelPath(rightPanel.getCurrentDirectory().getAbsolutePath());
}
// Save open tabs + view modes and selected index for both panels
try {
java.util.List<String> leftPaths = leftPanel.getTabPaths();
java.util.List<String> leftModes = leftPanel.getTabViewModes();
java.util.List<String> leftFocused = leftPanel.getTabFocusedItems();
int leftSelected = leftPanel.getSelectedTabIndex();
config.saveLeftPanelTabs(leftPaths, leftModes, leftFocused, leftSelected);
} catch (Exception ex) {
// ignore
}
try {
java.util.List<String> rightPaths = rightPanel.getTabPaths();
java.util.List<String> rightModes = rightPanel.getTabViewModes();
java.util.List<String> rightFocused = rightPanel.getTabFocusedItems();
int rightSelected = rightPanel.getSelectedTabIndex();
config.saveRightPanelTabs(rightPaths, rightModes, rightFocused, rightSelected);
} catch (Exception ex) {
// ignore
}
// Save command history
java.util.List<String> cmdHistory = new java.util.ArrayList<>();
for (int i = 0; i < commandLine.getItemCount(); i++) {
cmdHistory.add(commandLine.getItemAt(i));
}
config.saveCommandLineHistory(cmdHistory);
// Save configuration to file
config.saveConfig();
// Exit application
System.exit(0);
}
@FunctionalInterface
private interface FileOperation {
void execute(FileOperations.ProgressCallback callback) throws Exception;
}
private void addOperationToQueue(String title, String description, FileOperation operation, FilePanel... panelsToRefresh) {
addOperationToQueue(title, description, operation, null, panelsToRefresh);
}
private void addOperationToQueue(String title, String description, FileOperation operation, Runnable postTask, FilePanel... panelsToRefresh) {
FileOperationQueue.QueuedTask task = new FileOperationQueue.QueuedTask(title, description, (callback) -> {
operation.execute(callback);
SwingUtilities.invokeLater(() -> {
for (FilePanel panel : panelsToRefresh) {
if (panel.getCurrentDirectory() != null) {
panel.loadDirectory(panel.getCurrentDirectory(), false, false);
}
}
if (postTask != null) {
postTask.run();
}
});
});
FileOperationQueue.getInstance().addTask(task);
OperationQueueDialog.showQueue(this);
}
private void requestFocusInActivePanel() {
if (activePanel != null && activePanel.getFileTable() != null) {
activePanel.getFileTable().requestFocusInWindow();
}
}
private void syncTargetArchiveIfNeeded(FilePanel targetPanel, File targetDir, FileOperations.ProgressCallback callback) throws IOException {
if (targetPanel == null || targetDir == null) return;
FilePanelTab targetTab = targetPanel.getCurrentTab();
if (targetTab == null) return;
if (!targetTab.isDirectoryInsideOpenedArchive(targetDir)) return;
if (!targetTab.canSyncOpenedArchive()) {
throw new IOException("Updating this archive format is not supported");
}
targetTab.syncOpenedArchiveChanges(callback);
}
private void updateAutoRefreshTimer() {
if (autoRefreshTimer != null) {
autoRefreshTimer.stop();
}
int interval = config.getAutoRefreshInterval();
autoRefreshTimer = new Timer(interval, e -> {
if (leftPanel != null) {
FilePanelTab tab = leftPanel.getCurrentTab();
if (tab != null && !tab.isFtpTab() && leftPanel.getCurrentDirectory() != null) {
leftPanel.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<FtpProfile> 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);
}
}