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.