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,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 "<DIR>";
}
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() {

View File

@ -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<Path> 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<Path> 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);
}
}

View File

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

View File

@ -22,6 +22,7 @@ public class SearchDialog extends JDialog {
private JComboBox<String> 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<Void, File> worker = new SwingWorker<Void, File>() {
SwingWorker<Void, Object[]> worker = new SwingWorker<Void, Object[]>() {
@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<File> chunks) {
for (File file : chunks) {
tableModel.addResult(new FileItem(file));
protected void process(List<Object[]> 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...");