Merge pull request #1405 from github/pre-commit-ci-update-config
[git-cola.git] / cola / widgets / grep.py
blob9e9df759dfd4b72cd5362a769e606c2d13a43843
1 from qtpy import QtCore
2 from qtpy import QtWidgets
3 from qtpy.QtCore import Qt
4 from qtpy.QtCore import Signal
6 from ..i18n import N_
7 from ..utils import Group
8 from .. import cmds
9 from .. import core
10 from .. import hotkeys
11 from .. import utils
12 from .. import qtutils
13 from ..qtutils import get
14 from .standard import Dialog
15 from .text import HintedLineEdit
16 from .text import VimHintedPlainTextEdit
17 from .text import VimTextBrowser
18 from . import defs
21 def grep(context):
22 """Prompt and use 'git grep' to find the content."""
23 widget = new_grep(context, parent=qtutils.active_window())
24 widget.show()
25 return widget
28 def new_grep(context, text=None, parent=None):
29 """Construct a new Grep dialog"""
30 widget = Grep(context, parent=parent)
31 if text:
32 widget.search_for(text)
33 return widget
36 def parse_grep_line(line):
37 """Parse a grep result line into (filename, line_number, content)"""
38 try:
39 filename, line_number, contents = line.split(':', 2)
40 result = (filename, line_number, contents)
41 except ValueError:
42 result = None
43 return result
46 def goto_grep(context, line):
47 """Called when Search -> Grep's right-click 'goto' action."""
48 parsed_line = parse_grep_line(line)
49 if parsed_line:
50 filename, line_number, _ = parsed_line
51 cmds.do(
52 cmds.Edit,
53 context,
54 [filename],
55 line_number=line_number,
56 background_editor=True,
60 class GrepThread(QtCore.QThread):
61 """Gather `git grep` results in a background thread"""
63 result = Signal(object, object, object)
65 def __init__(self, context, parent):
66 QtCore.QThread.__init__(self, parent)
67 self.context = context
68 self.query = None
69 self.shell = False
70 self.regexp_mode = '--basic-regexp'
72 def run(self):
73 if self.query is None:
74 return
75 git = self.context.git
76 query = self.query
77 if self.shell:
78 args = utils.shell_split(query)
79 else:
80 args = [query]
81 status, out, err = git.grep(self.regexp_mode, n=True, _readonly=True, *args)
82 if query == self.query:
83 self.result.emit(status, out, err)
84 else:
85 self.run()
88 class Grep(Dialog):
89 """A dialog for searching content using `git grep`"""
91 def __init__(self, context, parent=None):
92 Dialog.__init__(self, parent)
93 self.context = context
94 self.grep_result = ''
96 self.setWindowTitle(N_('Search'))
97 if parent is not None:
98 self.setWindowModality(Qt.WindowModal)
100 self.edit_action = qtutils.add_action(self, N_('Edit'), self.edit, hotkeys.EDIT)
102 self.refresh_action = qtutils.add_action(
103 self, N_('Refresh'), self.search, *hotkeys.REFRESH_HOTKEYS
106 self.input_label = QtWidgets.QLabel('git grep')
107 self.input_label.setFont(qtutils.diff_font(context))
109 self.input_txt = HintedLineEdit(
110 context, N_('command-line arguments'), parent=self
113 self.regexp_combo = combo = QtWidgets.QComboBox()
114 combo.setToolTip(N_('Choose the "git grep" regular expression mode'))
115 items = [N_('Basic Regexp'), N_('Extended Regexp'), N_('Fixed String')]
116 combo.addItems(items)
117 combo.setCurrentIndex(0)
118 combo.setEditable(False)
120 tooltip0 = N_('Search using a POSIX basic regular expression')
121 tooltip1 = N_('Search using a POSIX extended regular expression')
122 tooltip2 = N_('Search for a fixed string')
123 combo.setItemData(0, tooltip0, Qt.ToolTipRole)
124 combo.setItemData(1, tooltip1, Qt.ToolTipRole)
125 combo.setItemData(2, tooltip2, Qt.ToolTipRole)
126 combo.setItemData(0, '--basic-regexp', Qt.UserRole)
127 combo.setItemData(1, '--extended-regexp', Qt.UserRole)
128 combo.setItemData(2, '--fixed-strings', Qt.UserRole)
130 self.result_txt = GrepTextView(context, N_('grep result...'), self)
131 self.preview_txt = PreviewTextView(context, self)
132 self.preview_txt.setFocusProxy(self.result_txt)
134 self.edit_button = qtutils.edit_button(default=True)
135 qtutils.button_action(self.edit_button, self.edit_action)
137 self.refresh_button = qtutils.refresh_button()
138 qtutils.button_action(self.refresh_button, self.refresh_action)
140 text = N_('Shell arguments')
141 tooltip = N_(
142 'Parse arguments using a shell.\n'
143 'Queries with spaces will require "double quotes".'
145 self.shell_checkbox = qtutils.checkbox(
146 text=text, tooltip=tooltip, checked=False
148 self.close_button = qtutils.close_button()
150 self.refresh_group = Group(self.refresh_action, self.refresh_button)
151 self.refresh_group.setEnabled(False)
153 self.edit_group = Group(self.edit_action, self.edit_button)
154 self.edit_group.setEnabled(False)
156 self.input_layout = qtutils.hbox(
157 defs.no_margin,
158 defs.button_spacing,
159 self.input_label,
160 self.input_txt,
161 self.regexp_combo,
164 self.bottom_layout = qtutils.hbox(
165 defs.no_margin,
166 defs.button_spacing,
167 self.refresh_button,
168 self.shell_checkbox,
169 qtutils.STRETCH,
170 self.close_button,
171 self.edit_button,
174 self.splitter = qtutils.splitter(Qt.Vertical, self.result_txt, self.preview_txt)
176 self.mainlayout = qtutils.vbox(
177 defs.margin,
178 defs.no_spacing,
179 self.input_layout,
180 self.splitter,
181 self.bottom_layout,
183 self.setLayout(self.mainlayout)
185 thread = self.worker_thread = GrepThread(context, self)
186 thread.result.connect(self.process_result, type=Qt.QueuedConnection)
188 self.input_txt.textChanged.connect(lambda s: self.search())
189 self.regexp_combo.currentIndexChanged.connect(lambda x: self.search())
190 self.result_txt.leave.connect(self.input_txt.setFocus)
191 self.result_txt.cursorPositionChanged.connect(self.update_preview)
193 qtutils.add_action(
194 self.input_txt,
195 'Focus Results',
196 self.focus_results,
197 hotkeys.DOWN,
198 *hotkeys.ACCEPT
200 qtutils.add_action(self, 'Focus Input', self.focus_input, hotkeys.FOCUS)
202 qtutils.connect_toggle(self.shell_checkbox, lambda x: self.search())
203 qtutils.connect_button(self.close_button, self.close)
204 qtutils.add_close_action(self)
206 self.init_size(parent=parent)
208 def focus_input(self):
209 """Focus the grep input field and select the text"""
210 self.input_txt.setFocus()
211 self.input_txt.selectAll()
213 def focus_results(self):
214 """Give focus to the results window"""
215 self.result_txt.setFocus()
217 def regexp_mode(self):
218 """Return the selected grep regex mode"""
219 idx = self.regexp_combo.currentIndex()
220 return self.regexp_combo.itemData(idx, Qt.UserRole)
222 def search(self):
223 """Initiate a search by starting the GrepThread"""
224 self.edit_group.setEnabled(False)
225 self.refresh_group.setEnabled(False)
227 query = get(self.input_txt)
228 if len(query) < 2:
229 self.result_txt.clear()
230 self.preview_txt.clear()
231 return
232 self.worker_thread.query = query
233 self.worker_thread.shell = get(self.shell_checkbox)
234 self.worker_thread.regexp_mode = self.regexp_mode()
235 self.worker_thread.start()
237 def search_for(self, txt):
238 """Set the initial value of the input text"""
239 self.input_txt.set_value(txt)
241 def text_scroll(self):
242 """Return the scrollbar value for the results window"""
243 scrollbar = self.result_txt.verticalScrollBar()
244 if scrollbar:
245 return get(scrollbar)
246 return None
248 def set_text_scroll(self, scroll):
249 """Set the scrollbar value for the results window"""
250 scrollbar = self.result_txt.verticalScrollBar()
251 if scrollbar and scroll is not None:
252 scrollbar.setValue(scroll)
254 def text_offset(self):
255 """Return the cursor's offset within the result text"""
256 return self.result_txt.textCursor().position()
258 def set_text_offset(self, offset):
259 """Set the text cursor from an offset"""
260 cursor = self.result_txt.textCursor()
261 cursor.setPosition(offset)
262 self.result_txt.setTextCursor(cursor)
264 def process_result(self, status, out, err):
265 """Apply the results from grep to the widgets"""
266 if status == 0:
267 value = out + err
268 elif out + err:
269 value = 'git grep: ' + out + err
270 else:
271 value = ''
273 # save scrollbar and text cursor
274 scroll = self.text_scroll()
275 offset = min(len(value), self.text_offset())
277 self.grep_result = value
278 self.result_txt.set_value(value)
279 # restore
280 self.set_text_scroll(scroll)
281 self.set_text_offset(offset)
283 enabled = status == 0
284 self.edit_group.setEnabled(enabled)
285 self.refresh_group.setEnabled(True)
286 if not value:
287 self.preview_txt.clear()
289 def update_preview(self):
290 """Update the file preview window"""
291 parsed_line = parse_grep_line(self.result_txt.selected_line())
292 if parsed_line:
293 filename, line_number, _ = parsed_line
294 self.preview_txt.preview(filename, line_number)
296 def edit(self):
297 """Launch an editor on the currently selected line"""
298 goto_grep(self.context, self.result_txt.selected_line())
300 def export_state(self):
301 """Export persistent settings"""
302 state = super().export_state()
303 state['sizes'] = get(self.splitter)
304 return state
306 def apply_state(self, state):
307 """Apply persistent settings"""
308 result = super().apply_state(state)
309 try:
310 self.splitter.setSizes(state['sizes'])
311 except (AttributeError, KeyError, ValueError, TypeError):
312 result = False
313 return result
316 class GrepTextView(VimHintedPlainTextEdit):
317 """A text view with hotkeys for launching editors"""
319 def __init__(self, context, hint, parent):
320 VimHintedPlainTextEdit.__init__(self, context, hint, parent=parent)
321 self.context = context
322 self.goto_action = qtutils.add_action(self, 'Launch Editor', self.edit)
323 self.goto_action.setShortcut(hotkeys.EDIT)
324 self.menu_actions.append(self.goto_action)
326 def edit(self):
327 goto_grep(self.context, self.selected_line())
330 class PreviewTask(qtutils.Task):
331 """Asynchronous task for loading file content"""
333 def __init__(self, filename, line_number):
334 qtutils.Task.__init__(self)
336 self.content = ''
337 self.filename = filename
338 self.line_number = line_number
340 def task(self):
341 try:
342 self.content = core.read(self.filename, errors='ignore')
343 except OSError:
344 pass
345 return (self.filename, self.content, self.line_number)
348 class PreviewTextView(VimTextBrowser):
349 """Preview window for file contents"""
351 def __init__(self, context, parent):
352 VimTextBrowser.__init__(self, context, parent)
353 self.filename = None
354 self.content = None
355 self.runtask = qtutils.RunTask(parent=self)
357 def preview(self, filename, line_number):
358 """Preview a file at the specified line number"""
360 if filename != self.filename:
361 request = PreviewTask(filename, line_number)
362 self.runtask.start(request, finish=self.show_preview)
363 else:
364 self.scroll_to_line(line_number)
366 def clear(self):
367 self.filename = ''
368 self.content = ''
369 super().clear()
371 def show_preview(self, task):
372 """Show the results of the asynchronous file read"""
374 filename = task.filename
375 content = task.content
376 line_number = task.line_number
378 if filename != self.filename:
379 self.filename = filename
380 self.content = content
381 self.set_value(content)
383 self.scroll_to_line(line_number)
385 def scroll_to_line(self, line_number):
386 """Scroll to the specified line number"""
387 try:
388 line_num = int(line_number) - 1
389 except ValueError:
390 return
392 self.numbers.set_highlighted(line_num)
393 cursor = self.textCursor()
394 cursor.setPosition(0)
395 self.setTextCursor(cursor)
396 self.ensureCursorVisible()
398 cursor.movePosition(cursor.Down, cursor.MoveAnchor, line_num)
399 cursor.movePosition(cursor.EndOfLine, cursor.KeepAnchor)
400 self.setTextCursor(cursor)
401 self.ensureCursorVisible()
403 scrollbar = self.verticalScrollBar()
404 step = scrollbar.pageStep() // 2
405 position = scrollbar.sliderPosition() + step
406 scrollbar.setSliderPosition(position)
407 self.ensureCursorVisible()