From 2a516ab48c8961826ab0c8a8f39f93d858bc78ad Mon Sep 17 00:00:00 2001 From: Radek Davidek Date: Mon, 19 Jan 2026 14:09:32 +0100 Subject: [PATCH] UI fixes, added search support for archives --- .../cz/kamma/kfmanager/model/FileItem.java | 30 +-- .../kfmanager/service/FileOperations.java | 175 ++++++++++++++++-- .../cz/kamma/kfmanager/ui/MainWindow.java | 80 +++++++- .../cz/kamma/kfmanager/ui/SearchDialog.java | 38 +++- 4 files changed, 285 insertions(+), 38 deletions(-) diff --git a/src/main/java/cz/kamma/kfmanager/model/FileItem.java b/src/main/java/cz/kamma/kfmanager/model/FileItem.java index 8bab761..a67376a 100644 --- a/src/main/java/cz/kamma/kfmanager/model/FileItem.java +++ b/src/main/java/cz/kamma/kfmanager/model/FileItem.java @@ -18,57 +18,63 @@ public class FileItem { private final boolean isDirectory; private final Icon icon; private boolean marked; - + private String displayPath; + public FileItem(File file) { + this(file, null); + } + + public FileItem(File file, String displayPath) { this.file = file; this.name = file.getName(); this.size = file.length(); this.modified = new Date(file.lastModified()); this.isDirectory = file.isDirectory(); this.marked = false; - + this.displayPath = displayPath; + // Load icon from system this.icon = FileSystemView.getFileSystemView().getSystemIcon(file); } - + public File getFile() { return file; } - + public String getName() { return name; } - + public String getFormattedSize() { if (isDirectory) { return ""; } return formatSize(size); } - + public long getSize() { return size; } - + public String getFormattedDate() { SimpleDateFormat sdf = new SimpleDateFormat("dd.MM.yyyy HH:mm"); return sdf.format(modified); } - + public Date getModified() { return modified; } - + public boolean isDirectory() { return isDirectory; } - + public Icon getIcon() { return icon; } - + public String getPath() { - return file.getAbsolutePath(); + return displayPath != null ? displayPath : file.getAbsolutePath(); } public boolean isMarked() { diff --git a/src/main/java/cz/kamma/kfmanager/service/FileOperations.java b/src/main/java/cz/kamma/kfmanager/service/FileOperations.java index fa35ca9..3cd29cd 100644 --- a/src/main/java/cz/kamma/kfmanager/service/FileOperations.java +++ b/src/main/java/cz/kamma/kfmanager/service/FileOperations.java @@ -238,7 +238,7 @@ public class FileOperations { /** * Search files by pattern */ - public static void search(File directory, String pattern, boolean recursive, SearchCallback callback) throws IOException { + public static void search(File directory, String pattern, boolean recursive, boolean searchArchives, SearchCallback callback) throws IOException { if (pattern == null) return; // Prepare a compiled regex if the pattern contains wildcards to avoid recompiling per-file Pattern filenameRegex = null; @@ -249,29 +249,30 @@ public class FileOperations { .replace("?", "."); filenameRegex = Pattern.compile(regex, Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE); } - searchRecursive(directory.toPath(), pattern.toLowerCase(), filenameRegex, recursive, callback); + searchRecursive(directory.toPath(), pattern.toLowerCase(), filenameRegex, recursive, searchArchives, callback); } /** * Search file contents for a text fragment (case-insensitive). * Calls callback.onFileFound(file) when a file contains the text. */ - public static void searchContents(File directory, String text, boolean recursive, SearchCallback callback) throws IOException { + public static void searchContents(File directory, String text, boolean recursive, boolean searchArchives, SearchCallback callback) throws IOException { if (text == null) return; // Precompile a case-insensitive pattern for content search to avoid per-line lowercasing Pattern contentPattern = Pattern.compile(Pattern.quote(text), Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE); - searchContentsRecursive(directory.toPath(), contentPattern, recursive, callback); + searchContentsRecursive(directory.toPath(), contentPattern, recursive, searchArchives, callback); } - private static void searchRecursive(Path directory, String patternLower, Pattern filenameRegex, boolean recursive, SearchCallback callback) throws IOException { + private static void searchRecursive(Path directory, String patternLower, Pattern filenameRegex, boolean recursive, boolean searchArchives, SearchCallback callback) throws IOException { try (DirectoryStream stream = Files.newDirectoryStream(directory)) { for (Path entry : stream) { if (Files.isDirectory(entry)) { if (recursive) { - searchRecursive(entry, patternLower, filenameRegex, recursive, callback); + searchRecursive(entry, patternLower, filenameRegex, recursive, searchArchives, callback); } } else { - String fileName = entry.getFileName().toString(); + File file = entry.toFile(); + String fileName = file.getName(); String fileNameLower = fileName.toLowerCase(); boolean matched = false; if (fileNameLower.contains(patternLower)) matched = true; @@ -280,7 +281,12 @@ public class FileOperations { if (m.matches()) matched = true; } if (matched) { - callback.onFileFound(entry.toFile()); + callback.onFileFound(file, null); + } + + // SEARCH IN ARCHIVES + if (searchArchives && isArchiveFile(file)) { + searchInArchive(file, fileNameLower, filenameRegex, callback); } } } @@ -289,14 +295,15 @@ public class FileOperations { } } - private static void searchContentsRecursive(Path directory, Pattern contentPattern, boolean recursive, SearchCallback callback) throws IOException { + private static void searchContentsRecursive(Path directory, Pattern contentPattern, boolean recursive, boolean searchArchives, SearchCallback callback) throws IOException { try (DirectoryStream stream = Files.newDirectoryStream(directory)) { for (Path entry : stream) { if (Files.isDirectory(entry)) { if (recursive) { - searchContentsRecursive(entry, contentPattern, recursive, callback); + searchContentsRecursive(entry, contentPattern, recursive, searchArchives, callback); } } else { + File file = entry.toFile(); // Try reading file as text line-by-line and search for pattern (case-insensitive via compiled Pattern) try (BufferedReader br = Files.newBufferedReader(entry)) { String line; @@ -307,16 +314,160 @@ public class FileOperations { break; } } - if (found) callback.onFileFound(entry.toFile()); + if (found) callback.onFileFound(file, null); } catch (IOException ex) { // Skip files that cannot be read as text } + + // SEARCH IN ARCHIVES CONTENTS + if (searchArchives && isArchiveFile(file)) { + searchContentsInArchive(file, contentPattern, callback); + } } } } catch (AccessDeniedException e) { // Ignore directories without access } } + + private static boolean isArchiveFile(File f) { + if (f == null) return false; + String n = f.getName().toLowerCase(); + return n.endsWith(".zip") || n.endsWith(".jar") || n.endsWith(".tar") || n.endsWith(".tar.gz") || n.endsWith(".tgz") || n.endsWith(".7z") || n.endsWith(".rar"); + } + + private static void searchInArchive(File archive, String patternLower, Pattern filenameRegex, SearchCallback callback) { + String name = archive.getName().toLowerCase(); + try { + if (name.endsWith(".zip") || name.endsWith(".jar")) { + try (ZipInputStream zis = new ZipInputStream(new FileInputStream(archive))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + if (matchEntry(entry.getName(), patternLower, filenameRegex)) { + callback.onFileFound(archive, archive.getAbsolutePath() + File.separator + entry.getName()); + } + } + } + } else if (name.endsWith(".tar.gz") || name.endsWith(".tgz") || name.endsWith(".tar")) { + InputStream is = new FileInputStream(archive); + if (name.endsWith(".gz") || name.endsWith(".tgz")) { + is = new org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream(is); + } + try (org.apache.commons.compress.archivers.tar.TarArchiveInputStream tais = new org.apache.commons.compress.archivers.tar.TarArchiveInputStream(is)) { + org.apache.commons.compress.archivers.tar.TarArchiveEntry entry; + while ((entry = tais.getNextTarEntry()) != null) { + if (matchEntry(entry.getName(), patternLower, filenameRegex)) { + callback.onFileFound(archive, archive.getAbsolutePath() + File.separator + entry.getName()); + } + } + } + } else if (name.endsWith(".7z")) { + try (org.apache.commons.compress.archivers.sevenz.SevenZFile sevenZFile = new org.apache.commons.compress.archivers.sevenz.SevenZFile(archive)) { + org.apache.commons.compress.archivers.sevenz.SevenZArchiveEntry entry; + while ((entry = sevenZFile.getNextEntry()) != null) { + if (matchEntry(entry.getName(), patternLower, filenameRegex)) { + callback.onFileFound(archive, archive.getAbsolutePath() + File.separator + entry.getName()); + } + } + } + } else if (name.endsWith(".rar")) { + try (com.github.junrar.Archive rar = new com.github.junrar.Archive(archive)) { + for (com.github.junrar.rarfile.FileHeader fh : rar.getFileHeaders()) { + if (matchEntry(fh.getFileName(), patternLower, filenameRegex)) { + callback.onFileFound(archive, archive.getAbsolutePath() + File.separator + fh.getFileName()); + } + } + } + } + } catch (Exception ignore) {} + } + + private static void searchContentsInArchive(File archive, Pattern contentPattern, SearchCallback callback) { + String name = archive.getName().toLowerCase(); + try { + if (name.endsWith(".zip") || name.endsWith(".jar")) { + try (ZipInputStream zis = new ZipInputStream(new FileInputStream(archive))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + if (!entry.isDirectory() && searchInStream(zis, contentPattern)) { + callback.onFileFound(archive, archive.getAbsolutePath() + File.separator + entry.getName()); + } + } + } + } else if (name.endsWith(".tar.gz") || name.endsWith(".tgz") || name.endsWith(".tar")) { + InputStream is = new FileInputStream(archive); + if (name.endsWith(".gz") || name.endsWith(".tgz")) { + is = new org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream(is); + } + try (org.apache.commons.compress.archivers.tar.TarArchiveInputStream tais = new org.apache.commons.compress.archivers.tar.TarArchiveInputStream(is)) { + org.apache.commons.compress.archivers.tar.TarArchiveEntry entry; + while ((entry = tais.getNextTarEntry()) != null) { + if (!entry.isDirectory() && searchInStream(tais, contentPattern)) { + callback.onFileFound(archive, archive.getAbsolutePath() + File.separator + entry.getName()); + } + } + } + } else if (name.endsWith(".7z")) { + try (org.apache.commons.compress.archivers.sevenz.SevenZFile sevenZFile = new org.apache.commons.compress.archivers.sevenz.SevenZFile(archive)) { + org.apache.commons.compress.archivers.sevenz.SevenZArchiveEntry entry; + while ((entry = sevenZFile.getNextEntry()) != null) { + if (!entry.isDirectory()) { + // SevenZFile.read(buffer) reads from current entry + if (searchInStream(new InputStream() { + @Override + public int read() throws IOException { + return sevenZFile.read(); + } + @Override + public int read(byte[] b, int off, int len) throws IOException { + return sevenZFile.read(b, off, len); + } + }, contentPattern)) { + callback.onFileFound(archive, archive.getAbsolutePath() + File.separator + entry.getName()); + } + } + } + } + } else if (name.endsWith(".rar")) { + try (com.github.junrar.Archive rar = new com.github.junrar.Archive(archive)) { + for (com.github.junrar.rarfile.FileHeader fh : rar.getFileHeaders()) { + if (!fh.isDirectory()) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + rar.extractFile(fh, baos); + if (contentPattern.matcher(new String(baos.toByteArray())).find()) { + callback.onFileFound(archive, archive.getAbsolutePath() + File.separator + fh.getFileName()); + } + } + } + } + } + } catch (Exception ignore) {} + } + + private static boolean matchEntry(String entryName, String patternLower, Pattern filenameRegex) { + if (entryName == null) return false; + String nameShort = entryName; + int lastSlash = entryName.lastIndexOf('/'); + if (lastSlash != -1) nameShort = entryName.substring(lastSlash + 1); + + String nameLower = nameShort.toLowerCase(); + if (nameLower.contains(patternLower)) return true; + if (filenameRegex != null && filenameRegex.matcher(nameShort).matches()) return true; + return false; + } + + private static boolean searchInStream(InputStream is, Pattern contentPattern) { + try { + BufferedReader br = new BufferedReader(new InputStreamReader(is)); + String line; + while ((line = br.readLine()) != null) { + if (contentPattern.matcher(line).find()) { + return true; + } + } + } catch (Exception ignore) {} + return false; + } /** * Zip files/directories into a target zip file @@ -431,6 +582,6 @@ public class FileOperations { * Callback for search */ public interface SearchCallback { - void onFileFound(File file); + void onFileFound(File file, String virtualPath); } } diff --git a/src/main/java/cz/kamma/kfmanager/ui/MainWindow.java b/src/main/java/cz/kamma/kfmanager/ui/MainWindow.java index d820857..f470684 100644 --- a/src/main/java/cz/kamma/kfmanager/ui/MainWindow.java +++ b/src/main/java/cz/kamma/kfmanager/ui/MainWindow.java @@ -283,17 +283,44 @@ public class MainWindow extends JFrame { JTextField tf = (JTextField) editorComp; tf.setFocusTraversalKeysEnabled(false); tf.addActionListener(e -> executeCommand(tf.getText())); + + // 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) { - if (!tf.getText().isEmpty()) { - tf.setText(""); - if (activePanel != null && activePanel.getFileTable() != null) { - activePanel.getFileTable().requestFocusInWindow(); - } + tf.setText(""); + if (activePanel != null && activePanel.getFileTable() != null) { + activePanel.getFileTable().requestFocusInWindow(); } e.consume(); } else if (e.getKeyCode() == KeyEvent.VK_TAB) { @@ -715,6 +742,24 @@ public class MainWindow extends JFrame { 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) { + JTextField tf = (JTextField) ed; + 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(selColor); + } else { + tf.setCaretColor(dark ? Color.WHITE : Color.BLACK); + } + } + } }); } @@ -722,6 +767,18 @@ public class MainWindow extends JFrame { if (sel != null) { if (leftPanel != null) leftPanel.applySelectionColor(sel); if (rightPanel != null) rightPanel.applySelectionColor(sel); + + // Apply selection color to command line editor for cursor and selection + if (commandLine != null) { + Component ed = commandLine.getEditor().getEditorComponent(); + if (ed instanceof JTextField) { + JTextField tf = (JTextField) ed; + tf.setSelectionColor(sel); + tf.setCaretColor(sel); + 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()); } @@ -750,6 +807,8 @@ public class MainWindow extends JFrame { 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.setBackground(bg); @@ -757,6 +816,17 @@ public class MainWindow extends JFrame { if (c instanceof JLabel || c instanceof JCheckBox || c instanceof JRadioButton || c instanceof JButton) { c.setForeground(dark ? Color.WHITE : Color.BLACK); } + if (c instanceof javax.swing.text.JTextComponent) { + javax.swing.text.JTextComponent tc = (javax.swing.text.JTextComponent) c; + tc.setBackground(bg); + tc.setForeground(dark ? Color.WHITE : Color.BLACK); + if (selColor != null) { + tc.setSelectionColor(selColor); + tc.setCaretColor(selColor); + } else { + tc.setCaretColor(dark ? Color.WHITE : Color.BLACK); + } + } if (c instanceof Container) { updateComponentBackground((Container) c, bg); } diff --git a/src/main/java/cz/kamma/kfmanager/ui/SearchDialog.java b/src/main/java/cz/kamma/kfmanager/ui/SearchDialog.java index a0dcd38..df4f920 100644 --- a/src/main/java/cz/kamma/kfmanager/ui/SearchDialog.java +++ b/src/main/java/cz/kamma/kfmanager/ui/SearchDialog.java @@ -22,6 +22,7 @@ public class SearchDialog extends JDialog { private JComboBox contentPatternCombo; private JCheckBox recursiveCheckBox; private JCheckBox contentSearchCheckBox; + private JCheckBox archiveSearchCheckBox; private JTable resultsTable; private ResultsTableModel tableModel; private JButton searchButton; @@ -112,13 +113,17 @@ public class SearchDialog extends JDialog { contentPatternCombo.setToolTipText("Text to search inside files"); searchPanel.add(contentPatternCombo, gbc); - gbc.gridx = 0; gbc.gridy = 3; gbc.gridwidth = 2; contentSearchCheckBox = new JCheckBox("Search inside file contents", false); searchPanel.add(contentSearchCheckBox, gbc); gbc.gridy = 4; + archiveSearchCheckBox = new JCheckBox("Search inside archives", false); + archiveSearchCheckBox.setMnemonic(KeyEvent.VK_R); + searchPanel.add(archiveSearchCheckBox, gbc); + + gbc.gridy = 5; JLabel pathLabel = new JLabel("Directory: " + searchDirectory.getAbsolutePath()); pathLabel.setFont(pathLabel.getFont().deriveFont(Font.ITALIC)); searchPanel.add(pathLabel, gbc); @@ -296,6 +301,18 @@ public class SearchDialog extends JDialog { } }); + // Alt+R toggles archive search + getRootPane().getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put( + KeyStroke.getKeyStroke(KeyEvent.VK_R, InputEvent.ALT_DOWN_MASK), "toggleArchiveSearch"); + getRootPane().getActionMap().put("toggleArchiveSearch", new AbstractAction() { + @Override + public void actionPerformed(java.awt.event.ActionEvent e) { + if (archiveSearchCheckBox != null) { + archiveSearchCheckBox.setSelected(!archiveSearchCheckBox.isSelected()); + } + } + }); + // Escape closes the dialog (and cancels ongoing search) getRootPane().registerKeyboardAction(e -> { searching = false; @@ -433,6 +450,7 @@ public class SearchDialog extends JDialog { } final String pattern = isContentSearch ? contentPat : namePat; + final boolean searchArchives = archiveSearchCheckBox != null && archiveSearchCheckBox.isSelected(); // Reset and show status foundCount = 0; @@ -441,27 +459,29 @@ public class SearchDialog extends JDialog { statusProgressBar.setIndeterminate(true); // Spustit vyhledávání v samostatném vlákně - SwingWorker worker = new SwingWorker() { + SwingWorker worker = new SwingWorker() { @Override protected Void doInBackground() throws Exception { if (isContentSearch) { - FileOperations.searchContents(searchDirectory, pattern, recursiveCheckBox.isSelected(), file -> { + FileOperations.searchContents(searchDirectory, pattern, recursiveCheckBox.isSelected(), searchArchives, (file, virtualPath) -> { if (!searching) return; - publish(file); + publish(new Object[]{file, virtualPath}); }); } else { - FileOperations.search(searchDirectory, pattern, recursiveCheckBox.isSelected(), file -> { + FileOperations.search(searchDirectory, pattern, recursiveCheckBox.isSelected(), searchArchives, (file, virtualPath) -> { if (!searching) return; - publish(file); + publish(new Object[]{file, virtualPath}); }); } return null; } @Override - protected void process(List chunks) { - for (File file : chunks) { - tableModel.addResult(new FileItem(file)); + protected void process(List chunks) { + for (Object[] chunk : chunks) { + File file = (File) chunk[0]; + String virtualPath = (String) chunk[1]; + tableModel.addResult(new FileItem(file, virtualPath)); // update found count and status foundCount++; statusLabel.setText("Found " + foundCount + " — searching...");