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