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))
MAX_RESULTS = 10000
@dataclass(frozen=True)
class Query:
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.

Query( text: str, regex: bool = False, case: bool = False, word: bool = False, include: str = '', exclude: str = '', ignored: bool = False, dependencies: bool = True)
text: str
regex: bool = False
case: bool = False
word: bool = False
include: str = ''
exclude: str = ''
ignored: bool = False
dependencies: bool = True
@dataclass
class FileResult:
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)
FileResult( path: pathlib.Path, root: vibefpmgui.result_tree.Root, matches: list[vibefpmgui.result_tree.Match] = <factory>)
path: pathlib.Path
relative: str
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)
def compile_query(query: Query) -> re.Pattern | None:
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.

def search_roots( graph: vibefpmgui.manifest.DependencyGraph | None, project_dir: pathlib.Path) -> list[vibefpmgui.result_tree.Root]:
 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.

def glob_matches(relative: str, globs: list[str]) -> bool:
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).

def iter_files( roots: list[vibefpmgui.result_tree.Root], query: Query, cancelled: Callable[[], bool] = <function <lambda>>):
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).

def is_searched( root: vibefpmgui.result_tree.Root, path: pathlib.Path, query: Query, others: set[pathlib.Path] = frozenset()) -> bool:
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).

def find_matches( text: str, pattern: re.Pattern, limit: int = 10000) -> list[vibefpmgui.result_tree.Match]:
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).

def replacement( found: re.Match, query: Query, template: str) -> str:
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.

def replacement_edits( text: str, pattern: re.Pattern, query: Query, template: str, keep: set[tuple[int, int]] | None = None) -> list[vibefpmgui.lsp.TextEdit]:
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.

def preview_replacement( match_text: str, pattern: re.Pattern, query: Query, template: str) -> str:
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).

class SearchWorker(PySide6.QtCore.QThread):
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.

SearchWorker(roots, query, pattern, open_texts, parent=None)
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.

def found(unknown):

The type of the None singleton.

def progress(unknown):

The type of the None singleton.

def done(unknown):

The type of the None singleton.

def cancel(self):
297    def cancel(self):
298        self._cancel = True
def run(self):
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

staticMetaObject = PySide6.QtCore.QMetaObject("SearchWorker" inherits "QThread": Methods: #12 type=Signal, signature=found(PyObject), parameters=PyObject #13 type=Signal, signature=progress(int), parameters=int #14 type=Signal, signature=done(int,bool), parameters=int, bool )
STOPPED = ('stopped',)
OPTIONS = {'case': 'case_button', 'word': 'word_button', 'regex': 'regex_button', 'dependencies': 'deps_check', 'ignored': 'ignored_check'}
class SearchPanel(PySide6.QtWidgets.QWidget):
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.

SearchPanel(settings=None, parent=None)
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.

def open_requested(unknown):

The type of the None singleton.

def keep_requested(unknown):

The type of the None singleton.

def replace_requested(unknown):

The type of the None singleton.

settings
provider: Callable[[], tuple[list[vibefpmgui.result_tree.Root], dict[pathlib.Path, str], pathlib.Path | None]] | None
replace_toggle
find_edit
case_button
word_button
regex_button
replace_edit
replace_all_button
include_edit
exclude_edit
deps_check
ignored_check
status
stop_button
refresh_button
collapse_button
clear_button
tree
replace_row
def query(self) -> Query:
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())
def open_find(self, text: str = '', replace: bool = False):
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.

def set_replace_visible(self, on: bool):
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()
def preview(self, match_text: str) -> str:
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())
def stop(self):
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.")
def shutdown(self):
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.

ADD_SECONDS = 0.015
def clear(self):
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()
def reset(self):
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).

def file_changed(self, path: pathlib.Path):
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).

def refresh_files(self, paths):
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).

def replace_all(self):
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)
staticMetaObject = PySide6.QtCore.QMetaObject("SearchPanel" inherits "QWidget": Methods: #33 type=Signal, signature=open_requested(QString,int,int,int,int), parameters=QString, int, int, int, int #34 type=Signal, signature=keep_requested(QString), parameters=QString #35 type=Signal, signature=replace_requested(PyObject,bool), parameters=PyObject, bool )