347 lines
15 KiB
Python
347 lines
15 KiB
Python
import os
|
|
import threading
|
|
import subprocess
|
|
from datetime import datetime
|
|
import tkinter as tk
|
|
from tkinter import ttk
|
|
from typing import Optional, TYPE_CHECKING
|
|
|
|
from shared_libs.message import MessageDialog
|
|
from .cfd_ui_setup import get_xdg_user_dir
|
|
from .cfd_app_config import LocaleStrings
|
|
|
|
if TYPE_CHECKING:
|
|
from custom_file_dialog import CustomFileDialog
|
|
|
|
|
|
class SearchManager:
|
|
"""Manages the file search functionality, including UI and threading."""
|
|
|
|
def __init__(self, dialog: 'CustomFileDialog') -> None:
|
|
"""
|
|
Initializes the SearchManager.
|
|
|
|
Args:
|
|
dialog: The main CustomFileDialog instance.
|
|
"""
|
|
self.dialog = dialog
|
|
|
|
def show_search_ready(self, event: Optional[tk.Event] = None) -> None:
|
|
"""Shows the static 'full circle' to indicate search is ready."""
|
|
if not self.dialog.search_mode:
|
|
self.dialog.widget_manager.search_animation.show_full_circle()
|
|
|
|
def activate_search(self, event: Optional[tk.Event] = None) -> None:
|
|
"""
|
|
Activates the search entry or cancels an ongoing search.
|
|
|
|
If a search is running, it cancels it. Otherwise, it executes a new search
|
|
only if there is a search term present.
|
|
"""
|
|
if self.dialog.widget_manager.search_animation.running:
|
|
if self.dialog.search_thread and self.dialog.search_thread.is_alive():
|
|
self.dialog.search_thread.cancelled = True
|
|
self.dialog.widget_manager.search_animation.stop()
|
|
self.dialog.widget_manager.search_status_label.config(
|
|
text=LocaleStrings.UI["cancel_search"])
|
|
else:
|
|
# Only execute search if there is text in the entry
|
|
if self.dialog.widget_manager.filename_entry.get().strip():
|
|
self.execute_search()
|
|
|
|
def show_search_bar(self, event: tk.Event) -> None:
|
|
"""
|
|
Activates search mode and displays the search bar upon user typing.
|
|
|
|
Args:
|
|
event: The key press event that triggered the search.
|
|
"""
|
|
if isinstance(event.widget, (ttk.Entry, tk.Entry)) or not event.char.strip():
|
|
return
|
|
self.dialog.search_mode = True
|
|
self.dialog.widget_manager.filename_entry.focus_set()
|
|
self.dialog.widget_manager.filename_entry.delete(0, tk.END)
|
|
self.dialog.widget_manager.filename_entry.insert(0, event.char)
|
|
self.dialog.widget_manager.search_animation.show_full_circle()
|
|
|
|
def hide_search_bar(self, event: Optional[tk.Event] = None) -> None:
|
|
"""
|
|
Deactivates search mode, clears the search bar, and restores the file view.
|
|
"""
|
|
self.dialog.search_mode = False
|
|
self.dialog.widget_manager.filename_entry.delete(0, tk.END)
|
|
self.dialog.widget_manager.search_status_label.config(text="")
|
|
self.dialog.widget_manager.filename_entry.unbind("<Escape>")
|
|
self.dialog.view_manager.populate_files()
|
|
self.dialog.widget_manager.search_animation.hide()
|
|
|
|
def execute_search(self, event: Optional[tk.Event] = None) -> None:
|
|
"""
|
|
Initiates a file search in a background thread.
|
|
|
|
Prevents starting a new search if one is already running.
|
|
"""
|
|
if self.dialog.search_thread and self.dialog.search_thread.is_alive():
|
|
return
|
|
search_term = self.dialog.widget_manager.filename_entry.get().strip()
|
|
if not search_term:
|
|
self.hide_search_bar()
|
|
return
|
|
self.dialog.widget_manager.search_status_label.config(
|
|
text=f"{LocaleStrings.UI['searching_for']} '{search_term}'...")
|
|
self.dialog.widget_manager.search_animation.start(pulse=False)
|
|
self.dialog.update_idletasks()
|
|
self.dialog.search_thread = threading.Thread(
|
|
target=self._perform_search_in_thread, args=(search_term,))
|
|
self.dialog.search_thread.start()
|
|
|
|
def _perform_search_in_thread(self, search_term: str) -> None:
|
|
"""
|
|
Performs the actual file search in a background thread.
|
|
|
|
Searches the current directory and relevant XDG user directories.
|
|
Handles recursive/non-recursive and hidden/non-hidden file searches.
|
|
Updates the UI with results upon completion.
|
|
|
|
Args:
|
|
search_term (str): The term to search for.
|
|
"""
|
|
self.dialog.search_results.clear()
|
|
search_dirs = [self.dialog.current_dir]
|
|
home_dir = os.path.expanduser("~")
|
|
if os.path.abspath(self.dialog.current_dir) == os.path.abspath(home_dir):
|
|
xdg_dirs = [get_xdg_user_dir(d, f) for d, f in [("XDG_DOWNLOAD_DIR", "Downloads"), ("XDG_DOCUMENTS_DIR", "Documents"), (
|
|
"XDG_PICTURES_DIR", "Pictures"), ("XDG_MUSIC_DIR", "Music"), ("XDG_VIDEO_DIR", "Videos")]]
|
|
search_dirs.extend([d for d in xdg_dirs if os.path.exists(
|
|
d) and os.path.abspath(d) != home_dir and d not in search_dirs])
|
|
|
|
search_successful = False
|
|
try:
|
|
all_files = []
|
|
is_recursive = self.dialog.settings.get("recursive_search", True)
|
|
search_hidden = self.dialog.settings.get(
|
|
"search_hidden_files", False)
|
|
search_term_lower = search_term.lower()
|
|
|
|
for search_dir in search_dirs:
|
|
if not (self.dialog.search_thread and self.dialog.search_thread.is_alive()):
|
|
break
|
|
if not os.path.exists(search_dir):
|
|
continue
|
|
|
|
is_home_search = os.path.abspath(search_dir) == home_dir
|
|
follow_links = is_recursive and is_home_search
|
|
|
|
if is_recursive:
|
|
for root, dirs, files in os.walk(search_dir, followlinks=follow_links):
|
|
if not (self.dialog.search_thread and self.dialog.search_thread.is_alive()):
|
|
raise InterruptedError(
|
|
LocaleStrings.UI["search_cancelled_by_user"])
|
|
|
|
if not search_hidden:
|
|
dirs[:] = [
|
|
d for d in dirs if not d.startswith('.')]
|
|
files = [f for f in files if not f.startswith('.')]
|
|
|
|
for name in files:
|
|
if search_term_lower in name.lower() and self.dialog._matches_filetype(name):
|
|
all_files.append(os.path.join(root, name))
|
|
for name in dirs:
|
|
if search_term_lower in name.lower():
|
|
all_files.append(os.path.join(root, name))
|
|
else:
|
|
for name in os.listdir(search_dir):
|
|
if not (self.dialog.search_thread and self.dialog.search_thread.is_alive()):
|
|
raise InterruptedError(
|
|
LocaleStrings.UI["search_cancelled_by_user"])
|
|
|
|
if not search_hidden and name.startswith('.'):
|
|
continue
|
|
|
|
path = os.path.join(search_dir, name)
|
|
is_dir = os.path.isdir(path)
|
|
|
|
if search_term_lower in name.lower():
|
|
if is_dir:
|
|
all_files.append(path)
|
|
elif self.dialog._matches_filetype(name):
|
|
all_files.append(path)
|
|
|
|
if is_recursive:
|
|
break
|
|
|
|
if not (self.dialog.search_thread and self.dialog.search_thread.is_alive()):
|
|
raise InterruptedError(
|
|
LocaleStrings.UI["search_cancelled_by_user"])
|
|
|
|
seen = set()
|
|
self.dialog.search_results = [
|
|
x for x in all_files if not (x in seen or seen.add(x))]
|
|
|
|
def update_ui() -> None:
|
|
nonlocal search_successful
|
|
if self.dialog.search_results:
|
|
search_successful = True
|
|
self.show_search_results_treeview()
|
|
folder_count = sum(
|
|
1 for p in self.dialog.search_results if os.path.isdir(p))
|
|
file_count = len(self.dialog.search_results) - folder_count
|
|
self.dialog.widget_manager.search_status_label.config(
|
|
text=f"{folder_count} {LocaleStrings.UI['folders_and']} {file_count} {LocaleStrings.UI['files_found']}")
|
|
else:
|
|
search_successful = False
|
|
self.dialog.widget_manager.search_status_label.config(
|
|
text=f"{LocaleStrings.UI['no_results_for']} '{search_term}'.")
|
|
self.dialog.after(0, update_ui)
|
|
|
|
except (Exception, InterruptedError) as e:
|
|
if isinstance(e, (InterruptedError, subprocess.SubprocessError)):
|
|
self.dialog.after(0, lambda: self.dialog.widget_manager.search_status_label.config(
|
|
text=LocaleStrings.UI["cancel_search"]))
|
|
else:
|
|
self.dialog.after(0, lambda: MessageDialog(
|
|
message_type="error", text=f"{LocaleStrings.UI['error_during_search']}: {e}", title=LocaleStrings.UI["search_error"], master=self.dialog).show())
|
|
finally:
|
|
self.dialog.after(
|
|
0, lambda: self.dialog.widget_manager.search_animation.stop(status="DISABLE" if not search_successful else None))
|
|
self.dialog.search_process = None
|
|
|
|
def show_search_results_treeview(self) -> None:
|
|
"""Displays the search results in a dedicated Treeview."""
|
|
if self.dialog.widget_manager.file_list_frame.winfo_exists():
|
|
for widget in self.dialog.widget_manager.file_list_frame.winfo_children():
|
|
widget.destroy()
|
|
|
|
tree_frame = ttk.Frame(self.dialog.widget_manager.file_list_frame)
|
|
tree_frame.pack(fill='both', expand=True)
|
|
tree_frame.grid_rowconfigure(0, weight=1)
|
|
tree_frame.grid_columnconfigure(0, weight=1)
|
|
|
|
columns = ("path", "size", "modified")
|
|
search_tree = ttk.Treeview(
|
|
tree_frame, columns=columns, show="tree headings")
|
|
|
|
search_tree.heading(
|
|
"#0", text=LocaleStrings.VIEW["filename"], anchor="w")
|
|
search_tree.column("#0", anchor="w", width=200, stretch=True)
|
|
search_tree.heading(
|
|
"path", text=LocaleStrings.VIEW["path"], anchor="w")
|
|
search_tree.column("path", anchor="w", width=300, stretch=True)
|
|
search_tree.heading(
|
|
"size", text=LocaleStrings.VIEW["size"], anchor="e")
|
|
search_tree.column("size", anchor="e", width=100, stretch=False)
|
|
search_tree.heading(
|
|
"modified", text=LocaleStrings.VIEW["date_modified"], anchor="w")
|
|
search_tree.column("modified", anchor="w", width=160, stretch=False)
|
|
|
|
v_scrollbar = ttk.Scrollbar(
|
|
tree_frame, orient="vertical", command=search_tree.yview)
|
|
h_scrollbar = ttk.Scrollbar(
|
|
tree_frame, orient="horizontal", command=search_tree.xview)
|
|
search_tree.configure(yscrollcommand=v_scrollbar.set,
|
|
xscrollcommand=h_scrollbar.set)
|
|
|
|
search_tree.grid(row=0, column=0, sticky='nsew')
|
|
v_scrollbar.grid(row=0, column=1, sticky='ns')
|
|
h_scrollbar.grid(row=1, column=0, sticky='ew')
|
|
|
|
for file_path in self.dialog.search_results:
|
|
try:
|
|
filename = os.path.basename(file_path)
|
|
directory = os.path.dirname(file_path)
|
|
stat = os.stat(file_path)
|
|
size = self.dialog._format_size(stat.st_size)
|
|
modified_time = datetime.fromtimestamp(
|
|
stat.st_mtime).strftime('%d.%m.%Y %H:%M')
|
|
|
|
if os.path.isdir(file_path):
|
|
icon = self.dialog.icon_manager.get_icon('folder_small')
|
|
else:
|
|
icon = self.dialog.get_file_icon(filename, 'small')
|
|
|
|
search_tree.insert("", "end", text=f" {filename}", image=icon,
|
|
values=(directory, size, modified_time))
|
|
except (FileNotFoundError, PermissionError):
|
|
continue
|
|
|
|
def on_search_select(event: tk.Event) -> None:
|
|
selection = search_tree.selection()
|
|
if selection:
|
|
item = search_tree.item(selection[0])
|
|
filename = item['text'].strip()
|
|
directory = item['values'][0]
|
|
full_path = os.path.join(directory, filename)
|
|
|
|
try:
|
|
stat = os.stat(full_path)
|
|
size_str = self.dialog._format_size(stat.st_size)
|
|
self.dialog.widget_manager.search_status_label.config(
|
|
text=f"'{filename}' {LocaleStrings.VIEW['size']}: {size_str}")
|
|
except (FileNotFoundError, PermissionError):
|
|
self.dialog.widget_manager.search_status_label.config(
|
|
text=f"'{filename}' {LocaleStrings.FILE['not_accessible']}")
|
|
|
|
self.dialog.widget_manager.filename_entry.delete(0, tk.END)
|
|
self.dialog.widget_manager.filename_entry.insert(0, filename)
|
|
|
|
search_tree.bind("<<TreeviewSelect>>", on_search_select)
|
|
|
|
def on_search_double_click(event: tk.Event) -> None:
|
|
selection = search_tree.selection()
|
|
if not selection:
|
|
return
|
|
|
|
item = search_tree.item(selection[0])
|
|
filename = item['text'].strip()
|
|
directory = item['values'][0]
|
|
full_path = os.path.join(directory, filename)
|
|
|
|
if not os.path.exists(full_path):
|
|
return # Item no longer exists
|
|
|
|
if os.path.isdir(full_path):
|
|
# For directories, navigate into them
|
|
self.hide_search_bar()
|
|
self.dialog.navigation_manager.navigate_to(full_path)
|
|
else:
|
|
# For files, select it and close the dialog
|
|
self.dialog.selected_file = full_path
|
|
self.dialog.destroy()
|
|
|
|
search_tree.bind("<Double-1>", on_search_double_click)
|
|
|
|
def show_context_menu(event: tk.Event) -> str:
|
|
iid = search_tree.identify_row(event.y)
|
|
if not iid:
|
|
return "break"
|
|
search_tree.selection_set(iid)
|
|
item = search_tree.item(iid)
|
|
filename = item['text'].strip()
|
|
directory = item['values'][0]
|
|
full_path = os.path.join(directory, filename)
|
|
self.dialog.file_op_manager._show_context_menu(event, full_path)
|
|
return "break"
|
|
|
|
search_tree.bind("<ButtonRelease-3>", show_context_menu)
|
|
|
|
def _open_file_location(self, search_tree: ttk.Treeview) -> None:
|
|
"""
|
|
Navigates to the directory of the selected item in the search results.
|
|
|
|
Args:
|
|
search_tree: The Treeview widget containing the search results.
|
|
"""
|
|
selection = search_tree.selection()
|
|
if not selection:
|
|
return
|
|
|
|
item = search_tree.item(selection[0])
|
|
filename = item['text'].strip()
|
|
directory = item['values'][0]
|
|
|
|
self.hide_search_bar()
|
|
self.dialog.navigation_manager.navigate_to(directory)
|
|
|
|
self.dialog.after(
|
|
100, lambda: self.dialog.view_manager._select_file_in_view(filename))
|