UI fixes, added search support for archives

This commit is contained in:
Radek Davidek 2026-01-19 14:09:32 +01:00
parent 357aea509a
commit 2a516ab48c
4 changed files with 285 additions and 38 deletions

View File

@ -18,14 +18,20 @@ public class FileItem {
private final boolean isDirectory; private final boolean isDirectory;
private final Icon icon; private final Icon icon;
private boolean marked; private boolean marked;
private String displayPath;
public FileItem(File file) { public FileItem(File file) {
this(file, null);
}
public FileItem(File file, String displayPath) {
this.file = file; this.file = file;
this.name = file.getName(); this.name = file.getName();
this.size = file.length(); this.size = file.length();
this.modified = new Date(file.lastModified()); this.modified = new Date(file.lastModified());
this.isDirectory = file.isDirectory(); this.isDirectory = file.isDirectory();
this.marked = false; this.marked = false;
this.displayPath = displayPath;
// Load icon from system // Load icon from system
this.icon = FileSystemView.getFileSystemView().getSystemIcon(file); this.icon = FileSystemView.getFileSystemView().getSystemIcon(file);
@ -68,7 +74,7 @@ public class FileItem {
} }
public String getPath() { public String getPath() {
return file.getAbsolutePath(); return displayPath != null ? displayPath : file.getAbsolutePath();
} }
public boolean isMarked() { public boolean isMarked() {

View File

@ -238,7 +238,7 @@ public class FileOperations {
/** /**
* Search files by pattern * 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; if (pattern == null) return;
// Prepare a compiled regex if the pattern contains wildcards to avoid recompiling per-file // Prepare a compiled regex if the pattern contains wildcards to avoid recompiling per-file
Pattern filenameRegex = null; Pattern filenameRegex = null;
@ -249,29 +249,30 @@ public class FileOperations {
.replace("?", "."); .replace("?", ".");
filenameRegex = Pattern.compile(regex, Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE); 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). * Search file contents for a text fragment (case-insensitive).
* Calls callback.onFileFound(file) when a file contains the text. * 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; if (text == null) return;
// Precompile a case-insensitive pattern for content search to avoid per-line lowercasing // 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); 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<Path> stream = Files.newDirectoryStream(directory)) { try (DirectoryStream<Path> stream = Files.newDirectoryStream(directory)) {
for (Path entry : stream) { for (Path entry : stream) {
if (Files.isDirectory(entry)) { if (Files.isDirectory(entry)) {
if (recursive) { if (recursive) {
searchRecursive(entry, patternLower, filenameRegex, recursive, callback); searchRecursive(entry, patternLower, filenameRegex, recursive, searchArchives, callback);
} }
} else { } else {
String fileName = entry.getFileName().toString(); File file = entry.toFile();
String fileName = file.getName();
String fileNameLower = fileName.toLowerCase(); String fileNameLower = fileName.toLowerCase();
boolean matched = false; boolean matched = false;
if (fileNameLower.contains(patternLower)) matched = true; if (fileNameLower.contains(patternLower)) matched = true;
@ -280,7 +281,12 @@ public class FileOperations {
if (m.matches()) matched = true; if (m.matches()) matched = true;
} }
if (matched) { 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<Path> stream = Files.newDirectoryStream(directory)) { try (DirectoryStream<Path> stream = Files.newDirectoryStream(directory)) {
for (Path entry : stream) { for (Path entry : stream) {
if (Files.isDirectory(entry)) { if (Files.isDirectory(entry)) {
if (recursive) { if (recursive) {
searchContentsRecursive(entry, contentPattern, recursive, callback); searchContentsRecursive(entry, contentPattern, recursive, searchArchives, callback);
} }
} else { } else {
File file = entry.toFile();
// Try reading file as text line-by-line and search for pattern (case-insensitive via compiled Pattern) // Try reading file as text line-by-line and search for pattern (case-insensitive via compiled Pattern)
try (BufferedReader br = Files.newBufferedReader(entry)) { try (BufferedReader br = Files.newBufferedReader(entry)) {
String line; String line;
@ -307,10 +314,15 @@ public class FileOperations {
break; break;
} }
} }
if (found) callback.onFileFound(entry.toFile()); if (found) callback.onFileFound(file, null);
} catch (IOException ex) { } catch (IOException ex) {
// Skip files that cannot be read as text // Skip files that cannot be read as text
} }
// SEARCH IN ARCHIVES CONTENTS
if (searchArchives && isArchiveFile(file)) {
searchContentsInArchive(file, contentPattern, callback);
}
} }
} }
} catch (AccessDeniedException e) { } catch (AccessDeniedException e) {
@ -318,6 +330,145 @@ public class FileOperations {
} }
} }
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 * Zip files/directories into a target zip file
*/ */
@ -431,6 +582,6 @@ public class FileOperations {
* Callback for search * Callback for search
*/ */
public interface SearchCallback { public interface SearchCallback {
void onFileFound(File file); void onFileFound(File file, String virtualPath);
} }
} }

View File

@ -284,16 +284,43 @@ public class MainWindow extends JFrame {
tf.setFocusTraversalKeysEnabled(false); tf.setFocusTraversalKeysEnabled(false);
tf.addActionListener(e -> executeCommand(tf.getText())); 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 // Let the panels catch focus back if user presses ESC or TAB in command line
tf.addKeyListener(new KeyAdapter() { tf.addKeyListener(new KeyAdapter() {
@Override @Override
public void keyPressed(KeyEvent e) { public void keyPressed(KeyEvent e) {
if (e.getKeyCode() == KeyEvent.VK_ESCAPE) { if (e.getKeyCode() == KeyEvent.VK_ESCAPE) {
if (!tf.getText().isEmpty()) { tf.setText("");
tf.setText(""); if (activePanel != null && activePanel.getFileTable() != null) {
if (activePanel != null && activePanel.getFileTable() != null) { activePanel.getFileTable().requestFocusInWindow();
activePanel.getFileTable().requestFocusInWindow();
}
} }
e.consume(); e.consume();
} else if (e.getKeyCode() == KeyEvent.VK_TAB) { } else if (e.getKeyCode() == KeyEvent.VK_TAB) {
@ -715,6 +742,24 @@ public class MainWindow extends JFrame {
updateComponentBackground(getContentPane(), bg); updateComponentBackground(getContentPane(), bg);
if (leftPanel != null) leftPanel.applyBackgroundColor(bg); if (leftPanel != null) leftPanel.applyBackgroundColor(bg);
if (rightPanel != null) rightPanel.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 (sel != null) {
if (leftPanel != null) leftPanel.applySelectionColor(sel); if (leftPanel != null) leftPanel.applySelectionColor(sel);
if (rightPanel != null) rightPanel.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 // Ensure the active panel border uses the updated configuration color immediately
SwingUtilities.invokeLater(() -> updateActivePanelBorder()); SwingUtilities.invokeLater(() -> updateActivePanelBorder());
} }
@ -750,6 +807,8 @@ public class MainWindow extends JFrame {
if (container == null) return; if (container == null) return;
container.setBackground(bg); container.setBackground(bg);
boolean dark = isDark(bg); boolean dark = isDark(bg);
Color selColor = config != null ? config.getSelectionColor() : null;
for (Component c : container.getComponents()) { for (Component c : container.getComponents()) {
if (c instanceof JPanel || c instanceof JToolBar || c instanceof JScrollPane || c instanceof JViewport || c instanceof JTabbedPane || c instanceof JButton) { if (c instanceof JPanel || c instanceof JToolBar || c instanceof JScrollPane || c instanceof JViewport || c instanceof JTabbedPane || c instanceof JButton) {
c.setBackground(bg); 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) { if (c instanceof JLabel || c instanceof JCheckBox || c instanceof JRadioButton || c instanceof JButton) {
c.setForeground(dark ? Color.WHITE : Color.BLACK); 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) { if (c instanceof Container) {
updateComponentBackground((Container) c, bg); updateComponentBackground((Container) c, bg);
} }

View File

@ -22,6 +22,7 @@ public class SearchDialog extends JDialog {
private JComboBox<String> contentPatternCombo; private JComboBox<String> contentPatternCombo;
private JCheckBox recursiveCheckBox; private JCheckBox recursiveCheckBox;
private JCheckBox contentSearchCheckBox; private JCheckBox contentSearchCheckBox;
private JCheckBox archiveSearchCheckBox;
private JTable resultsTable; private JTable resultsTable;
private ResultsTableModel tableModel; private ResultsTableModel tableModel;
private JButton searchButton; private JButton searchButton;
@ -112,13 +113,17 @@ public class SearchDialog extends JDialog {
contentPatternCombo.setToolTipText("Text to search inside files"); contentPatternCombo.setToolTipText("Text to search inside files");
searchPanel.add(contentPatternCombo, gbc); searchPanel.add(contentPatternCombo, gbc);
gbc.gridx = 0;
gbc.gridy = 3; gbc.gridy = 3;
gbc.gridwidth = 2; gbc.gridwidth = 2;
contentSearchCheckBox = new JCheckBox("Search inside file contents", false); contentSearchCheckBox = new JCheckBox("Search inside file contents", false);
searchPanel.add(contentSearchCheckBox, gbc); searchPanel.add(contentSearchCheckBox, gbc);
gbc.gridy = 4; 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()); JLabel pathLabel = new JLabel("Directory: " + searchDirectory.getAbsolutePath());
pathLabel.setFont(pathLabel.getFont().deriveFont(Font.ITALIC)); pathLabel.setFont(pathLabel.getFont().deriveFont(Font.ITALIC));
searchPanel.add(pathLabel, gbc); 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) // Escape closes the dialog (and cancels ongoing search)
getRootPane().registerKeyboardAction(e -> { getRootPane().registerKeyboardAction(e -> {
searching = false; searching = false;
@ -433,6 +450,7 @@ public class SearchDialog extends JDialog {
} }
final String pattern = isContentSearch ? contentPat : namePat; final String pattern = isContentSearch ? contentPat : namePat;
final boolean searchArchives = archiveSearchCheckBox != null && archiveSearchCheckBox.isSelected();
// Reset and show status // Reset and show status
foundCount = 0; foundCount = 0;
@ -441,27 +459,29 @@ public class SearchDialog extends JDialog {
statusProgressBar.setIndeterminate(true); statusProgressBar.setIndeterminate(true);
// Spustit vyhledávání v samostatném vlákně // Spustit vyhledávání v samostatném vlákně
SwingWorker<Void, File> worker = new SwingWorker<Void, File>() { SwingWorker<Void, Object[]> worker = new SwingWorker<Void, Object[]>() {
@Override @Override
protected Void doInBackground() throws Exception { protected Void doInBackground() throws Exception {
if (isContentSearch) { if (isContentSearch) {
FileOperations.searchContents(searchDirectory, pattern, recursiveCheckBox.isSelected(), file -> { FileOperations.searchContents(searchDirectory, pattern, recursiveCheckBox.isSelected(), searchArchives, (file, virtualPath) -> {
if (!searching) return; if (!searching) return;
publish(file); publish(new Object[]{file, virtualPath});
}); });
} else { } else {
FileOperations.search(searchDirectory, pattern, recursiveCheckBox.isSelected(), file -> { FileOperations.search(searchDirectory, pattern, recursiveCheckBox.isSelected(), searchArchives, (file, virtualPath) -> {
if (!searching) return; if (!searching) return;
publish(file); publish(new Object[]{file, virtualPath});
}); });
} }
return null; return null;
} }
@Override @Override
protected void process(List<File> chunks) { protected void process(List<Object[]> chunks) {
for (File file : chunks) { for (Object[] chunk : chunks) {
tableModel.addResult(new FileItem(file)); File file = (File) chunk[0];
String virtualPath = (String) chunk[1];
tableModel.addResult(new FileItem(file, virtualPath));
// update found count and status // update found count and status
foundCount++; foundCount++;
statusLabel.setText("Found " + foundCount + " — searching..."); statusLabel.setText("Found " + foundCount + " — searching...");