widgets.search: Improve design and usability
[git-cola.git] / cola / widgets / search.py
blob07ebac8b2b0f8b569d701db01358621d41c4e7a1
1 """Provides the SearchView class."""
2 import os
3 import time
4 import subprocess
6 from PyQt4 import QtGui
7 from PyQt4 import QtCore
8 from PyQt4.QtCore import SIGNAL
10 from cola import gitcmds
11 from cola import utils
12 from cola import qtutils
13 from cola.git import git
14 from cola.qt import create_toolbutton
15 from cola.qtutils import connect_button
16 from cola.qtutils import dir_icon
17 from cola.views import standard
18 from cola.widgets import defs
19 from cola.widgets.diff import DiffTextEdit
22 def mkdate(timespec):
23 return '%04d-%02d-%02d' % time.localtime(timespec)[:3]
26 class SearchOptions(object):
27 def __init__(self):
28 self.query = ''
29 self.max_count = 500
30 self.start_date = ''
31 self.end_date = ''
34 class SearchWidget(standard.Dialog):
35 def __init__(self, parent):
36 super(SearchWidget, self).__init__(parent)
37 self.setWindowTitle(self.tr('Search'))
39 self.mode_combo = QtGui.QComboBox()
40 self.browse_button = create_toolbutton(icon=dir_icon(),
41 tooltip='Browse...')
42 self.query = QtGui.QLineEdit()
44 self.start_date = QtGui.QDateEdit()
45 self.start_date.setCurrentSection(QtGui.QDateTimeEdit.YearSection)
46 self.start_date.setCalendarPopup(True)
47 self.start_date.setDisplayFormat(self.tr('yyyy-MM-dd'))
49 self.end_date = QtGui.QDateEdit()
50 self.end_date.setCurrentSection(QtGui.QDateTimeEdit.YearSection)
51 self.end_date.setCalendarPopup(True)
52 self.end_date.setDisplayFormat(self.tr('yyyy-MM-dd'))
54 self.search_button = QtGui.QPushButton()
55 self.search_button.setText(self.tr('Search'))
56 self.search_button.setDefault(True)
58 self.max_count = QtGui.QSpinBox()
59 self.max_count.setMinimum(5)
60 self.max_count.setMaximum(9995)
61 self.max_count.setSingleStep(5)
62 self.max_count.setValue(500)
64 self.commit_list = QtGui.QListWidget()
65 self.commit_list.setMinimumSize(QtCore.QSize(1, 1))
66 self.commit_list.setAlternatingRowColors(True)
67 self.commit_list.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
69 self.commit_text = DiffTextEdit(self, whitespace=False)
70 self.commit_text.setTabChangesFocus(True)
71 self.commit_text.setReadOnly(True)
73 self.button_export = QtGui.QPushButton()
74 self.button_export.setText(self.tr('Export Patches'))
76 self.button_cherrypick = QtGui.QPushButton()
77 self.button_cherrypick.setText(self.tr('Cherry Pick'))
79 self.button_close = QtGui.QPushButton()
80 self.button_close.setText(self.tr('Close'))
82 self.top_layout = QtGui.QHBoxLayout()
83 self.top_layout.setMargin(0)
84 self.top_layout.setSpacing(defs.button_spacing)
86 self.top_layout.addWidget(self.browse_button)
87 self.top_layout.addWidget(self.query)
88 self.top_layout.addWidget(self.start_date)
89 self.top_layout.addWidget(self.end_date)
90 self.top_layout.addWidget(self.search_button)
91 self.top_layout.addStretch()
92 self.top_layout.addWidget(self.mode_combo)
93 self.top_layout.addWidget(self.max_count)
95 self.splitter = QtGui.QSplitter()
96 self.splitter.setHandleWidth(defs.handle_width)
97 self.splitter.setOrientation(QtCore.Qt.Vertical)
98 self.splitter.setChildrenCollapsible(True)
99 self.splitter.addWidget(self.commit_list)
100 self.splitter.addWidget(self.commit_text)
102 self.bottom_layout = QtGui.QHBoxLayout()
103 self.bottom_layout.setMargin(0)
104 self.bottom_layout.setSpacing(defs.spacing)
105 self.bottom_layout.addWidget(self.button_export)
106 self.bottom_layout.addWidget(self.button_cherrypick)
107 self.bottom_layout.addStretch()
108 self.bottom_layout.addWidget(self.button_close)
110 self.main_layout = QtGui.QVBoxLayout()
111 self.main_layout.setMargin(defs.margin)
112 self.main_layout.setSpacing(defs.spacing)
113 self.main_layout.addLayout(self.top_layout)
114 self.main_layout.addWidget(self.splitter)
115 self.main_layout.addLayout(self.bottom_layout)
116 self.setLayout(self.main_layout)
118 self.resize(self.parent().width(), self.parent().height())
121 def search():
122 """Return a callback to handle various search actions."""
123 search_commits(qtutils.active_window())
126 class SearchEngine(object):
127 def __init__(self, model):
128 self.model = model
130 def rev_args(self):
131 max_count = self.model.max_count
132 return {
133 'no_color': True,
134 'max-count': max_count,
135 'pretty': 'format:%H %aN - %s - %ar',
138 def common_args(self):
139 return (self.model.query, self.rev_args())
141 def search(self):
142 if not self.validate():
143 return
144 return self.results()
146 def validate(self):
147 return len(self.model.query) > 1
149 def revisions(self, *args, **kwargs):
150 revlist = git.log(*args, **kwargs)
151 return gitcmds.parse_rev_list(revlist)
153 def results(self):
154 pass
156 class RevisionSearch(SearchEngine):
157 def results(self):
158 query, opts = self.common_args()
159 args = utils.shell_split(query)
160 return self.revisions(all=True, *args, **opts)
163 class PathSearch(SearchEngine):
164 def results(self):
165 query, args = self.common_args()
166 paths = ['--'] + utils.shell_split(query)
167 return self.revisions(all=True, *paths, **args)
170 class MessageSearch(SearchEngine):
171 def results(self):
172 query, kwargs = self.common_args()
173 return self.revisions(all=True, grep=query, **kwargs)
176 class AuthorSearch(SearchEngine):
177 def results(self):
178 query, kwargs = self.common_args()
179 return self.revisions(all=True, author=query, **kwargs)
182 class CommitterSearch(SearchEngine):
183 def results(self):
184 query, kwargs = self.common_args()
185 return self.revisions(all=True, committer=query, **kwargs)
188 class DiffSearch(SearchEngine):
189 def results(self):
190 query, kwargs = self.common_args()
191 return gitcmds.parse_rev_list(
192 git.log('-S'+query, all=True, **kwargs))
195 class DateRangeSearch(SearchEngine):
196 def validate(self):
197 return self.model.start_date < self.model.end_date
199 def results(self):
200 kwargs = self.rev_args()
201 start_date = self.model.start_date
202 end_date = self.model.end_date
203 return self.revisions(date='iso',
204 all=True,
205 after=start_date,
206 before=end_date,
207 **kwargs)
210 class Search(SearchWidget):
211 EXPR = 'Search by Expression'
212 PATH = 'Search by Path'
213 MESSAGE = 'Search Commit Messages'
214 DIFF = 'Search Diffs'
215 AUTHOR = 'Search Authors'
216 COMMITTER = 'Search Committers'
217 DATE_RANGE = 'Search Date Range'
219 def __init__(self, model, parent):
220 super(Search, self).__init__(parent)
221 self.model = model
223 # Each search type is handled by a distinct SearchEngine subclass
224 self.engines = {
225 self.EXPR: RevisionSearch,
226 self.PATH: PathSearch,
227 self.MESSAGE: MessageSearch,
228 self.DIFF: DiffSearch,
229 self.AUTHOR: AuthorSearch,
230 self.COMMITTER: CommitterSearch,
231 self.DATE_RANGE: DateRangeSearch,
234 self.modes = (self.EXPR, self.PATH, self.DATE_RANGE,
235 self.DIFF, self.MESSAGE, self.AUTHOR, self.COMMITTER)
236 self.mode_combo.addItems(self.modes)
238 connect_button(self.search_button, self.search_callback)
239 connect_button(self.browse_button, self.browse_callback)
240 connect_button(self.button_export, self.export_patch)
241 connect_button(self.button_cherrypick, self.cherry_pick)
242 connect_button(self.button_close, self.accept)
244 self.connect(self.mode_combo, SIGNAL('currentIndexChanged(int)'),
245 self.mode_index_changed)
247 self.connect(self.commit_list,
248 SIGNAL('itemSelectionChanged()'),
249 self.display)
251 self.set_start_date(mkdate(time.time()-(87640*31)))
252 self.set_end_date(mkdate(time.time()+87640))
253 self.set_mode(self.EXPR)
255 self.query.setFocus()
257 def mode_index_changed(self, idx):
258 mode = self.mode()
259 self.update_shown_widgets(mode)
260 if mode == self.PATH:
261 self.browse_callback()
263 def set_commit_list(self, commits):
264 widget = self.commit_list
265 widget.clear()
266 widget.addItems(commits)
268 def set_start_date(self, datestr):
269 self.set_date(self.start_date, datestr)
271 def set_end_date(self, datestr):
272 self.set_date(self.end_date, datestr)
274 def set_date(self, widget, datestr):
275 fmt = QtCore.Qt.ISODate
276 date = QtCore.QDate.fromString(datestr, fmt)
277 if date:
278 widget.setDate(date)
280 def set_mode(self, mode):
281 idx = self.modes.index(mode)
282 self.mode_combo.setCurrentIndex(idx)
283 self.update_shown_widgets(mode)
285 def update_shown_widgets(self, mode):
286 date_shown = mode == self.DATE_RANGE
287 browse_shown = mode == self.PATH
288 self.query.setVisible(not date_shown)
289 self.browse_button.setVisible(browse_shown)
290 self.start_date.setVisible(date_shown)
291 self.end_date.setVisible(date_shown)
293 def mode(self):
294 return str(self.mode_combo.currentText())
296 def search_callback(self, *args):
297 engineclass = self.engines[self.mode()]
298 self.model.query = unicode(self.query.text())
299 self.model.max_count = self.max_count.value()
301 fmt = QtCore.Qt.ISODate
302 self.model.start_date = str(self.start_date.date().toString(fmt))
303 self.model.end_date = str(self.end_date.date().toString(fmt))
305 self.results = engineclass(self.model).search()
306 if self.results:
307 self.display_results()
308 else:
309 self.commit_list.clear()
310 self.commit_text.setText('')
312 def browse_callback(self):
313 paths = QtGui.QFileDialog.getOpenFileNames(self,
314 self.tr("Choose Path(s)"))
315 if not paths:
316 return
317 filepaths = []
318 lenprefix = len(os.getcwd()) + 1
319 for path in map(lambda x: unicode(x), paths):
320 if not path.startswith(os.getcwd()):
321 continue
322 filepaths.append(path[lenprefix:])
323 query = subprocess.list2cmdline(filepaths)
324 self.query.setText(query)
325 if query:
326 self.search_callback()
328 def display_results(self):
329 commit_list = map(lambda x: x[1], self.results)
330 self.set_commit_list(commit_list)
332 def display(self, *args):
333 widget = self.commit_list
334 row, selected = qtutils.selected_row(widget)
335 if not selected or len(self.results) < row:
336 self.commit_text.setText('')
337 return
338 revision = self.results[row][0]
339 qtutils.set_clipboard(revision)
340 diff = gitcmds.commit_diff(revision)
341 self.commit_text.setText(diff)
343 def export_patch(self):
344 widget = self.commit_list
345 row, selected = qtutils.selected_row(widget)
346 if not selected or len(self.results) < row:
347 return
348 revision = self.results[row][0]
349 qtutils.log(*self.model.export_patchset(revision, revision))
351 def cherry_pick(self):
352 widget = self.commit_list
353 row, selected = qtutils.selected_row(widget)
354 if not selected or len(self.results) < row:
355 return
356 revision = self.results[row][0]
357 qtutils.log(*git.cherry_pick(revision,
358 with_stderr=True,
359 with_status=True))
361 def search_commits(parent):
362 opts = SearchOptions()
363 widget = Search(opts, parent)
364 widget.show()
368 if __name__ == "__main__":
369 import sys
370 app = QtGui.QApplication(sys.argv)
371 search = Search()
372 search.show()
373 sys.exit(app.exec_())