1"""A widget for searching git commits"""
2from __future__ import division, absolute_import, unicode_literals
3import time
4
5from qtpy import QtCore
6from qtpy import QtWidgets
7from qtpy.QtCore import Qt
8
9from ..i18n import N_
10from ..interaction import Interaction
11from ..git import STDOUT
12from ..qtutils import connect_button
13from ..qtutils import create_toolbutton
14from ..qtutils import get
15from .. import core
16from .. import gitcmds
17from .. import icons
18from .. import utils
19from .. import qtutils
20from . import diff
21from . import defs
22from . import standard
23
24
25def mkdate(timespec):
26    return '%04d-%02d-%02d' % time.localtime(timespec)[:3]
27
28
29class SearchOptions(object):
30    def __init__(self):
31        self.query = ''
32        self.max_count = 500
33        self.start_date = ''
34        self.end_date = ''
35
36
37class SearchWidget(standard.Dialog):
38    def __init__(self, context, parent):
39        standard.Dialog.__init__(self, parent)
40
41        self.context = context
42        self.setWindowTitle(N_('Search'))
43
44        self.mode_combo = QtWidgets.QComboBox()
45        self.browse_button = create_toolbutton(
46            icon=icons.folder(), tooltip=N_('Browse...')
47        )
48        self.query = QtWidgets.QLineEdit()
49
50        self.start_date = QtWidgets.QDateEdit()
51        self.start_date.setCurrentSection(QtWidgets.QDateTimeEdit.YearSection)
52        self.start_date.setCalendarPopup(True)
53        self.start_date.setDisplayFormat(N_('yyyy-MM-dd'))
54
55        self.end_date = QtWidgets.QDateEdit()
56        self.end_date.setCurrentSection(QtWidgets.QDateTimeEdit.YearSection)
57        self.end_date.setCalendarPopup(True)
58        self.end_date.setDisplayFormat(N_('yyyy-MM-dd'))
59
60        icon = icons.search()
61        self.search_button = qtutils.create_button(
62            text=N_('Search'), icon=icon, default=True
63        )
64        self.max_count = standard.SpinBox(value=500, mini=5, maxi=9995, step=5)
65
66        self.commit_list = QtWidgets.QListWidget()
67        self.commit_list.setMinimumSize(QtCore.QSize(10, 10))
68        self.commit_list.setAlternatingRowColors(True)
69        selection_mode = QtWidgets.QAbstractItemView.SingleSelection
70        self.commit_list.setSelectionMode(selection_mode)
71
72        self.commit_text = diff.DiffTextEdit(context, self, whitespace=False)
73
74        self.button_export = qtutils.create_button(
75            text=N_('Export Patches'), icon=icons.diff()
76        )
77
78        self.button_cherrypick = qtutils.create_button(
79            text=N_('Cherry Pick'), icon=icons.save()
80        )
81        self.button_close = qtutils.close_button()
82
83        self.top_layout = qtutils.hbox(
84            defs.no_margin,
85            defs.button_spacing,
86            self.query,
87            self.start_date,
88            self.end_date,
89            self.browse_button,
90            self.search_button,
91            qtutils.STRETCH,
92            self.mode_combo,
93            self.max_count,
94        )
95
96        self.splitter = qtutils.splitter(
97            Qt.Vertical, self.commit_list, self.commit_text
98        )
99
100        self.bottom_layout = qtutils.hbox(
101            defs.no_margin,
102            defs.spacing,
103            self.button_close,
104            qtutils.STRETCH,
105            self.button_export,
106            self.button_cherrypick,
107        )
108
109        self.main_layout = qtutils.vbox(
110            defs.margin,
111            defs.spacing,
112            self.top_layout,
113            self.splitter,
114            self.bottom_layout,
115        )
116        self.setLayout(self.main_layout)
117
118        self.init_size(parent=parent)
119
120
121def search(context):
122    """Return a callback to handle various search actions."""
123    return search_commits(context, qtutils.active_window())
124
125
126class SearchEngine(object):
127    def __init__(self, context, model):
128        self.context = context
129        self.model = model
130
131    def rev_args(self):
132        max_count = self.model.max_count
133        return {
134            'no_color': True,
135            'max-count': max_count,
136            'pretty': 'format:%H %aN - %s - %ar',
137        }
138
139    def common_args(self):
140        return (self.model.query, self.rev_args())
141
142    def search(self):
143        if self.validate():
144            return self.results()
145        return []
146
147    def validate(self):
148        return len(self.model.query) > 1
149
150    def revisions(self, *args, **kwargs):
151        git = self.context.git
152        revlist = git.log(*args, **kwargs)[STDOUT]
153        return gitcmds.parse_rev_list(revlist)
154
155    def results(self):
156        pass
157
158
159class RevisionSearch(SearchEngine):
160    def results(self):
161        query, opts = self.common_args()
162        args = utils.shell_split(query)
163        return self.revisions(*args, **opts)
164
165
166class PathSearch(SearchEngine):
167    def results(self):
168        query, args = self.common_args()
169        paths = ['--'] + utils.shell_split(query)
170        return self.revisions(all=True, *paths, **args)
171
172
173class MessageSearch(SearchEngine):
174    def results(self):
175        query, kwargs = self.common_args()
176        return self.revisions(all=True, grep=query, **kwargs)
177
178
179class AuthorSearch(SearchEngine):
180    def results(self):
181        query, kwargs = self.common_args()
182        return self.revisions(all=True, author=query, **kwargs)
183
184
185class CommitterSearch(SearchEngine):
186    def results(self):
187        query, kwargs = self.common_args()
188        return self.revisions(all=True, committer=query, **kwargs)
189
190
191class DiffSearch(SearchEngine):
192    def results(self):
193        git = self.context.git
194        query, kwargs = self.common_args()
195        return gitcmds.parse_rev_list(git.log('-S' + query, all=True, **kwargs)[STDOUT])
196
197
198class DateRangeSearch(SearchEngine):
199    def validate(self):
200        return self.model.start_date < self.model.end_date
201
202    def results(self):
203        kwargs = self.rev_args()
204        start_date = self.model.start_date
205        end_date = self.model.end_date
206        return self.revisions(
207            date='iso', all=True, after=start_date, before=end_date, **kwargs
208        )
209
210
211class Search(SearchWidget):
212    def __init__(self, context, model, parent):
213        """
214        Search diffs and commit logs
215
216        :param model: SearchOptions instance
217
218        """
219        SearchWidget.__init__(self, context, parent)
220        self.model = model
221
222        self.EXPR = N_('Search by Expression')
223        self.PATH = N_('Search by Path')
224        self.MESSAGE = N_('Search Commit Messages')
225        self.DIFF = N_('Search Diffs')
226        self.AUTHOR = N_('Search Authors')
227        self.COMMITTER = N_('Search Committers')
228        self.DATE_RANGE = N_('Search Date Range')
229        self.results = []
230
231        # Each search type is handled by a distinct SearchEngine subclass
232        self.engines = {
233            self.EXPR: RevisionSearch,
234            self.PATH: PathSearch,
235            self.MESSAGE: MessageSearch,
236            self.DIFF: DiffSearch,
237            self.AUTHOR: AuthorSearch,
238            self.COMMITTER: CommitterSearch,
239            self.DATE_RANGE: DateRangeSearch,
240        }
241
242        self.modes = (
243            self.EXPR,
244            self.PATH,
245            self.DATE_RANGE,
246            self.DIFF,
247            self.MESSAGE,
248            self.AUTHOR,
249            self.COMMITTER,
250        )
251        self.mode_combo.addItems(self.modes)
252
253        connect_button(self.search_button, self.search_callback)
254        connect_button(self.browse_button, self.browse_callback)
255        connect_button(self.button_export, self.export_patch)
256        connect_button(self.button_cherrypick, self.cherry_pick)
257        connect_button(self.button_close, self.accept)
258
259        # pylint: disable=no-member
260        self.mode_combo.currentIndexChanged.connect(self.mode_changed)
261        self.commit_list.itemSelectionChanged.connect(self.display)
262
263        self.set_start_date(mkdate(time.time() - (87640 * 31)))
264        self.set_end_date(mkdate(time.time() + 87640))
265        self.set_mode(self.EXPR)
266
267        self.query.setFocus()
268
269    def mode_changed(self, _idx):
270        mode = self.mode()
271        self.update_shown_widgets(mode)
272        if mode == self.PATH:
273            self.browse_callback()
274
275    def set_commits(self, commits):
276        widget = self.commit_list
277        widget.clear()
278        widget.addItems(commits)
279
280    def set_start_date(self, datestr):
281        set_date(self.start_date, datestr)
282
283    def set_end_date(self, datestr):
284        set_date(self.end_date, datestr)
285
286    def set_mode(self, mode):
287        idx = self.modes.index(mode)
288        self.mode_combo.setCurrentIndex(idx)
289        self.update_shown_widgets(mode)
290
291    def update_shown_widgets(self, mode):
292        date_shown = mode == self.DATE_RANGE
293        browse_shown = mode == self.PATH
294        self.query.setVisible(not date_shown)
295        self.browse_button.setVisible(browse_shown)
296        self.start_date.setVisible(date_shown)
297        self.end_date.setVisible(date_shown)
298
299    def mode(self):
300        return self.mode_combo.currentText()
301
302    # pylint: disable=unused-argument
303    def search_callback(self, *args):
304        engineclass = self.engines[self.mode()]
305        self.model.query = get(self.query)
306        self.model.max_count = get(self.max_count)
307
308        self.model.start_date = get(self.start_date)
309        self.model.end_date = get(self.end_date)
310
311        self.results = engineclass(self.context, self.model).search()
312        if self.results:
313            self.display_results()
314        else:
315            self.commit_list.clear()
316            self.commit_text.setText('')
317
318    def browse_callback(self):
319        paths = qtutils.open_files(N_('Choose Paths'))
320        if not paths:
321            return
322        filepaths = []
323        curdir = core.getcwd()
324        prefix_len = len(curdir) + 1
325        for path in paths:
326            if not path.startswith(curdir):
327                continue
328            relpath = path[prefix_len:]
329            if relpath:
330                filepaths.append(relpath)
331
332        query = core.list2cmdline(filepaths)
333        self.query.setText(query)
334        if query:
335            self.search_callback()
336
337    def display_results(self):
338        commits = [result[1] for result in self.results]
339        self.set_commits(commits)
340
341    def selected_revision(self):
342        result = qtutils.selected_item(self.commit_list, self.results)
343        return result[0] if result else None
344
345    # pylint: disable=unused-argument
346    def display(self, *args):
347        context = self.context
348        revision = self.selected_revision()
349        if revision is None:
350            self.commit_text.setText('')
351        else:
352            qtutils.set_clipboard(revision)
353            diff_text = gitcmds.commit_diff(context, revision)
354            self.commit_text.setText(diff_text)
355
356    def export_patch(self):
357        context = self.context
358        revision = self.selected_revision()
359        if revision is not None:
360            Interaction.log_status(
361                *gitcmds.export_patchset(context, revision, revision)
362            )
363
364    def cherry_pick(self):
365        git = self.context.git
366        revision = self.selected_revision()
367        if revision is not None:
368            Interaction.log_status(*git.cherry_pick(revision))
369
370
371def set_date(widget, datestr):
372    fmt = Qt.ISODate
373    date = QtCore.QDate.fromString(datestr, fmt)
374    if date:
375        widget.setDate(date)
376
377
378def search_commits(context, parent):
379    opts = SearchOptions()
380    widget = Search(context, opts, parent)
381    widget.show()
382    return widget
383