vibefpmgui.window.search

Find and Replace in Files, and the Problems panel.

Also what the lists of places in files (References, Search, Problems) share.

  1"""Find and Replace in Files, and the Problems panel.
  2
  3Also what the lists of places in files (References, Search, Problems) share.
  4"""
  5
  6from __future__ import annotations
  7
  8from pathlib import Path
  9
 10from qtpy.QtCore import QTimer
 11from qtpy.QtGui import QTextCursor
 12from qtpy.QtWidgets import QMessageBox
 13
 14from .. import lsp
 15from ..problems_panel import ProblemsPanel
 16from ..project_search import SearchPanel, search_roots
 17from ..references_panel import ReferencesPanel
 18from ..result_tree import file_labels, root_for
 19from ..source_text import read_source
 20from ..widgets import run_dialog
 21
 22
 23class SearchMixin:
 24    """Find and Replace in Files, and the Problems panel (a part of `vibefpmgui.main_window.MainWindow`)."""
 25
 26    def _build_search_panels(self):
 27        """The References, Search and Problems panels."""
 28        self.references_panel = ReferencesPanel()
 29        # (A result clicked: its file in the preview tab, unless it's open; double-clicked: kept open.)
 30        self.references_panel.open_requested.connect(lambda *match: self._open_search_match(*match, preview=True))
 31        self.references_panel.keep_requested.connect(self._keep_open)
 32        self.search_panel = SearchPanel(self.settings)
 33        self.search_panel.provider = self._search_sources
 34        self.search_panel.open_requested.connect(lambda *match: self._open_search_match(*match, preview=True))
 35        self.search_panel.keep_requested.connect(self._keep_open)
 36        self.search_panel.replace_requested.connect(self._replace_in_files)
 37        self.problems_panel = ProblemsPanel(self.settings)
 38        self.problems_panel.open_requested.connect(self._open_search_match)
 39        self.problems_panel.explain_requested.connect(
 40            lambda problem: self._with_editor(lambda e: self.explain_problem(e, problem)))
 41        self.problems_panel.fix_requested.connect(self.fix_with_ai)
 42        self.problems_panel.apply_fix_requested.connect(self._apply_problem_fix)
 43        # (A burst of changes, e.g. spelling and fortitude answering together: one update.)
 44        self._problems_timer = QTimer(self)
 45        self._problems_timer.setSingleShot(True)
 46        self._problems_timer.setInterval(150)
 47        self._problems_timer.timeout.connect(self._update_problems)
 48        self._problems_visible = False  # (in front of its stack, or on its own: see _problems_shown)
 49
 50    def _link_file_lists(self):
 51        """The lists of places in files (vibefpmgui.result_tree), drawn alike:
 52        Project files' icons, the files' text as the editor has it."""
 53        self.coverage_panel.icon_for = self.project_tree.icon_for
 54        self.result_trees = [self.references_panel.tree, self.search_panel.tree, self.problems_panel.tree]
 55        for tree in self.result_trees:
 56            tree.icon_for = self.project_tree.icon_for
 57            tree.text_for = self._open_text
 58            tree.mono = self._mono_font()
 59            tree.refresh_style()
 60
 61    def find_in_files(self, replace: bool = False):
 62        """Show the Search tab, with the editor's selection to search for."""
 63        text = ""
 64        editor = self.current_editor()
 65        if editor is not None:
 66            selected = editor.editor.textCursor().selectedText()
 67            if selected and "\u2029" not in selected:  # (one line only)
 68                text = selected
 69        self.show_panel(self.search_panel)
 70        self.search_panel.open_find(text, replace)
 71
 72    def _open_text(self, path: Path) -> str | None:
 73        """The text of a file open in the editor (unsaved changes included)."""
 74        editor = self._open_editor_for(path)
 75        return editor.editor.toPlainText() if editor is not None else None
 76
 77    def _search_sources(self):
 78        """For the Search tab: (packages to search, open files' text, project folder)."""
 79        if self.manifest_path is None:
 80            return [], {}, None
 81        project_dir = self.manifest_path.parent
 82        texts = {e.path: e.editor.toPlainText() for e in self.editors() if not e.untitled}
 83        return search_roots(self.graph, project_dir), texts, project_dir
 84
 85    def _problems_shown(self, shown: bool):
 86        """The Problems panel came to the front (or went behind another, or
 87        closed). Qt says so here; isVisible() isn't reliable for a panel
 88        behind another in a stack."""
 89        self._problems_visible = shown
 90        if shown:
 91            self._update_problems()
 92
 93    def _update_problems(self, *_):
 94        """The Problems tab: the file in the editor's problems (only while
 95        it's shown: it catches up when it is)."""
 96        if not hasattr(self, "problems_panel"):
 97            return  # (the window is still being built)
 98        self._problems_timer.stop()
 99        if not self._problems_visible:
100            return
101        editor = self.current_editor()
102        if editor is None:
103            self.problems_panel.show_problems(None, [])
104            return
105        project_dir = self.manifest_path.parent if self.manifest_path else None
106        roots = search_roots(self.graph, project_dir) if project_dir else []
107        folder, package = file_labels(editor.path, root_for(editor.path, roots), project_dir)
108        self.problems_panel.show_problems(editor.path, editor.problems(), folder, package)
109
110    def _apply_problem_fix(self, path, problem):
111        """Problems → right-click → Apply Fix: fortitude's fix, as its hover
112        card's Apply fix does (in the file's tab, which the panel is for)."""
113        editor = next((e for e in self.editors() if e.path == Path(path)), None)
114        if editor is not None and problem.issue is not None:
115            editor.apply_lint_fix(problem.issue)
116
117    def _open_search_match(self, path: str, line: int, column: int, end_line: int, end_column: int,
118                           preview: bool = False):
119        """Open a search result with the match selected (``preview``: in the
120        preview tab, if the file isn't open already)."""
121        self.navigate_to(path, line, column, preview=preview)
122        editor = self.current_editor()
123        if editor is None or editor.path != Path(path).resolve():
124            return
125        doc = editor.editor.document()
126        start, end = doc.findBlockByNumber(line - 1), doc.findBlockByNumber(end_line - 1)
127        if start.isValid() and end.isValid():
128            cursor = editor.editor.textCursor()
129            cursor.setPosition(start.position() + min(column, start.length() - 1))
130            cursor.setPosition(end.position() + min(end_column, end.length() - 1), QTextCursor.KeepAnchor)
131            editor.editor.setTextCursor(cursor)
132
133    def _replace_in_files(self, plan: dict, ask: bool):
134        """Apply Find in Files replacements: open files are changed in the
135        editor (not saved; Undo reverts), the others are saved."""
136        places = sum(len(edits) for edits in plan.values())
137        on_disk = [p for p in plan if self._open_editor_for(p) is None]
138        if ask:
139            files = len(plan)
140            text = (f"Replace {places:,} match{'es' * (places != 1)} in {files:,} "
141                    f"file{'s' * (files != 1)} with “{self.search_panel.replace_edit.text()}”?")
142            info = []
143            if on_disk:
144                info.append(f"{len(on_disk):,} file{'s' * (len(on_disk) != 1)} will be changed and saved "
145                            "(this can't be undone).")
146            if len(on_disk) < files:
147                info.append("Files open in the editor are changed but not saved (Undo reverts them).")
148            box = QMessageBox(QMessageBox.Question, "Replace in Files", text,
149                              QMessageBox.Yes | QMessageBox.Cancel, self)
150            box.setInformativeText(" ".join(info))
151            box.button(QMessageBox.Yes).setText("Replace")
152            if run_dialog(box) != QMessageBox.Yes:
153                return
154        changed_open, written, failed = 0, 0, []
155        for path, edits in plan.items():
156            editor = self._open_editor_for(path)
157            if editor is not None:
158                editor.apply_text_edits(edits)
159                changed_open += 1
160                continue
161            try:
162                text, encoding, newline, _mixed = read_source(path)
163                text = lsp.apply_edits(text, edits)
164                path.write_bytes(text.replace("\n", newline).encode(encoding, errors="replace"))
165            except OSError as exc:
166                failed.append(f"{path}: {exc}")
167                continue
168            self.lsp_client.file_changed_on_disk(path)
169            written += 1
170        if failed:
171            QMessageBox.warning(self, "Replace in Files", "Some files could not be changed:\n\n"
172                                + "\n".join(failed))
173        self.search_panel.refresh_files(plan.keys())
174        parts = []
175        if written:
176            parts.append(f"{written} file{'s' * (written != 1)} saved")
177        if changed_open:
178            parts.append(f"{changed_open} open file{'s' * (changed_open != 1)} changed (not saved yet)")
179        self.statusBar().showMessage(f"Replaced {places:,} match{'es' * (places != 1)}"
180                                     + (f": {', '.join(parts)}" if parts else ""), 10000)
class SearchMixin:
 24class SearchMixin:
 25    """Find and Replace in Files, and the Problems panel (a part of `vibefpmgui.main_window.MainWindow`)."""
 26
 27    def _build_search_panels(self):
 28        """The References, Search and Problems panels."""
 29        self.references_panel = ReferencesPanel()
 30        # (A result clicked: its file in the preview tab, unless it's open; double-clicked: kept open.)
 31        self.references_panel.open_requested.connect(lambda *match: self._open_search_match(*match, preview=True))
 32        self.references_panel.keep_requested.connect(self._keep_open)
 33        self.search_panel = SearchPanel(self.settings)
 34        self.search_panel.provider = self._search_sources
 35        self.search_panel.open_requested.connect(lambda *match: self._open_search_match(*match, preview=True))
 36        self.search_panel.keep_requested.connect(self._keep_open)
 37        self.search_panel.replace_requested.connect(self._replace_in_files)
 38        self.problems_panel = ProblemsPanel(self.settings)
 39        self.problems_panel.open_requested.connect(self._open_search_match)
 40        self.problems_panel.explain_requested.connect(
 41            lambda problem: self._with_editor(lambda e: self.explain_problem(e, problem)))
 42        self.problems_panel.fix_requested.connect(self.fix_with_ai)
 43        self.problems_panel.apply_fix_requested.connect(self._apply_problem_fix)
 44        # (A burst of changes, e.g. spelling and fortitude answering together: one update.)
 45        self._problems_timer = QTimer(self)
 46        self._problems_timer.setSingleShot(True)
 47        self._problems_timer.setInterval(150)
 48        self._problems_timer.timeout.connect(self._update_problems)
 49        self._problems_visible = False  # (in front of its stack, or on its own: see _problems_shown)
 50
 51    def _link_file_lists(self):
 52        """The lists of places in files (vibefpmgui.result_tree), drawn alike:
 53        Project files' icons, the files' text as the editor has it."""
 54        self.coverage_panel.icon_for = self.project_tree.icon_for
 55        self.result_trees = [self.references_panel.tree, self.search_panel.tree, self.problems_panel.tree]
 56        for tree in self.result_trees:
 57            tree.icon_for = self.project_tree.icon_for
 58            tree.text_for = self._open_text
 59            tree.mono = self._mono_font()
 60            tree.refresh_style()
 61
 62    def find_in_files(self, replace: bool = False):
 63        """Show the Search tab, with the editor's selection to search for."""
 64        text = ""
 65        editor = self.current_editor()
 66        if editor is not None:
 67            selected = editor.editor.textCursor().selectedText()
 68            if selected and "\u2029" not in selected:  # (one line only)
 69                text = selected
 70        self.show_panel(self.search_panel)
 71        self.search_panel.open_find(text, replace)
 72
 73    def _open_text(self, path: Path) -> str | None:
 74        """The text of a file open in the editor (unsaved changes included)."""
 75        editor = self._open_editor_for(path)
 76        return editor.editor.toPlainText() if editor is not None else None
 77
 78    def _search_sources(self):
 79        """For the Search tab: (packages to search, open files' text, project folder)."""
 80        if self.manifest_path is None:
 81            return [], {}, None
 82        project_dir = self.manifest_path.parent
 83        texts = {e.path: e.editor.toPlainText() for e in self.editors() if not e.untitled}
 84        return search_roots(self.graph, project_dir), texts, project_dir
 85
 86    def _problems_shown(self, shown: bool):
 87        """The Problems panel came to the front (or went behind another, or
 88        closed). Qt says so here; isVisible() isn't reliable for a panel
 89        behind another in a stack."""
 90        self._problems_visible = shown
 91        if shown:
 92            self._update_problems()
 93
 94    def _update_problems(self, *_):
 95        """The Problems tab: the file in the editor's problems (only while
 96        it's shown: it catches up when it is)."""
 97        if not hasattr(self, "problems_panel"):
 98            return  # (the window is still being built)
 99        self._problems_timer.stop()
100        if not self._problems_visible:
101            return
102        editor = self.current_editor()
103        if editor is None:
104            self.problems_panel.show_problems(None, [])
105            return
106        project_dir = self.manifest_path.parent if self.manifest_path else None
107        roots = search_roots(self.graph, project_dir) if project_dir else []
108        folder, package = file_labels(editor.path, root_for(editor.path, roots), project_dir)
109        self.problems_panel.show_problems(editor.path, editor.problems(), folder, package)
110
111    def _apply_problem_fix(self, path, problem):
112        """Problems → right-click → Apply Fix: fortitude's fix, as its hover
113        card's Apply fix does (in the file's tab, which the panel is for)."""
114        editor = next((e for e in self.editors() if e.path == Path(path)), None)
115        if editor is not None and problem.issue is not None:
116            editor.apply_lint_fix(problem.issue)
117
118    def _open_search_match(self, path: str, line: int, column: int, end_line: int, end_column: int,
119                           preview: bool = False):
120        """Open a search result with the match selected (``preview``: in the
121        preview tab, if the file isn't open already)."""
122        self.navigate_to(path, line, column, preview=preview)
123        editor = self.current_editor()
124        if editor is None or editor.path != Path(path).resolve():
125            return
126        doc = editor.editor.document()
127        start, end = doc.findBlockByNumber(line - 1), doc.findBlockByNumber(end_line - 1)
128        if start.isValid() and end.isValid():
129            cursor = editor.editor.textCursor()
130            cursor.setPosition(start.position() + min(column, start.length() - 1))
131            cursor.setPosition(end.position() + min(end_column, end.length() - 1), QTextCursor.KeepAnchor)
132            editor.editor.setTextCursor(cursor)
133
134    def _replace_in_files(self, plan: dict, ask: bool):
135        """Apply Find in Files replacements: open files are changed in the
136        editor (not saved; Undo reverts), the others are saved."""
137        places = sum(len(edits) for edits in plan.values())
138        on_disk = [p for p in plan if self._open_editor_for(p) is None]
139        if ask:
140            files = len(plan)
141            text = (f"Replace {places:,} match{'es' * (places != 1)} in {files:,} "
142                    f"file{'s' * (files != 1)} with “{self.search_panel.replace_edit.text()}”?")
143            info = []
144            if on_disk:
145                info.append(f"{len(on_disk):,} file{'s' * (len(on_disk) != 1)} will be changed and saved "
146                            "(this can't be undone).")
147            if len(on_disk) < files:
148                info.append("Files open in the editor are changed but not saved (Undo reverts them).")
149            box = QMessageBox(QMessageBox.Question, "Replace in Files", text,
150                              QMessageBox.Yes | QMessageBox.Cancel, self)
151            box.setInformativeText(" ".join(info))
152            box.button(QMessageBox.Yes).setText("Replace")
153            if run_dialog(box) != QMessageBox.Yes:
154                return
155        changed_open, written, failed = 0, 0, []
156        for path, edits in plan.items():
157            editor = self._open_editor_for(path)
158            if editor is not None:
159                editor.apply_text_edits(edits)
160                changed_open += 1
161                continue
162            try:
163                text, encoding, newline, _mixed = read_source(path)
164                text = lsp.apply_edits(text, edits)
165                path.write_bytes(text.replace("\n", newline).encode(encoding, errors="replace"))
166            except OSError as exc:
167                failed.append(f"{path}: {exc}")
168                continue
169            self.lsp_client.file_changed_on_disk(path)
170            written += 1
171        if failed:
172            QMessageBox.warning(self, "Replace in Files", "Some files could not be changed:\n\n"
173                                + "\n".join(failed))
174        self.search_panel.refresh_files(plan.keys())
175        parts = []
176        if written:
177            parts.append(f"{written} file{'s' * (written != 1)} saved")
178        if changed_open:
179            parts.append(f"{changed_open} open file{'s' * (changed_open != 1)} changed (not saved yet)")
180        self.statusBar().showMessage(f"Replaced {places:,} match{'es' * (places != 1)}"
181                                     + (f": {', '.join(parts)}" if parts else ""), 10000)

Find and Replace in Files, and the Problems panel (a part of vibefpmgui.main_window.MainWindow).

def find_in_files(self, replace: bool = False):
62    def find_in_files(self, replace: bool = False):
63        """Show the Search tab, with the editor's selection to search for."""
64        text = ""
65        editor = self.current_editor()
66        if editor is not None:
67            selected = editor.editor.textCursor().selectedText()
68            if selected and "\u2029" not in selected:  # (one line only)
69                text = selected
70        self.show_panel(self.search_panel)
71        self.search_panel.open_find(text, replace)

Show the Search tab, with the editor's selection to search for.