CHANGES: update v4.4.2 release notes draft for #1368
[git-cola.git] / cola / widgets / grep.py
blob87becdefbdd4d881b3e5f7dd9fac33265b8275ef
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 # pylint: disable=no-member
189 self.input_txt.textChanged.connect(lambda s: self.search())
190 self.regexp_combo.currentIndexChanged.connect(lambda x: self.search())
191 self.result_txt.leave.connect(self.input_txt.setFocus)
192 self.result_txt.cursorPositionChanged.connect(self.update_preview)
194 qtutils.add_action(
195 self.input_txt,
196 'Focus Results',
197 self.focus_results,
198 hotkeys.DOWN,
199 *hotkeys.ACCEPT
201 qtutils.add_action(self, 'Focus Input', self.focus_input, hotkeys.FOCUS)
203 qtutils.connect_toggle(self.shell_checkbox, lambda x: self.search())
204 qtutils.connect_button(self.close_button, self.close)
205 qtutils.add_close_action(self)
207 self.init_size(parent=parent)
209 def focus_input(self):
210 """Focus the grep input field and select the text"""
211 self.input_txt.setFocus()
212 self.input_txt.selectAll()
214 def focus_results(self):
215 """Give focus to the results window"""
216 self.result_txt.setFocus()
218 def regexp_mode(self):
219 """Return the selected grep regex mode"""
220 idx = self.regexp_combo.currentIndex()
221 return self.regexp_combo.itemData(idx, Qt.UserRole)
223 def search(self):
224 """Initiate a search by starting the GrepThread"""
225 self.edit_group.setEnabled(False)
226 self.refresh_group.setEnabled(False)
228 query = get(self.input_txt)
229 if len(query) < 2:
230 self.result_txt.clear()
231 self.preview_txt.clear()
232 return
233 self.worker_thread.query = query
234 self.worker_thread.shell = get(self.shell_checkbox)
235 self.worker_thread.regexp_mode = self.regexp_mode()
236 self.worker_thread.start()
238 def search_for(self, txt):
239 """Set the initial value of the input text"""
240 self.input_txt.set_value(txt)
242 def text_scroll(self):
243 """Return the scrollbar value for the results window"""
244 scrollbar = self.result_txt.verticalScrollBar()
245 if scrollbar:
246 return get(scrollbar)
247 return None
249 def set_text_scroll(self, scroll):
250 """Set the scrollbar value for the results window"""
251 scrollbar = self.result_txt.verticalScrollBar()
252 if scrollbar and scroll is not None:
253 scrollbar.setValue(scroll)
255 def text_offset(self):
256 """Return the cursor's offset within the result text"""
257 return self.result_txt.textCursor().position()
259 def set_text_offset(self, offset):
260 """Set the text cursor from an offset"""
261 cursor = self.result_txt.textCursor()
262 cursor.setPosition(offset)
263 self.result_txt.setTextCursor(cursor)
265 def process_result(self, status, out, err):
266 """Apply the results from grep to the widgets"""
267 if status == 0:
268 value = out + err
269 elif out + err:
270 value = 'git grep: ' + out + err
271 else:
272 value = ''
274 # save scrollbar and text cursor
275 scroll = self.text_scroll()
276 offset = min(len(value), self.text_offset())
278 self.grep_result = value
279 self.result_txt.set_value(value)
280 # restore
281 self.set_text_scroll(scroll)
282 self.set_text_offset(offset)
284 enabled = status == 0
285 self.edit_group.setEnabled(enabled)
286 self.refresh_group.setEnabled(True)
287 if not value:
288 self.preview_txt.clear()
290 def update_preview(self):
291 """Update the file preview window"""
292 parsed_line = parse_grep_line(self.result_txt.selected_line())
293 if parsed_line:
294 filename, line_number, _ = parsed_line
295 self.preview_txt.preview(filename, line_number)
297 def edit(self):
298 """Launch an editor on the currently selected line"""
299 goto_grep(self.context, self.result_txt.selected_line())
301 def export_state(self):
302 """Export persistent settings"""
303 state = super().export_state()
304 state['sizes'] = get(self.splitter)
305 return state
307 def apply_state(self, state):
308 """Apply persistent settings"""
309 result = super().apply_state(state)
310 try:
311 self.splitter.setSizes(state['sizes'])
312 except (AttributeError, KeyError, ValueError, TypeError):
313 result = False
314 return result
317 # pylint: disable=too-many-ancestors
318 class GrepTextView(VimHintedPlainTextEdit):
319 """A text view with hotkeys for launching editors"""
321 def __init__(self, context, hint, parent):
322 VimHintedPlainTextEdit.__init__(self, context, hint, parent=parent)
323 self.context = context
324 self.goto_action = qtutils.add_action(self, 'Launch Editor', self.edit)
325 self.goto_action.setShortcut(hotkeys.EDIT)
326 self.menu_actions.append(self.goto_action)
328 def edit(self):
329 goto_grep(self.context, self.selected_line())
332 class PreviewTask(qtutils.Task):
333 """Asynchronous task for loading file content"""
335 def __init__(self, filename, line_number):
336 qtutils.Task.__init__(self)
338 self.content = ''
339 self.filename = filename
340 self.line_number = line_number
342 def task(self):
343 try:
344 self.content = core.read(self.filename, errors='ignore')
345 except OSError:
346 pass
347 return (self.filename, self.content, self.line_number)
350 # pylint: disable=too-many-ancestors
351 class PreviewTextView(VimTextBrowser):
352 """Preview window for file contents"""
354 def __init__(self, context, parent):
355 VimTextBrowser.__init__(self, context, parent)
356 self.filename = None
357 self.content = None
358 self.runtask = qtutils.RunTask(parent=self)
360 def preview(self, filename, line_number):
361 """Preview the a file at the specified line number"""
363 if filename != self.filename:
364 request = PreviewTask(filename, line_number)
365 self.runtask.start(request, finish=self.show_preview)
366 else:
367 self.scroll_to_line(line_number)
369 def clear(self):
370 self.filename = ''
371 self.content = ''
372 super().clear()
374 def show_preview(self, task):
375 """Show the results of the asynchronous file read"""
377 filename = task.filename
378 content = task.content
379 line_number = task.line_number
381 if filename != self.filename:
382 self.filename = filename
383 self.content = content
384 self.set_value(content)
386 self.scroll_to_line(line_number)
388 def scroll_to_line(self, line_number):
389 """Scroll to the specified line number"""
390 try:
391 line_num = int(line_number) - 1
392 except ValueError:
393 return
395 self.numbers.set_highlighted(line_num)
396 cursor = self.textCursor()
397 cursor.setPosition(0)
398 self.setTextCursor(cursor)
399 self.ensureCursorVisible()
401 cursor.movePosition(cursor.Down, cursor.MoveAnchor, line_num)
402 cursor.movePosition(cursor.EndOfLine, cursor.KeepAnchor)
403 self.setTextCursor(cursor)
404 self.ensureCursorVisible()
406 scrollbar = self.verticalScrollBar()
407 step = scrollbar.pageStep() // 2
408 position = scrollbar.sliderPosition() + step
409 scrollbar.setSliderPosition(position)
410 self.ensureCursorVisible()