Coverage for vibefpmgui/project_search.py: 87%
627 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
1"""*Find in Files* (⌘/Ctrl+Shift+F): search, and replace, across the project
2and its dependencies.
4The search runs on a worker thread over every text file of the project and
5of the dependencies on disk. Files open in the editor are searched as they
6are in the editor, unsaved changes included. Like the *Project files* tree it
7leaves out fpm's ``build`` folder, ``.git`` and compiled files; files ignored
8by git and hidden files only with *Ignored files* checked.
10Results are grouped by file in the *Search* tab, each line in the colors
11the editor would give it; click one to open the file there. Replacing
12changes open files in the editor (not saved: Undo reverts it) and saves the
13others, like *Rename Symbol*.
14"""
16import fnmatch
17import os
18import re
19import time
20from collections.abc import Callable
21from dataclasses import dataclass, field
22from pathlib import Path
24from qtpy.QtCore import Qt, QThread, QTimer, Signal
25from qtpy.QtWidgets import (
26 QApplication,
27 QCheckBox,
28 QHBoxLayout,
29 QLineEdit,
30 QMenu,
31 QPushButton,
32 QToolButton,
33 QVBoxLayout,
34 QWidget,
35)
37from . import manifest as m
38from .lsp import TextEdit
39from .project_tree import SKIP_DIRS, SKIP_SUFFIXES, GitIgnore
40from .result_tree import (
41 RESULT_ROLE,
42 Match,
43 ResultTree,
44 Root,
45 file_labels,
46 make_match,
47 root_for,
48)
49from .source_text import search_text
50from .widgets import StatusLabel
52MAX_RESULTS = 10_000 # matches shown (the search stops there)
55# ------------------------------------------------------------------ search
56@dataclass(frozen=True)
57class Query:
58 """What to search for, and where."""
59 text: str
60 regex: bool = False
61 case: bool = False # match case
62 word: bool = False # whole words only
63 include: str = "" # comma-separated globs, e.g. "*.f90, src/"
64 exclude: str = ""
65 ignored: bool = False # also files ignored by git, and hidden files
66 dependencies: bool = True
69@dataclass
70class FileResult:
71 path: Path
72 root: Root
73 matches: list[Match] = field(default_factory=list)
75 @property
76 def relative(self) -> str:
77 try:
78 return self.path.relative_to(self.root.directory.resolve()).as_posix()
79 except ValueError:
80 return str(self.path)
83def compile_query(query: Query) -> re.Pattern | None:
84 """The query as a regular expression (None if there's nothing to find).
85 Raises `re.error` for a bad regular expression."""
86 if not query.text:
87 return None
88 pattern = query.text if query.regex else re.escape(query.text)
89 if query.word:
90 pattern = rf"(?<!\w)(?:{pattern})(?!\w)"
91 return re.compile(pattern, re.MULTILINE | (0 if query.case else re.IGNORECASE))
94def search_roots(graph: m.DependencyGraph | None, project_dir: Path) -> list[Root]:
95 """The project folder, then each dependency on disk."""
96 name = project_dir.name
97 if graph is not None and graph.root in graph.packages: 97 ↛ 99line 97 didn't jump to line 99 because the condition on line 97 was always true
98 name = graph.packages[graph.root].name
99 roots, seen = [Root(name, project_dir, False)], {project_dir.resolve()}
100 if graph is not None: 100 ↛ 107line 100 didn't jump to line 107 because the condition on line 100 was always true
101 for pkg in graph.packages.values():
102 if pkg.name == graph.root or pkg.kind in (m.META, m.MISSING):
103 continue
104 if pkg.directory and pkg.directory.is_dir() and pkg.directory.resolve() not in seen:
105 seen.add(pkg.directory.resolve())
106 roots.append(Root(pkg.name, pkg.directory, True))
107 return roots
110def _globs(text: str) -> list[str]:
111 return [g.strip() for g in text.replace(";", ",").split(",") if g.strip()]
114def glob_matches(relative: str, globs: list[str]) -> bool:
115 """Whether a file (its path in its package, "/"-separated) matches one
116 of the globs: "*.f90" (a name anywhere), "src/" or "src" (a folder and
117 everything in it), "src/*.f90" (a path)."""
118 relative = relative.lower() # (*.f90 matches .F90 files too)
119 name = relative.rsplit("/", 1)[-1]
120 for glob in globs:
121 glob = glob.lower().removeprefix("./")
122 folder = glob.rstrip("/")
123 if fnmatch.fnmatch(name, glob) or fnmatch.fnmatch(relative, glob) \
124 or fnmatch.fnmatch(relative, f"*/{glob}") \
125 or fnmatch.fnmatch(relative, f"{folder}/*") or fnmatch.fnmatch(relative, f"*/{folder}/*"):
126 return True
127 return False
130def iter_files(roots: list[Root], query: Query, cancelled: Callable[[], bool] = lambda: False):
131 """(root, path) for every file to search, package by package (the
132 dependencies only with ``query.dependencies``; their folders are left out
133 of the project's even when not searched, e.g. a local dependency in it)."""
134 others = {r.directory.resolve() for r in roots}
135 include, exclude = _globs(query.include), _globs(query.exclude)
136 for root in roots:
137 if root.dependency and not query.dependencies:
138 continue
139 top = root.directory.resolve()
140 rules = GitIgnore(top)
141 for folder, dirs, files in os.walk(top):
142 if cancelled():
143 return
144 here = Path(folder) # (resolved: the walk starts at a resolved folder, and doesn't follow links)
145 keep = []
146 for name in sorted(dirs, key=str.lower):
147 path = here / name
148 if name == ".git" or name in SKIP_DIRS or path in others:
149 continue # (other packages are searched as themselves)
150 if not query.ignored and (name.startswith(".") or rules.check_name(here, name, True)):
151 continue
152 keep.append(name)
153 dirs[:] = keep
154 prefix = here.relative_to(top).as_posix()
155 prefix = "" if prefix == "." else prefix + "/"
156 for name in sorted(files, key=str.lower):
157 suffix = os.path.splitext(name)[1]
158 if suffix in SKIP_SUFFIXES or suffix.lower() in SKIP_SUFFIXES: 158 ↛ 159line 158 didn't jump to line 159 because the condition on line 158 was never true
159 continue
160 if not query.ignored and (name.startswith(".") or rules.check_name(here, name, False)):
161 continue
162 path = here / name
163 relative = prefix + name
164 if include and not glob_matches(relative, include):
165 continue
166 if exclude and glob_matches(relative, exclude):
167 continue
168 yield root, path
171def is_searched(root: Root, path: Path, query: Query, others: set[Path] = frozenset()) -> bool:
172 """Whether `iter_files` would search a file (checked on its own)."""
173 top = root.directory.resolve()
174 try:
175 parts = path.relative_to(top).parts
176 except ValueError:
177 return False
178 rules = GitIgnore(top)
179 folder = top
180 for name in parts[:-1]:
181 folder = folder / name
182 if name == ".git" or name in SKIP_DIRS or folder in others:
183 return False
184 if not query.ignored and (name.startswith(".") or rules.check(folder, True)):
185 return False
186 name = parts[-1]
187 if path.suffix in SKIP_SUFFIXES or path.suffix.lower() in SKIP_SUFFIXES: 187 ↛ 188line 187 didn't jump to line 188 because the condition on line 187 was never true
188 return False
189 if not query.ignored and (name.startswith(".") or rules.check(path, False)): 189 ↛ 190line 189 didn't jump to line 190 because the condition on line 189 was never true
190 return False
191 relative = "/".join(parts)
192 include, exclude = _globs(query.include), _globs(query.exclude)
193 return (not include or glob_matches(relative, include)) and not (exclude and glob_matches(relative, exclude))
196def find_matches(text: str, pattern: re.Pattern, limit: int = MAX_RESULTS) -> list[Match]:
197 """Every match in a text (empty matches are skipped)."""
198 out: list[Match] = []
199 line, last = 0, 0 # (counted incrementally: big files stay quick)
200 for found in pattern.finditer(text):
201 start, end = found.span()
202 if start == end: 202 ↛ 203line 202 didn't jump to line 203 because the condition on line 202 was never true
203 continue
204 line += text.count("\n", last, start)
205 last = start
206 line_start = text.rfind("\n", 0, start) + 1
207 line_end = text.find("\n", start)
208 line_end = len(text) if line_end < 0 else line_end
209 end_line = line + text.count("\n", start, end)
210 end_column = end - (text.rfind("\n", 0, end) + 1)
211 out.append(make_match(text[line_start:line_end], line + 1, start - line_start,
212 end_line + 1, end_column))
213 if len(out) >= limit:
214 break
215 return out
218def replacement(found: re.Match, query: Query, template: str) -> str:
219 """What one match is replaced with: with *regex*, ``\\1`` and
220 ``\\g<name>`` in the template are the match's groups."""
221 return found.expand(template) if query.regex else template
224def replacement_edits(text: str, pattern: re.Pattern, query: Query, template: str,
225 keep: set[tuple[int, int]] | None = None) -> list[TextEdit]:
226 """The edits that replace the matches in a text: all of them, or those
227 starting at the (1-based line, column) positions in ``keep``.
228 Raises `re.error` for a bad template."""
229 edits = []
230 line, line_start, last = 0, 0, 0 # (counted as it goes: recounting from the top was quadratic)
231 for found in pattern.finditer(text):
232 start, end = found.span()
233 if start == end: 233 ↛ 234line 233 didn't jump to line 234 because the condition on line 233 was never true
234 continue
235 newlines = text.count("\n", last, start)
236 if newlines:
237 line += newlines
238 line_start = text.rfind("\n", 0, start) + 1
239 last = start
240 column = start - line_start
241 if keep is not None and (line + 1, column) not in keep:
242 continue
243 end_line = line + text.count("\n", start, end)
244 end_column = end - (text.rfind("\n", 0, end) + 1)
245 edits.append(TextEdit(line, column, end_line, end_column, replacement(found, query, template)))
246 return edits
249def preview_replacement(match_text: str, pattern: re.Pattern, query: Query, template: str) -> str:
250 """The replacement shown in the results list (the match on its own:
251 good enough for a preview)."""
252 if not query.regex:
253 return template
254 try:
255 return pattern.sub(lambda f: f.expand(template), match_text, count=1)
256 except (re.error, IndexError):
257 return template
260def search(roots: list[Root], query: Query, pattern: re.Pattern, open_texts: dict[Path, str],
261 found: Callable[[FileResult], None], cancelled: Callable[[], bool] = lambda: False,
262 limit: int = MAX_RESULTS) -> tuple[int, bool]:
263 """Search the files; ``found`` gets each file with matches.
264 Returns (files searched, whether it stopped at ``limit`` matches)."""
265 searched, total = 0, 0
266 for root, path in iter_files(roots, query, cancelled):
267 if cancelled(): 267 ↛ 268line 267 didn't jump to line 268 because the condition on line 267 was never true
268 break
269 resolved = path.resolve()
270 text = open_texts.get(resolved)
271 if text is None:
272 text = search_text(path)
273 if text is None:
274 continue
275 searched += 1
276 matches = find_matches(text, pattern, limit - total)
277 if matches:
278 total += len(matches)
279 found(FileResult(resolved, root, matches))
280 if total >= limit:
281 return searched, True
282 return searched, False
285class SearchWorker(QThread):
286 """Runs `search` on a thread; results arrive in batches."""
287 found = Signal(object) # list[FileResult]
288 progress = Signal(int) # files searched so far
289 done = Signal(int, bool) # files searched, stopped at the limit
291 def __init__(self, roots, query, pattern, open_texts, parent=None):
292 super().__init__(parent)
293 self.roots, self.query, self.pattern, self.open_texts = roots, query, pattern, open_texts
294 self._cancel = False
296 def cancel(self):
297 self._cancel = True
299 def run(self):
300 batch: list[FileResult] = []
301 last = time.monotonic()
303 def found(result):
304 nonlocal last
305 batch.append(result)
306 if time.monotonic() - last > 0.15: 306 ↛ 307line 306 didn't jump to line 307 because the condition on line 306 was never true
307 self.found.emit(batch[:])
308 batch.clear()
309 last = time.monotonic()
311 searched, truncated = search(self.roots, self.query, self.pattern, self.open_texts, found,
312 lambda: self._cancel)
313 if not self._cancel:
314 if batch: 314 ↛ 316line 314 didn't jump to line 316 because the condition on line 314 was always true
315 self.found.emit(batch)
316 self.done.emit(searched, truncated)
319# ------------------------------------------------------------------- panel
320STOPPED = ("stopped",) # (SearchPanel._ending: stopped, not finished)
321# Saved options: settings key -> SearchPanel attribute.
322OPTIONS = {"case": "case_button", "word": "word_button", "regex": "regex_button",
323 "dependencies": "deps_check", "ignored": "ignored_check"}
325def _toggle(text: str, tip: str) -> QToolButton:
326 button = QToolButton()
327 button.setText(text)
328 button.setToolTip(tip)
329 button.setCheckable(True)
330 button.setAutoRaise(True)
331 # (Shown highlighted when on: an auto-raise button's checked look is faint.)
332 button.setStyleSheet("QToolButton { padding: 2px 5px; border-radius: 4px; }"
333 "QToolButton:checked { background: palette(highlight); "
334 "color: palette(highlighted-text); }")
335 return button
338class SearchPanel(QWidget):
339 """The *Search* tab."""
341 open_requested = Signal(str, int, int, int, int) # file, line, column, end line, end column
342 keep_requested = Signal(str) # a file to keep open (a result double-clicked)
343 replace_requested = Signal(object, bool) # {Path: [TextEdit]}, ask first (Replace All)
345 def __init__(self, settings=None, parent=None):
346 super().__init__(parent)
347 self.settings = settings
348 # Set by the window: () -> (roots to search, {path: text of open editors}, project folder).
349 self.provider: Callable[[], tuple[list[Root], dict[Path, str], Path | None]] | None = None
350 self._results: dict[Path, FileResult] = {}
351 self._query: Query | None = None # what the results are for
352 self._pattern: re.Pattern | None = None
353 self._project_dir: Path | None = None
354 self._worker: SearchWorker | None = None
355 self._old_workers: set[SearchWorker] = set()
356 self._changed: set[Path] = set()
357 # Results not listed yet: added a slice at a time (a batch of a
358 # thousand files at once held the window a tenth of a second).
359 self._incoming: list[FileResult] = []
360 self._ending: tuple | None = None # (the search's end, shown once the last are listed)
361 self._adding = QTimer(self)
362 self._adding.setSingleShot(True)
363 self._adding.setInterval(0)
364 self._adding.timeout.connect(self._add_some)
366 self.replace_toggle = QToolButton()
367 self.replace_toggle.setCheckable(True)
368 self.replace_toggle.setAutoRaise(True)
369 self.replace_toggle.setArrowType(Qt.RightArrow)
370 self.replace_toggle.setToolTip("Show the Replace field")
371 self.find_edit = QLineEdit()
372 self.find_edit.setPlaceholderText("Find in files (⏎ to search)")
373 self.find_edit.setClearButtonEnabled(True)
374 self.case_button = _toggle("Aa", "Match case")
375 self.word_button = _toggle("ab", "Whole words only")
376 font = self.word_button.font()
377 font.setUnderline(True)
378 self.word_button.setFont(font)
379 self.regex_button = _toggle(".*", "Regular expression (Python syntax)")
380 self.replace_edit = QLineEdit()
381 self.replace_edit.setPlaceholderText("Replace with")
382 self.replace_edit.setClearButtonEnabled(True)
383 self.replace_edit.setToolTip("With a regular expression, \\1 or \\g<name> is a group of the match")
384 self.replace_all_button = QPushButton("Replace All")
385 self.replace_all_button.setToolTip("Replace every match in the list (asks first)")
386 self.include_edit = QLineEdit()
387 self.include_edit.setPlaceholderText("Files to include (e.g. *.f90, src/)")
388 self.include_edit.setClearButtonEnabled(True)
389 self.exclude_edit = QLineEdit()
390 self.exclude_edit.setPlaceholderText("Files to exclude (e.g. test/)")
391 self.exclude_edit.setClearButtonEnabled(True)
392 for edit in (self.include_edit, self.exclude_edit):
393 edit.setToolTip("Comma-separated: a name pattern (*.f90), a folder (src/) or a path "
394 "(src/*.f90), in each package")
395 self.deps_check = QCheckBox("Dependencies")
396 self.deps_check.setToolTip("Also search the dependencies' files")
397 self.ignored_check = QCheckBox("Ignored files")
398 self.ignored_check.setToolTip("Also search files ignored by git (.gitignore) and hidden files")
399 self.status = StatusLabel("Type to search the project and its dependencies.")
400 self.status.setContentsMargins(4, 0, 4, 0)
401 self.stop_button = QToolButton()
402 self.stop_button.setText("Stop")
403 self.stop_button.setAutoRaise(True)
404 self.stop_button.hide()
405 self.refresh_button = QToolButton()
406 self.refresh_button.setText("Search Again")
407 self.refresh_button.setToolTip("Search again")
408 self.refresh_button.setAutoRaise(True)
409 self.collapse_button = QToolButton()
410 self.collapse_button.setText("Collapse All")
411 self.collapse_button.setToolTip("Collapse or expand all files")
412 self.collapse_button.setAutoRaise(True)
413 self.clear_button = QToolButton()
414 self.clear_button.setText("Clear")
415 self.clear_button.setToolTip("Clear the results")
416 self.clear_button.setAutoRaise(True)
418 self.tree = ResultTree()
419 self.tree.setContextMenuPolicy(Qt.CustomContextMenu)
420 self.tree.customContextMenuRequested.connect(self._context_menu)
421 self.tree.open_requested.connect(self.open_requested)
422 self.tree.keep_requested.connect(self.keep_requested)
424 find_row = QHBoxLayout()
425 find_row.setSpacing(2)
426 find_row.addWidget(self.replace_toggle)
427 find_row.addWidget(self.find_edit, 1)
428 for button in (self.case_button, self.word_button, self.regex_button):
429 find_row.addWidget(button)
430 self.replace_row = QWidget()
431 replace_row = QHBoxLayout(self.replace_row)
432 replace_row.setContentsMargins(0, 0, 0, 0)
433 replace_row.setSpacing(4)
434 replace_row.addSpacing(self.replace_toggle.sizeHint().width() + 2)
435 replace_row.addWidget(self.replace_edit, 1)
436 replace_row.addWidget(self.replace_all_button)
437 self.replace_row.hide()
438 files_row = QHBoxLayout()
439 files_row.setSpacing(4)
440 files_row.addWidget(self.include_edit, 1)
441 files_row.addWidget(self.exclude_edit, 1)
442 files_row.addSpacing(6)
443 files_row.addWidget(self.deps_check)
444 files_row.addSpacing(6)
445 files_row.addWidget(self.ignored_check)
446 status_row = QHBoxLayout()
447 status_row.setSpacing(2)
448 status_row.addWidget(self.status, 1)
449 for button in (self.stop_button, self.refresh_button, self.collapse_button, self.clear_button):
450 status_row.addWidget(button)
451 layout = QVBoxLayout(self)
452 layout.setContentsMargins(6, 4, 6, 0)
453 layout.setSpacing(4)
454 layout.addLayout(find_row)
455 layout.addWidget(self.replace_row)
456 layout.addLayout(files_row)
457 layout.addLayout(status_row)
458 layout.addWidget(self.tree, 1)
460 self._restore_options()
462 # Search as you type (after a short pause), or at once with Enter.
463 self._timer = QTimer(self)
464 self._timer.setSingleShot(True)
465 self._timer.setInterval(350)
466 self._timer.timeout.connect(self.start_search)
467 self._changed_timer = QTimer(self) # editor changes -> those files again
468 self._changed_timer.setSingleShot(True)
469 self._changed_timer.setInterval(500)
470 self._changed_timer.timeout.connect(self._refresh_changed)
471 self.find_edit.textChanged.connect(lambda: self._timer.start())
472 self.find_edit.returnPressed.connect(self.start_search)
473 for edit in (self.include_edit, self.exclude_edit):
474 edit.textChanged.connect(lambda: self._timer.start())
475 edit.returnPressed.connect(self.start_search)
476 for button in (self.case_button, self.word_button, self.regex_button, self.deps_check,
477 self.ignored_check):
478 button.toggled.connect(self.start_search)
479 self.replace_toggle.toggled.connect(self.set_replace_visible)
480 self.replace_edit.textChanged.connect(self.tree.viewport().update)
481 self.replace_edit.returnPressed.connect(self.replace_all)
482 self.replace_all_button.clicked.connect(self.replace_all)
483 self.stop_button.clicked.connect(self.stop)
484 self.refresh_button.clicked.connect(self.start_search)
485 self.collapse_button.clicked.connect(self._toggle_collapse)
486 self.clear_button.clicked.connect(self.clear)
487 self._update_buttons()
489 # ---------------------------------------------------------- options
490 def _restore_options(self):
491 s = self.settings
492 for key, name in OPTIONS.items():
493 default = key == "dependencies"
494 value = s.value(f"search/{key}", default) if s is not None else default
495 getattr(self, name).setChecked(value in (True, "true", "1", 1))
496 if s is not None: 496 ↛ exitline 496 didn't return from function '_restore_options' because the condition on line 496 was always true
497 self.include_edit.setText(str(s.value("search/include", "") or ""))
498 self.exclude_edit.setText(str(s.value("search/exclude", "") or ""))
500 def _save_options(self):
501 s = self.settings
502 if s is None: 502 ↛ 503line 502 didn't jump to line 503 because the condition on line 502 was never true
503 return
504 for key, name in OPTIONS.items():
505 s.setValue(f"search/{key}", getattr(self, name).isChecked())
506 s.setValue("search/include", self.include_edit.text())
507 s.setValue("search/exclude", self.exclude_edit.text())
509 def query(self) -> Query:
510 return Query(self.find_edit.text(), self.regex_button.isChecked(), self.case_button.isChecked(),
511 self.word_button.isChecked(), self.include_edit.text(), self.exclude_edit.text(),
512 self.ignored_check.isChecked(), self.deps_check.isChecked())
514 # ------------------------------------------------------------- open
515 def open_find(self, text: str = "", replace: bool = False):
516 """Show the search with ``text`` in the field (e.g. the editor's
517 selection), ready to type."""
518 if replace: 518 ↛ 519line 518 didn't jump to line 519 because the condition on line 518 was never true
519 self.replace_toggle.setChecked(True)
520 if text:
521 self.find_edit.setText(text)
522 field = self.replace_edit if replace and self.find_edit.text() else self.find_edit
523 field.setFocus()
524 field.selectAll()
525 if text:
526 self.start_search()
528 def set_replace_visible(self, on: bool):
529 self.replace_toggle.setChecked(on)
530 self.replace_toggle.setArrowType(Qt.DownArrow if on else Qt.RightArrow)
531 self.replace_toggle.setToolTip("Hide the Replace field" if on else "Show the Replace field")
532 self.replace_row.setVisible(on)
533 self.tree.replacement = self.preview if on else None
534 self.tree.viewport().update()
535 self._update_buttons()
537 def preview(self, match_text: str) -> str:
538 if self._pattern is None or self._query is None:
539 return self.replace_edit.text()
540 return preview_replacement(match_text, self._pattern, self._query, self.replace_edit.text())
542 # ----------------------------------------------------------- search
543 def start_search(self):
544 self._timer.stop()
545 self._save_options()
546 self.stop()
547 query = self.query()
548 try:
549 pattern = compile_query(query)
550 except re.error as exc:
551 self._show_error(f"Bad regular expression: {exc}")
552 return
553 self._clear_results()
554 if pattern is None or self.provider is None:
555 self.status.setText("Type to search the project and its dependencies.")
556 self._update_buttons()
557 return
558 roots, open_texts, self._project_dir = self.provider()
559 if not roots:
560 self.status.setText("Open a project to search its files.")
561 return
562 self._query, self._pattern = query, pattern
563 worker = SearchWorker(roots, query, pattern, open_texts, self)
564 worker.found.connect(self._add_results)
565 worker.done.connect(self._finished)
566 worker.finished.connect(lambda w=worker: self._old_workers.discard(w))
567 self._worker = worker
568 self._old_workers.add(worker)
569 self.status.setText("Searching…")
570 self.stop_button.show()
571 worker.start()
573 def stop(self):
574 if self._worker is not None:
575 self._worker.cancel()
576 self._worker.found.disconnect()
577 self._worker.done.disconnect()
578 self._worker = None
579 self.stop_button.hide()
580 if self._incoming: 580 ↛ 581line 580 didn't jump to line 581 because the condition on line 580 was never true
581 self._ending = STOPPED # (those found are still listed, then "stopped")
582 elif self._results: 582 ↛ 583line 582 didn't jump to line 583 because the condition on line 582 was never true
583 self._show_counts(stopped=True)
584 else:
585 self.status.setText("Stopped.")
587 def shutdown(self):
588 """Before the window closes: stop searching."""
589 self.stop()
590 for worker in list(self._old_workers): 590 ↛ 591line 590 didn't jump to line 591 because the loop on line 590 never started
591 worker.cancel()
592 worker.wait(3000)
594 def _show_error(self, text: str):
595 self.status.setText(f"<span style='color:#e03131'>{text}</span>")
597 ADD_SECONDS = 0.015 # listed at a time before the window gets its turn
599 def _add_results(self, results: list[FileResult]):
600 self._incoming += results
601 self._add_some()
603 def _add_some(self):
604 started = time.monotonic()
605 while self._incoming: # (at least one each time: always getting on)
606 result = self._incoming.pop(0)
607 self._results[result.path] = result
608 self._add_file_item(result)
609 if time.monotonic() - started >= self.ADD_SECONDS:
610 break
611 if self._incoming:
612 self._adding.start()
613 self._show_counts(searching=True)
614 elif self._ending == STOPPED: 614 ↛ 615line 614 didn't jump to line 615 because the condition on line 614 was never true
615 self._ending = None
616 self._show_counts(stopped=True)
617 elif self._ending is not None:
618 searched, truncated = self._ending
619 self._ending = None
620 self._searched = searched
621 self._show_counts(truncated=truncated)
622 else:
623 self._show_counts(searching=self._worker is not None)
625 def _finished(self, searched: int, truncated: bool):
626 self._worker = None
627 self.stop_button.hide()
628 self._ending = (searched, truncated)
629 self._add_some()
631 def _show_counts(self, searching=False, truncated=False, stopped=False):
632 matches = sum(len(r.matches) for r in self._results.values())
633 files = len(self._results)
634 if not matches and not searching:
635 text = f"No results ({getattr(self, '_searched', 0):,} files searched)."
636 else:
637 text = (f"{matches:,} result{'s' * (matches != 1)} in {files:,} file{'s' * (files != 1)}")
638 if searching:
639 text += " — searching…"
640 elif truncated: 640 ↛ 641line 640 didn't jump to line 641 because the condition on line 640 was never true
641 text += f" (stopped at {MAX_RESULTS:,}: narrow the search)"
642 elif stopped: 642 ↛ 643line 642 didn't jump to line 643 because the condition on line 642 was never true
643 text += " (stopped)"
644 self.status.setText(text)
645 self._update_buttons()
647 def _update_buttons(self):
648 has = bool(self._results)
649 self.replace_all_button.setEnabled(has and self._worker is None and not self._incoming) # (all listed)
650 self.collapse_button.setEnabled(has)
651 self.clear_button.setEnabled(has)
653 def clear(self):
654 self.stop()
655 self._clear_results()
656 self.status.setText("Type to search the project and its dependencies.")
657 self._update_buttons()
659 def reset(self):
660 """Another project was opened: no search text or results (the options
661 and file patterns are kept)."""
662 self.find_edit.clear()
663 self.replace_edit.clear()
664 self._timer.stop() # (no search for the cleared text)
665 self.clear()
667 def _clear_results(self):
668 self._incoming.clear()
669 self._ending = None
670 self._adding.stop()
671 self.collapse_button.setText("Collapse All")
672 self.tree.clear_results()
673 self._results.clear()
674 self._query = self._pattern = None
676 # ---------------------------------------------------------- results
677 def _add_file_item(self, result: FileResult):
678 self.tree.set_file(result.path, result.matches,
679 *file_labels(result.path, result.root, self._project_dir))
681 def _remove_file(self, path: Path):
682 self._results.pop(path, None)
683 self.tree.remove_file(path)
685 def _toggle_collapse(self):
686 expanded = self.tree.toggle_collapse()
687 self.collapse_button.setText("Collapse All" if expanded else "Expand All")
689 # ---------------------------------------- keeping results up to date
690 def file_changed(self, path: Path):
691 """An open file was edited: search it again shortly (if it's listed,
692 or could be)."""
693 if self._pattern is not None:
694 self._changed.add(Path(path).resolve())
695 self._changed_timer.start()
697 def _refresh_changed(self):
698 if self._worker is not None: # (still searching: try again later) 698 ↛ 699line 698 didn't jump to line 699 because the condition on line 698 was never true
699 self._changed_timer.start()
700 return
701 paths, self._changed = self._changed, set()
702 self.refresh_files(paths)
704 def refresh_files(self, paths):
705 """Search these files again (after they changed)."""
706 if self._pattern is None or self.provider is None: 706 ↛ 707line 706 didn't jump to line 707 because the condition on line 706 was never true
707 return
708 roots, open_texts, _ = self.provider()
709 for path in paths:
710 path = Path(path).resolve()
711 self.tree.forget(path)
712 result = self._results.get(path)
713 if result is None:
714 root = self._root_for(path, roots)
715 if root is None: 715 ↛ 716line 715 didn't jump to line 716 because the condition on line 715 was never true
716 continue
717 result = FileResult(path, root)
718 text = open_texts.get(path)
719 if text is None:
720 text = search_text(path) or ""
721 result.matches = find_matches(text, self._pattern)
722 if not result.matches:
723 self._remove_file(path)
724 else:
725 self._results[path] = result
726 self._add_file_item(result)
727 self._show_counts()
729 def _root_for(self, path: Path, roots: list[Root]) -> Root | None:
730 """The package an open file belongs to, if it would be searched."""
731 if self._query is None: 731 ↛ 732line 731 didn't jump to line 732 because the condition on line 731 was never true
732 return None
733 best = root_for(path, roots)
734 if best is None or (best.dependency and not self._query.dependencies): 734 ↛ 735line 734 didn't jump to line 735 because the condition on line 734 was never true
735 return None
736 others = {r.directory.resolve() for r in roots if r is not best}
737 return best if is_searched(best, path, self._query, others) else None
739 # ---------------------------------------------------------- replace
740 def _plan(self, selection: dict[Path, set[tuple[int, int]] | None]) -> dict[Path, list[TextEdit]] | None:
741 """The edits for the chosen matches (None for all of a file's listed
742 ones), made from the files' current text."""
743 if self._pattern is None or self.provider is None: 743 ↛ 744line 743 didn't jump to line 744 because the condition on line 743 was never true
744 return None
745 _roots, open_texts, _ = self.provider()
746 plan = {}
747 try:
748 for path, keep in selection.items():
749 result = self._results.get(path)
750 if result is None: 750 ↛ 751line 750 didn't jump to line 751 because the condition on line 750 was never true
751 continue
752 if keep is None:
753 keep = {(found.line, found.column) for found in result.matches}
754 text = open_texts.get(path)
755 if text is None:
756 text = search_text(path)
757 if text is None: 757 ↛ 758line 757 didn't jump to line 758 because the condition on line 757 was never true
758 continue
759 edits = replacement_edits(text, self._pattern, self._query, self.replace_edit.text(), keep)
760 if edits: 760 ↛ 748line 760 didn't jump to line 748 because the condition on line 760 was always true
761 plan[path] = edits
762 except re.error as exc:
763 self._show_error(f"Bad replacement: {exc}")
764 return None
765 return plan
767 def replace_all(self):
768 if not self._results or self._worker is not None: 768 ↛ 769line 768 didn't jump to line 769 because the condition on line 768 was never true
769 return
770 plan = self._plan({path: None for path in self._results})
771 if plan: 771 ↛ exitline 771 didn't return from function 'replace_all' because the condition on line 771 was always true
772 self.replace_requested.emit(plan, True)
774 def _replace(self, selection):
775 plan = self._plan(selection)
776 if plan: 776 ↛ exitline 776 didn't return from function '_replace' because the condition on line 776 was always true
777 self.replace_requested.emit(plan, False)
779 def _dismiss(self, path: Path, found: Match | None):
780 result = self._results.get(path)
781 if result is None: 781 ↛ 782line 781 didn't jump to line 782 because the condition on line 781 was never true
782 return
783 if found is not None: 783 ↛ 784line 783 didn't jump to line 784 because the condition on line 783 was never true
784 result.matches = [f for f in result.matches if f != found]
785 if found is None or not result.matches: 785 ↛ 788line 785 didn't jump to line 788 because the condition on line 785 was always true
786 self._remove_file(path)
787 else:
788 self._add_file_item(result)
789 self._show_counts()
791 def _context_menu(self, pos):
792 item = self.tree.itemAt(pos)
793 if item is None:
794 return
795 path, found = item.data(0, RESULT_ROLE)
796 path = Path(path)
797 menu = QMenu(self)
798 menu.addAction("Open", lambda: self.tree.open_item(item))
799 menu.addSeparator()
800 enabled = self._worker is None
801 if found is None:
802 action = menu.addAction("Replace in This File", lambda: self._replace({path: None}))
803 action.setEnabled(enabled)
804 menu.addAction("Dismiss File", lambda: self._dismiss(path, None))
805 else:
806 action = menu.addAction("Replace", lambda: self._replace({path: {(found.line, found.column)}}))
807 action.setEnabled(enabled)
808 menu.addAction("Dismiss", lambda: self._dismiss(path, found))
809 menu.addSeparator()
810 menu.addAction("Copy Path", lambda: QApplication.clipboard().setText(str(path)))
811 if found is not None:
812 menu.addAction("Copy Line", lambda: QApplication.clipboard().setText(
813 found.before + found.text + found.after))
814 menu.exec(self.tree.viewport().mapToGlobal(pos))