Coverage for vibefpmgui/window/search.py: 92%
133 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
1"""Find and Replace in Files, and the Problems panel.
3Also what the lists of places in files (References, Search, Problems) share.
4"""
6from __future__ import annotations
8from pathlib import Path
10from qtpy.QtCore import QTimer
11from qtpy.QtGui import QTextCursor
12from qtpy.QtWidgets import QMessageBox
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
23class SearchMixin:
24 """Find and Replace in Files, and the Problems panel (a part of `vibefpmgui.main_window.MainWindow`)."""
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)
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()
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) 67 ↛ 69line 67 didn't jump to line 69 because the condition on line 67 was always true
68 text = selected
69 self.show_panel(self.search_panel)
70 self.search_panel.open_find(text, replace)
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
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
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()
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"): 96 ↛ 97line 96 didn't jump to line 97 because the condition on line 96 was never true
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)
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: 114 ↛ exitline 114 didn't return from function '_apply_problem_fix' because the condition on line 114 was always true
115 editor.apply_lint_fix(problem.issue)
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(): 123 ↛ 124line 123 didn't jump to line 124 because the condition on line 123 was never true
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(): 127 ↛ exitline 127 didn't return from function '_open_search_match' because the condition on line 127 was always true
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)
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: 143 ↛ 146line 143 didn't jump to line 146 because the condition on line 143 was always true
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: 170 ↛ 171line 170 didn't jump to line 171 because the condition on line 170 was never true
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: 175 ↛ 177line 175 didn't jump to line 177 because the condition on line 175 was always true
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)