import os
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
import threading
class FileSearcher:
def __init__(self, root):
self.root = root
self.root.title("File String Searcher")
self.root.geometry("900x650")
self._stop_event = threading.Event()
self._search_thread = None
self._terms_list = None # list[str] when a .txt terms file is loaded
main_frame = ttk.Frame(root, padding="10")
main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
root.columnconfigure(0, weight=1)
root.rowconfigure(0, weight=1)
main_frame.columnconfigure(1, weight=1)
main_frame.rowconfigure(5, weight=1)
# ---- Path row ----
ttk.Label(main_frame, text="Search Path:").grid(row=0, column=0, sticky=tk.W, pady=5)
self.path_var = tk.StringVar()
ttk.Entry(main_frame, textvariable=self.path_var, width=50).grid(
row=0, column=1, sticky=(tk.W, tk.E), padx=5, pady=5)
ttk.Button(main_frame, text="Browse", command=self.browse_folder).grid(
row=0, column=2, pady=5)
# ---- Search string row ----
ttk.Label(main_frame, text="Search String:").grid(row=1, column=0, sticky=tk.W, pady=5)
self.search_var = tk.StringVar()
self.search_entry = ttk.Entry(main_frame, textvariable=self.search_var, width=50)
self.search_entry.grid(row=1, column=1, sticky=(tk.W, tk.E), padx=5, pady=5)
self.search_entry.bind('<Return>', lambda e: self.start_search())
# ---- Terms file row ----
ttk.Label(main_frame, text="Terms File (.txt):").grid(row=2, column=0, sticky=tk.W, pady=5)
self.terms_var = tk.StringVar()
self.terms_entry = ttk.Entry(main_frame, textvariable=self.terms_var, width=50)
self.terms_entry.grid(row=2, column=1, sticky=(tk.W, tk.E), padx=5, pady=5)
terms_btn_frame = ttk.Frame(main_frame)
terms_btn_frame.grid(row=2, column=2, sticky=tk.W)
ttk.Button(terms_btn_frame, text="Load",
command=self.load_terms_file).pack(side=tk.LEFT)
ttk.Button(terms_btn_frame, text="X",
command=self.clear_terms_file, width=2).pack(side=tk.LEFT, padx=(2, 0))
# ---- Options + buttons row ----
options_frame = ttk.Frame(main_frame)
options_frame.grid(row=3, column=0, columnspan=3, pady=8, sticky=tk.W)
self.case_sensitive_var = tk.BooleanVar(value=False)
ttk.Checkbutton(options_frame, text="Case sensitive",
variable=self.case_sensitive_var).pack(side=tk.LEFT, padx=(0, 20))
self.search_button = ttk.Button(options_frame, text="Search", command=self.start_search)
self.search_button.pack(side=tk.LEFT, padx=5)
self.stop_button = ttk.Button(options_frame, text="Stop",
command=self.stop_search, state='disabled')
self.stop_button.pack(side=tk.LEFT, padx=5)
self.clear_button = ttk.Button(options_frame, text="Clear Results",
command=self.clear_results)
self.clear_button.pack(side=tk.LEFT, padx=5)
# ---- Progress ----
self.progress = ttk.Progressbar(main_frame, mode='indeterminate')
self.progress.grid(row=4, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=5)
# ---- Results ----
result_frame = ttk.Frame(main_frame)
result_frame.grid(row=5, column=0, columnspan=3, sticky=(tk.W, tk.E, tk.N, tk.S), pady=5)
result_frame.columnconfigure(0, weight=1)
result_frame.rowconfigure(0, weight=1)
columns = ("Term", "Path", "File", "Line", "Match")
self.tree = ttk.Treeview(result_frame, columns=columns, show="headings", height=15)
self.tree.heading("Term", text="Matched Term")
self.tree.heading("Path", text="Full Path")
self.tree.heading("File", text="File Name")
self.tree.heading("Line", text="Line #")
self.tree.heading("Match", text="Matched Line")
self.tree.column("Term", width=130)
self.tree.column("Path", width=280)
self.tree.column("File", width=160)
self.tree.column("Line", width=60)
self.tree.column("Match", width=280)
vsb = ttk.Scrollbar(result_frame, orient="vertical", command=self.tree.yview)
hsb = ttk.Scrollbar(result_frame, orient="horizontal", command=self.tree.xview)
self.tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
self.tree.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
vsb.grid(row=0, column=1, sticky=(tk.N, tk.S))
hsb.grid(row=1, column=0, sticky=(tk.W, tk.E))
# ---- Status ----
self.status_var = tk.StringVar(value="Ready to search")
ttk.Label(main_frame, textvariable=self.status_var).grid(
row=6, column=0, columnspan=3, sticky=tk.W, pady=5)
# ---------- Callbacks ----------
def browse_folder(self):
folder = filedialog.askdirectory()
if folder:
self.path_var.set(folder)
def load_terms_file(self):
fp = filedialog.askopenfilename(
title="Select terms file",
filetypes=[("Text files", "*.txt"), ("All files", "*.*")])
if not fp:
return
try:
with open(fp, 'r', encoding='utf-8', errors='replace') as f:
terms = [line.strip() for line in f if line.strip()]
except OSError as e:
messagebox.showerror("Error", f"Could not read terms file:\n{e}")
return
if not terms:
messagebox.showwarning("Empty file", "The selected file has no non-empty lines.")
return
self._terms_list = terms
self.terms_var.set(fp)
self.status_var.set(f"Loaded {len(terms)} search term(s) from {os.path.basename(fp)}")
def clear_terms_file(self):
self._terms_list = None
self.terms_var.set("")
self.status_var.set("Cleared terms file.")
def clear_results(self):
for item in self.tree.get_children():
self.tree.delete(item)
self.status_var.set("Results cleared.")
def start_search(self):
path = self.path_var.get().strip()
# Determine search terms
if self._terms_list:
search_terms = self._terms_list
else:
single = self.search_var.get().strip()
if not single:
messagebox.showwarning("Warning",
"Enter a search string or load a terms .txt file.")
return
search_terms = [single]
if not path:
messagebox.showwarning("Warning", "Please select a search path")
return
if not os.path.exists(path):
messagebox.showerror("Error", f"Path does not exist: {path}")
return
for item in self.tree.get_children():
self.tree.delete(item)
self._stop_event.clear()
self.search_button.config(state='disabled')
self.stop_button.config(state='normal')
self.progress.start(10)
self.status_var.set(f"Searching for {len(search_terms)} term(s)...")
self._search_thread = threading.Thread(
target=self.search_files,
args=(path, search_terms, self.case_sensitive_var.get()),
daemon=True)
self._search_thread.start()
def stop_search(self):
self._stop_event.set()
self.status_var.set("Stopping...")
# ---------- Search core ----------
def search_files(self, path, search_terms, case_sensitive):
matched_files = 0
total_files = 0
# Prepare (term_bytes, term_display) pairs once.
terms = [(t.encode('utf-8', errors='ignore'), t) for t in search_terms]
# overlap = longest needle minus 1, so cross-chunk matches aren't missed
overlap = max(len(tb) for tb, _ in terms) - 1 if terms else 0
try:
for root_dir, dirs, files in os.walk(path):
if self._stop_event.is_set():
break
dirs[:] = [d for d in dirs if d not in (
'.git', 'node_modules', '__pycache__', '.venv', 'venv')]
for file in files:
if self._stop_event.is_set():
break
total_files += 1
file_path = os.path.join(root_dir, file)
try:
if os.path.getsize(file_path) == 0:
continue
if self._scan_file(file_path, terms, overlap, case_sensitive):
matched_files += 1
except (PermissionError, OSError, FileNotFoundError):
continue
if total_files % 500 == 0:
msg = f"Scanning... {total_files} files, {matched_files} matches"
self.root.after(0, self.status_var.set, msg)
self.root.after(0, self.search_complete,
matched_files, total_files,
self._stop_event.is_set())
except Exception as e:
self.root.after(0, self.search_error, str(e))
def _scan_file(self, file_path, terms, overlap, case_sensitive):
"""
Stream file in 64KB chunks. All terms are searched in one pass.
`overlap` bytes from the previous chunk are retained so matches
spanning a chunk boundary aren't missed.
"""
chunk_size = 64 * 1024
prev_tail = b''
line_number = 1
found_any = False
first_chunk = True
try:
with open(file_path, 'rb') as f:
while True:
if self._stop_event.is_set():
break
chunk = f.read(chunk_size)
if not chunk:
break
if first_chunk:
first_chunk = False
if b'\x00' in chunk:
return False # binary
combined = prev_tail + chunk
# Search every term in this combined slice.
# For case-insensitive mode, lower once per chunk.
haystack = combined if case_sensitive else combined.lower()
for term_bytes, term_display in terms:
needle = term_bytes if case_sensitive else term_bytes.lower()
positions = []
idx = 0
while True:
p = haystack.find(needle, idx)
if p == -1:
break
positions.append(p)
idx = p + 1
for pos in positions:
line_start = combined.rfind(b'\n', 0, pos) + 1
line_end = combined.find(b'\n', pos)
if line_end == -1:
line_end = len(combined)
lines_before = combined.count(b'\n', 0, pos)
line_num = line_number + lines_before
snippet = combined[line_start:line_end].decode(
'utf-8', errors='replace')
if len(snippet) > 200:
snippet = snippet[:200] + "..."
self.root.after(0, self.add_result,
term_display, file_path,
os.path.basename(file_path),
line_num, snippet)
found_any = True
consumed = len(combined) - overlap
if consumed > 0:
line_number += combined.count(b'\n', 0, consumed)
prev_tail = combined[consumed:]
else:
prev_tail = combined
except (UnicodeDecodeError, PermissionError, OSError):
return False
return found_any
def add_result(self, term, file_path, file_name, line_num, match_context):
self.tree.insert("", "end",
values=(term, file_path, file_name, line_num, match_context))
def search_complete(self, matched_files, total_files, was_stopped):
self.progress.stop()
self.search_button.config(state='normal')
self.stop_button.config(state='disabled')
prefix = "Search stopped" if was_stopped else "Search complete"
self.status_var.set(
f"{prefix}. {matched_files} match(es) in {total_files} files.")
def search_error(self, error_message):
self.progress.stop()
self.search_button.config(state='normal')
self.stop_button.config(state='disabled')
self.status_var.set("Search failed")
messagebox.showerror("Search Error", f"An error occurred: {error_message}")
def main():
root = tk.Tk()
FileSearcher(root)
root.mainloop()
if __name__ == "__main__":
main()