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

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) 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) 

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"): 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) 

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: 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) 

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(): 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) 

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: 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)