Skip to content

Commit 5b8314a

Browse files
committed
feat: Integrierte globale Textsuche ueber alle Projektordner (Find in Files / Grep-Tool)
Fuegt ein vollstaendiges Find in Files / Grep-Werkzeug (Ctrl+Shift+F) hinzu mit asynchronem SearchWorker (QThread), Regex-/Ganzwort-/Case-Optionen, Glob-Filtern fuer Include/Exclude, hierarchischem Trefferbaum nach Dateien und Zeilen sowie Direktsprung in den Editor mit Cursor- und Treffer-Hervorhebung. Integriert in Bearbeiten-Menue, Projektbaum-Kontextmenue, Befehlspalette und Tastaturkuerzel-Referenz. 9 neue Unit- und Integrationstests, 100% ruff-clean.
1 parent 0b4f365 commit 5b8314a

11 files changed

Lines changed: 1161 additions & 0 deletions

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,30 @@ Alle wesentlichen Änderungen an CodeBox werden hier dokumentiert.
1212

1313
## [0.2.0] - 2026-09-12
1414

15+
### Integrierte globale Textsuche (Workspace Find in Files / Grep-Tool)
16+
17+
- `core/file_search.py`: Neues Kernmodul für schnelle datei- und projektübergreifende Textsuche:
18+
- Datenklassen `SearchMatch` (mit exakten 1-basierten Zeilen- und Spaltenangaben, Trefferlänge und Zeilenvorschau), `FileSearchResult` und `SearchOptions`.
19+
- Robuster Regex-Compiler `compile_search_regex` mit Unterstützung für Groß-/Kleinschreibung, Wortgrenzen (`\b`) und benutzerdefinierte Regex-Muster inklusive detaillierter Syntaxfehler-Rückmeldungen.
20+
- Leistungsfähiges Glob-Filtersystem `matches_glob_patterns` für Include- und Exclude-Filter (z. B. `*.py`, `*.ts`, `dist/*`).
21+
- Asynchroner Multi-Root `SearchWorker` (`QThread`) mit blockierungsfreier Verarbeitung, Abbruch-Unterstützung (`cancel()`), Fortschrittsmeldung (`searchProgress`), inkrementeller Trefferübermittlung (`fileCompleted`) und präziser Zeitmessung.
22+
- Automatische Null-Byte-Erkennung (`is_binary_file`) zum sicheren Überspringen von Binärdateien und robuste Kodierungs-Fallbacks (UTF-8 -> CP1252 -> Latin-1).
23+
- `ui/find_in_files_dialog.py`: Moderner, nicht-modaler Suchdialog (`FindInFilesDialog`):
24+
- Tastenkürzel `Ctrl+Shift+F` (sowie Alt-Mnemonics für Schalter: `Alt+K` Groß-/Kleinschreibung, `Alt+G` Ganzes Wort, `Alt+R` Regulärer Ausdruck, `Alt+S` Suchfeld).
25+
- Flexible Suchbereichsauswahl über alle Arbeitsbereichs-Ordner oder gezielt für einzelne Unterordner.
26+
- Glob-Filter für Dateitypen (Einschließen / Ausschließen).
27+
- Interaktiver Trefferbaum (`QTreeWidget`) mit Dateigruppierung, Trefferanzahl-Badges, farblich hervorgehobenen Zeilennummern und Kontextzeilenvorschau.
28+
- Direktsprung in den Editor per Enter oder Doppelklick mit Cursorfokussierung, Zentrierung und Hervorhebung des exakten Treffers.
29+
- Vollständige Barrierefreiheit (Accessible Names, Accessible Descriptions und Statusleiste mit Fortschrittsanzeige).
30+
- `ui/main_window.py`:
31+
- Menü *Bearbeiten* um *In Dateien suchen...* (`Ctrl+Shift+F`) erweitert.
32+
- Methoden `show_find_in_files` (übernimmt automatisch aktuellen markierten Text aus dem Editor) und `open_path_at` für punktgenaues Öffnen und Hervorheben.
33+
- `features/project_view.py`:
34+
- Ordner-Kontextmenü erweitert um *In diesem Ordner suchen...* zur direkten Fokussierung der Suche auf ein Verzeichnis.
35+
- Signal `findInFilesRequested` angebunden.
36+
- `ui/command_palette.py` & `ui/shortcuts_dialog.py`:
37+
- *In Dateien suchen (Projektweite Textsuche)* (`Ctrl+Shift+F`) in die Befehlspalette (`Ctrl+Shift+P`) und die Tastenkürzelübersicht aufgenommen.
38+
1539
### Multi-Root-Workspace Support
1640

1741
- `core/workspace.py`: Neues Kernmodul für Multi-Root-Arbeitsbereiche:

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,8 @@ sequenceDiagram
168168
| **Background Linting** | Asynchronous execution of local linters (Ruff, flake8, ESLint) on save. | Errors and warnings aggregated dynamically in unified Problems Panel. |
169169
| **Save-Failure Resilience** | Guarded filesystem write operations with buffer preservation. | Tabs remain open and unsaved state is protected if filesystem write fails. |
170170
| **Integrated Terminal** | Embedded `QProcess` terminal supporting `cmd`, `PowerShell`, and `bash`. | Dynamic encoding adaptation (cp1252 / utf-8) and working directory synchronisation. |
171+
| **Multi-Root Workspaces** | Native `.codebox-workspace` format with portable relative paths. | Seamless project switching across multiple repository roots (`Ctrl+Shift+O`). |
172+
| **Global Find in Files** | Multi-threaded background grep (`core/file_search.py`) with `Ctrl+Shift+F`. | Fast non-blocking search with Regex/Word/Case options and direct jump. |
171173

172174
---
173175

README_de.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,8 @@ sequenceDiagram
168168
| **Hintergrund-Linting** | Asynchrone Ausführung lokaler Linter (Ruff, flake8, ESLint) beim Speichern. | Dynamische Bündelung von Warnungen und Fehlern im Problems-Panel. |
169169
| **Schutz vor Speicherverlust** | Abgesicherte Dateisystemoperationen mit Puffererhaltung. | Tabs bleiben geöffnet und Daten geschützt, falls ein Schreibfehler auftritt. |
170170
| **Integriertes Terminal** | Eingebetteter `QProcess`-Terminalbereich für `cmd`, `PowerShell` und `bash`. | Dynamische Kodierungsanpassung (cp1252 / utf-8) und Verzeichnissynchronisation. |
171+
| **Multi-Root-Arbeitsbereiche** | Natives `.codebox-workspace`-Format mit relativen, portablen Pfaden. | Nahtlose Verwaltung mehrerer Projektordner in einer Instanz (`Ctrl+Shift+O`). |
172+
| **Projektweite Dateisuche** | Multithreaded Hintergrund-Grep (`core/file_search.py`) mit `Ctrl+Shift+F`. | Schnelle, nicht-blockierende Suche mit Regex-, Wort- und Glob-Filtern. |
171173

172174
---
173175

@@ -180,6 +182,8 @@ sequenceDiagram
180182

181183
## Funktionen
182184

185+
- **Projektweite Dateisuche (Find in Files / Grep-Tool)**: Schnelle Textsuche über alle Projektordner und Dateien (`Ctrl+Shift+F`) mit regulären Ausdrücken, Groß-/Kleinschreibung, Ganzwort-Suche, Glob-Filtern (Einschließen/Ausschließen), asynchronem Hintergrund-Worker und Direktsprung in den Editor.
186+
- **Multi-Root-Arbeitsbereiche**: Verwaltung mehrerer Projektordner in einer gemeinsamen Instanz mit dem `.codebox-workspace`-Format und portablen relativen Pfaden (`Ctrl+Shift+O`).
183187
- **Schnellauswahl & Befehlspalette**: Schneller Datei-Finder (`Ctrl+P`) mit intelligentem Treffer-Scoring und interaktive Befehlspalette (`Ctrl+Shift+P`) zur tastaturgesteuerten IDE-Bedienung.
184188
- **Git-Staging & Commit-Dialog**: Integriertes Git-Staging (`git add`, `git restore --staged`), Verwerfen von Änderungen, Diff-Inspektion und nativer Commit-Dialog (`Ctrl+Alt+C`) direkt aus der Seitenleiste und dem Ansicht-Menü.
185189
- **Integrierter Git Diff-Viewer**: Side-by-Side- und Unified-Diffs direkt in der IDE (`Ctrl+Alt+D`).

core/file_search.py

Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
"""
4+
File Search Engine - Projektweite Textsuche für CodeBox.
5+
6+
Stellt Datenstrukturen, reguläre Suchmuster-Kompilierung, Glob-Filter
7+
und einen asynchronen Such-Worker (QThread) für Multi-Root-Arbeitsbereiche bereit:
8+
- SearchMatch: Repräsentiert einen einzelnen Treffer (Datei, Zeile, Spalte, Text).
9+
- FileSearchResult: Gruppiert alle Treffer einer Datei.
10+
- SearchOptions: Konfigurationsparameter (Regex, Case-Sensitivity, Globs etc.).
11+
- SearchWorker: Hintergrund-Thread für flüssiges Durchsuchen ohne UI-Blockade.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import fnmatch
17+
import os
18+
import re
19+
import time
20+
from dataclasses import dataclass, field
21+
from pathlib import Path
22+
from typing import List, Optional, Set, Tuple
23+
24+
from PySide6.QtCore import QObject, QThread, Signal
25+
26+
from core.workspace import DEFAULT_WORKSPACE_SKIP_DIRS
27+
28+
29+
@dataclass
30+
class SearchMatch:
31+
"""Repräsentiert einen einzelnen Suchtreffer innerhalb einer Datei."""
32+
33+
file_path: Path
34+
rel_path: str
35+
folder_name: str
36+
line_number: int # 1-basiert
37+
column: int # 1-basiert
38+
match_length: int
39+
line_text: str # Ganze Zeile für Vorschau
40+
41+
42+
@dataclass
43+
class FileSearchResult:
44+
"""Gruppiert alle Suchtreffer einer Datei."""
45+
46+
file_path: Path
47+
rel_path: str
48+
folder_name: str
49+
matches: List[SearchMatch] = field(default_factory=list)
50+
51+
52+
@dataclass
53+
class SearchOptions:
54+
"""Optionen für die dateiübergreifende Textsuche."""
55+
56+
query: str
57+
case_sensitive: bool = False
58+
whole_word: bool = False
59+
is_regex: bool = False
60+
include_globs: List[str] = field(default_factory=list)
61+
exclude_globs: List[str] = field(default_factory=list)
62+
max_results: int = 5000
63+
max_file_size_kb: int = 4096
64+
65+
66+
def is_binary_file(file_path: Path) -> bool:
67+
"""Prüft schnell, ob eine Datei binär ist (Null-Byte im Header oder unlesbar)."""
68+
try:
69+
with open(file_path, "rb") as f:
70+
chunk = f.read(1024)
71+
return b"\x00" in chunk
72+
except (OSError, PermissionError):
73+
return True
74+
75+
76+
def compile_search_regex(options: SearchOptions) -> Tuple[Optional[re.Pattern], Optional[str]]:
77+
"""Kompiliert das Suchmuster anhand der Optionen.
78+
79+
Returns:
80+
(pattern, error_message): pattern ist None wenn ungültig, mit Fehlermeldung.
81+
"""
82+
raw_query = options.query
83+
if not raw_query:
84+
return None, "Suchbegriff darf nicht leer sein."
85+
86+
flags = 0 if options.case_sensitive else re.IGNORECASE
87+
88+
if options.is_regex:
89+
pattern_str = raw_query
90+
else:
91+
pattern_str = re.escape(raw_query)
92+
93+
if options.whole_word:
94+
pattern_str = rf"\b{pattern_str}\b"
95+
96+
try:
97+
compiled = re.compile(pattern_str, flags)
98+
return compiled, None
99+
except re.error as e:
100+
return None, f"Ungültiger regulärer Ausdruck: {e}"
101+
102+
103+
def matches_glob_patterns(
104+
filename: str,
105+
rel_path_str: str,
106+
include_globs: List[str],
107+
exclude_globs: List[str],
108+
) -> bool:
109+
"""Prüft, ob eine Datei den Include- und Exclude-Glob-Mustern entspricht."""
110+
# Exclude-Prüfung hat Vorrang
111+
normalized_rel = rel_path_str.replace("\\", "/")
112+
for exc in exclude_globs:
113+
exc = exc.strip()
114+
if not exc:
115+
continue
116+
exc_norm = exc.replace("\\", "/")
117+
if fnmatch.fnmatch(filename, exc) or fnmatch.fnmatch(normalized_rel, exc_norm):
118+
return False
119+
120+
# Include-Prüfung: Falls Muster angegeben, muss mindestens eines passen
121+
active_includes = [inc.strip() for inc in include_globs if inc.strip()]
122+
if active_includes:
123+
matched_any = False
124+
for inc in active_includes:
125+
inc_norm = inc.replace("\\", "/")
126+
if fnmatch.fnmatch(filename, inc) or fnmatch.fnmatch(normalized_rel, inc_norm):
127+
matched_any = True
128+
break
129+
if not matched_any:
130+
return False
131+
132+
return True
133+
134+
135+
def search_file(
136+
file_path: Path,
137+
rel_path: str,
138+
folder_name: str,
139+
pattern: re.Pattern,
140+
max_file_size_kb: int = 4096,
141+
) -> List[SearchMatch]:
142+
"""Durchsucht eine einzelne Datei Zeile für Zeile nach dem gegebenen Suchmuster."""
143+
try:
144+
size = file_path.stat().st_size
145+
if size > max_file_size_kb * 1024:
146+
return []
147+
except (OSError, PermissionError):
148+
return []
149+
150+
if is_binary_file(file_path):
151+
return []
152+
153+
matches: List[SearchMatch] = []
154+
encodings = ["utf-8", "cp1252", "latin-1"]
155+
156+
content = None
157+
for enc in encodings:
158+
try:
159+
with open(file_path, "r", encoding=enc) as f:
160+
content = f.readlines()
161+
break
162+
except (UnicodeDecodeError, OSError):
163+
continue
164+
165+
if content is None:
166+
return []
167+
168+
for line_idx, line in enumerate(content, start=1):
169+
clean_line = line.rstrip("\r\n")
170+
for m in pattern.finditer(clean_line):
171+
col = m.start() + 1
172+
length = max(1, m.end() - m.start())
173+
matches.append(
174+
SearchMatch(
175+
file_path=file_path,
176+
rel_path=rel_path,
177+
folder_name=folder_name,
178+
line_number=line_idx,
179+
column=col,
180+
match_length=length,
181+
line_text=clean_line,
182+
)
183+
)
184+
185+
return matches
186+
187+
188+
class SearchWorker(QThread):
189+
"""Hintergrund-Thread zum Durchsuchen eines oder mehrerer Projektordner.
190+
191+
Signale:
192+
matchFound(object): Emittiert SearchMatch bei jedem Fund.
193+
fileCompleted(object): Emittiert FileSearchResult wenn eine Datei Treffer hatte.
194+
searchProgress(int, int, str): Emittiert (gescannte_Dateien, Gesamtdateien, aktueller_Pfad).
195+
searchFinished(int, int, float): Emittiert (Gesamttreffer, betroffene_Dateien, Laufzeit_Sekunden).
196+
searchError(str): Emittiert eine Fehlermeldung (z. B. fehlerhafter Regex).
197+
"""
198+
199+
matchFound = Signal(object) # SearchMatch
200+
fileCompleted = Signal(object) # FileSearchResult
201+
searchProgress = Signal(int, int, str) # (current, total, file_name)
202+
searchFinished = Signal(int, int, float) # (total_matches, total_files, elapsed_secs)
203+
searchError = Signal(str)
204+
205+
def __init__(
206+
self,
207+
folders: List[Tuple[Path, str]], # Liste aus (Wurzelpfad, Anzeigename)
208+
options: SearchOptions,
209+
skip_dirs: Optional[Set[str]] = None,
210+
parent: Optional[QObject] = None,
211+
):
212+
super().__init__(parent)
213+
self.folders = folders
214+
self.options = options
215+
self.skip_dirs = skip_dirs or DEFAULT_WORKSPACE_SKIP_DIRS
216+
self._is_cancelled = False
217+
218+
def cancel(self):
219+
"""Bricht die Suche ab."""
220+
self._is_cancelled = True
221+
222+
def is_cancelled(self) -> bool:
223+
"""Gibt zurück, ob die Suche abgebrochen wurde."""
224+
return self._is_cancelled
225+
226+
def run(self):
227+
"""Führt den Suchlauf im Hintergrund durch."""
228+
start_time = time.perf_counter()
229+
230+
pattern, err = compile_search_regex(self.options)
231+
if err or pattern is None:
232+
self.searchError.emit(err or "Fehler beim Kompilieren des Suchmusters.")
233+
return
234+
235+
# 1. Dateien einsammeln
236+
files_to_scan: List[Tuple[Path, str, str]] = [] # (abs_path, rel_path, folder_name)
237+
for root_path, folder_name in self.folders:
238+
if self._is_cancelled:
239+
break
240+
if not root_path.exists() or not root_path.is_dir():
241+
continue
242+
243+
for dirpath, dirnames, filenames in os.walk(str(root_path)):
244+
if self._is_cancelled:
245+
break
246+
# Ignorierte Verzeichnisse filtern
247+
dirnames[:] = [
248+
d for d in dirnames
249+
if d not in self.skip_dirs and not d.startswith(".")
250+
]
251+
252+
for fname in filenames:
253+
if fname.startswith(".") and fname != ".gitignore":
254+
continue
255+
abs_p = Path(dirpath) / fname
256+
try:
257+
rel_p = abs_p.relative_to(root_path)
258+
rel_str = str(rel_p).replace("\\", "/")
259+
except ValueError:
260+
rel_str = fname
261+
262+
if matches_glob_patterns(
263+
fname,
264+
rel_str,
265+
self.options.include_globs,
266+
self.options.exclude_globs,
267+
):
268+
files_to_scan.append((abs_p, rel_str, folder_name))
269+
270+
total_files = len(files_to_scan)
271+
total_matches = 0
272+
total_files_with_matches = 0
273+
274+
# 2. Dateien durchsuchen
275+
for idx, (abs_p, rel_str, folder_name) in enumerate(files_to_scan, start=1):
276+
if self._is_cancelled:
277+
break
278+
279+
if idx % 10 == 0 or idx == total_files:
280+
self.searchProgress.emit(idx, total_files, rel_str)
281+
282+
file_matches = search_file(
283+
abs_p,
284+
rel_str,
285+
folder_name,
286+
pattern,
287+
max_file_size_kb=self.options.max_file_size_kb,
288+
)
289+
290+
if file_matches:
291+
total_files_with_matches += 1
292+
total_matches += len(file_matches)
293+
for m in file_matches:
294+
self.matchFound.emit(m)
295+
self.fileCompleted.emit(
296+
FileSearchResult(
297+
file_path=abs_p,
298+
rel_path=rel_str,
299+
folder_name=folder_name,
300+
matches=file_matches,
301+
)
302+
)
303+
304+
if total_matches >= self.options.max_results:
305+
break
306+
307+
elapsed = time.perf_counter() - start_time
308+
self.searchFinished.emit(total_matches, total_files_with_matches, elapsed)

features/project_view.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ class ProjectView(QWidget):
162162
fileDoubleClicked = Signal(object) # Path
163163
diffRequested = Signal(object) # Path
164164
commitRequested = Signal(object) # Optional[Path]
165+
findInFilesRequested = Signal(object) # Optional[Path]
165166

166167
def __init__(self, parent=None):
167168
super().__init__(parent)
@@ -552,6 +553,9 @@ def _on_context_menu(self, position):
552553
act_new_file = menu.addAction("Neue Datei...")
553554
act_new_file.triggered.connect(lambda: self._new_file_in(file_path))
554555

556+
act_find_folder = menu.addAction("In diesem Ordner suchen...")
557+
act_find_folder.triggered.connect(lambda f=file_path: self.findInFilesRequested.emit(f))
558+
555559
menu.addSeparator()
556560
act_add_ws = menu.addAction("Ordner zum Arbeitsbereich hinzufügen")
557561
act_add_ws.triggered.connect(lambda f=file_path: self.add_workspace_folder(f))

0 commit comments

Comments
 (0)