|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2007 - 2009 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing a dialog to browse the log history. |
|
8 """ |
|
9 |
|
10 import os |
|
11 |
|
12 import pysvn |
|
13 |
|
14 from PyQt4.QtCore import * |
|
15 from PyQt4.QtGui import * |
|
16 |
|
17 from SvnUtilities import formatTime, dateFromTime_t |
|
18 from SvnDialogMixin import SvnDialogMixin |
|
19 from SvnDiffDialog import SvnDiffDialog |
|
20 |
|
21 from Ui_SvnLogBrowserDialog import Ui_SvnLogBrowserDialog |
|
22 |
|
23 import UI.PixmapCache |
|
24 |
|
25 class SvnLogBrowserDialog(QDialog, SvnDialogMixin, Ui_SvnLogBrowserDialog): |
|
26 """ |
|
27 Class implementing a dialog to browse the log history. |
|
28 """ |
|
29 def __init__(self, vcs, parent = None): |
|
30 """ |
|
31 Constructor |
|
32 |
|
33 @param vcs reference to the vcs object |
|
34 @param parent parent widget (QWidget) |
|
35 """ |
|
36 QDialog.__init__(self, parent) |
|
37 self.setupUi(self) |
|
38 SvnDialogMixin.__init__(self) |
|
39 |
|
40 self.buttonBox.button(QDialogButtonBox.Close).setEnabled(False) |
|
41 self.buttonBox.button(QDialogButtonBox.Cancel).setDefault(True) |
|
42 |
|
43 self.filesTree.headerItem().setText(self.filesTree.columnCount(), "") |
|
44 self.filesTree.header().setSortIndicator(0, Qt.AscendingOrder) |
|
45 |
|
46 self.vcs = vcs |
|
47 |
|
48 self.__maxDate = QDate() |
|
49 self.__minDate = QDate() |
|
50 self.__filterLogsEnabled = True |
|
51 |
|
52 self.fromDate.setDisplayFormat("yyyy-MM-dd") |
|
53 self.toDate.setDisplayFormat("yyyy-MM-dd") |
|
54 self.fromDate.setDate(QDate.currentDate()) |
|
55 self.toDate.setDate(QDate.currentDate()) |
|
56 self.fieldCombo.setCurrentIndex(self.fieldCombo.findText(self.trUtf8("Message"))) |
|
57 self.clearRxEditButton.setIcon(UI.PixmapCache.getIcon("clearLeft.png")) |
|
58 self.limitSpinBox.setValue(self.vcs.getPlugin().getPreferences("LogLimit")) |
|
59 self.stopCheckBox.setChecked(self.vcs.getPlugin().getPreferences("StopLogOnCopy")) |
|
60 |
|
61 self.__messageRole = Qt.UserRole |
|
62 self.__changesRole = Qt.UserRole + 1 |
|
63 |
|
64 self.flags = { |
|
65 'A' : self.trUtf8('Added'), |
|
66 'D' : self.trUtf8('Deleted'), |
|
67 'M' : self.trUtf8('Modified') |
|
68 } |
|
69 |
|
70 self.diff = None |
|
71 self.__lastRev = 0 |
|
72 |
|
73 self.client = self.vcs.getClient() |
|
74 self.client.callback_cancel = \ |
|
75 self._clientCancelCallback |
|
76 self.client.callback_get_login = \ |
|
77 self._clientLoginCallback |
|
78 self.client.callback_ssl_server_trust_prompt = \ |
|
79 self._clientSslServerTrustPromptCallback |
|
80 |
|
81 def _reset(self): |
|
82 """ |
|
83 Protected method to reset the internal state of the dialog. |
|
84 """ |
|
85 SvnDialogMixin._reset(self) |
|
86 |
|
87 self.cancelled = False |
|
88 |
|
89 self.buttonBox.button(QDialogButtonBox.Close).setEnabled(False) |
|
90 self.buttonBox.button(QDialogButtonBox.Cancel).setEnabled(True) |
|
91 self.buttonBox.button(QDialogButtonBox.Cancel).setDefault(True) |
|
92 QApplication.processEvents() |
|
93 |
|
94 def __resizeColumnsLog(self): |
|
95 """ |
|
96 Private method to resize the log tree columns. |
|
97 """ |
|
98 self.logTree.header().resizeSections(QHeaderView.ResizeToContents) |
|
99 self.logTree.header().setStretchLastSection(True) |
|
100 |
|
101 def __resortLog(self): |
|
102 """ |
|
103 Private method to resort the log tree. |
|
104 """ |
|
105 self.logTree.sortItems(self.logTree.sortColumn(), |
|
106 self.logTree.header().sortIndicatorOrder()) |
|
107 |
|
108 def __resizeColumnsFiles(self): |
|
109 """ |
|
110 Private method to resize the changed files tree columns. |
|
111 """ |
|
112 self.filesTree.header().resizeSections(QHeaderView.ResizeToContents) |
|
113 self.filesTree.header().setStretchLastSection(True) |
|
114 |
|
115 def __resortFiles(self): |
|
116 """ |
|
117 Private method to resort the changed files tree. |
|
118 """ |
|
119 sortColumn = self.filesTree.sortColumn() |
|
120 self.filesTree.sortItems(1, |
|
121 self.filesTree.header().sortIndicatorOrder()) |
|
122 self.filesTree.sortItems(sortColumn, |
|
123 self.filesTree.header().sortIndicatorOrder()) |
|
124 |
|
125 def __generateLogItem(self, author, date, message, revision, changedPaths): |
|
126 """ |
|
127 Private method to generate a log tree entry. |
|
128 |
|
129 @param author author info (string) |
|
130 @param date date info (integer) |
|
131 @param message text of the log message (string) |
|
132 @param revision revision info (string or pysvn.opt_revision_kind) |
|
133 @param changedPaths list of pysvn dictionary like objects containing |
|
134 info about the changed files/directories |
|
135 @return reference to the generated item (QTreeWidgetItem) |
|
136 """ |
|
137 if revision == "": |
|
138 rev = "" |
|
139 self.__lastRev = 0 |
|
140 else: |
|
141 rev = "%7d" % revision.number |
|
142 self.__lastRev = revision.number |
|
143 if date == "": |
|
144 dt = "" |
|
145 else: |
|
146 dt = formatTime(date) |
|
147 |
|
148 itm = QTreeWidgetItem(self.logTree, |
|
149 [rev, author, dt," ".join(message.splitlines())] |
|
150 ) |
|
151 |
|
152 changes = [] |
|
153 for changedPath in changedPaths: |
|
154 if changedPath["copyfrom_path"] is None: |
|
155 copyPath = "" |
|
156 else: |
|
157 copyPath = changedPath["copyfrom_path"] |
|
158 if changedPath["copyfrom_revision"] is None: |
|
159 copyRev = "" |
|
160 else: |
|
161 copyRev = "%7d" % changedPath["copyfrom_revision"].number |
|
162 change = { |
|
163 "action" : changedPath["action"], |
|
164 "path" : changedPath["path"], |
|
165 "copyfrom_path" : copyPath, |
|
166 "copyfrom_revision" : copyRev, |
|
167 } |
|
168 changes.append(change) |
|
169 itm.setData(0, self.__messageRole, QVariant(message)) |
|
170 # TODO: change this to simply store the list for QVariant v2 |
|
171 itm.setData(0, self.__changesRole, QVariant(unicode(changes))) |
|
172 |
|
173 itm.setTextAlignment(0, Qt.AlignRight) |
|
174 itm.setTextAlignment(1, Qt.AlignLeft) |
|
175 itm.setTextAlignment(2, Qt.AlignLeft) |
|
176 itm.setTextAlignment(3, Qt.AlignLeft) |
|
177 itm.setTextAlignment(4, Qt.AlignLeft) |
|
178 |
|
179 return itm |
|
180 |
|
181 def __generateFileItem(self, action, path, copyFrom, copyRev): |
|
182 """ |
|
183 Private method to generate a changed files tree entry. |
|
184 |
|
185 @param action indicator for the change action ("A", "D" or "M") |
|
186 @param path path of the file in the repository (string) |
|
187 @param copyFrom path the file was copied from (None, string) |
|
188 @param copyRev revision the file was copied from (None, string) |
|
189 @return reference to the generated item (QTreeWidgetItem) |
|
190 """ |
|
191 itm = QTreeWidgetItem(self.filesTree, |
|
192 [self.flags[action], path, copyFrom, copyRev] |
|
193 ) |
|
194 |
|
195 itm.setTextAlignment(3, Qt.AlignRight) |
|
196 |
|
197 return itm |
|
198 |
|
199 def __getLogEntries(self, startRev = None): |
|
200 """ |
|
201 Private method to retrieve log entries from the repository. |
|
202 |
|
203 @param startRev revision number to start from (integer, string) |
|
204 """ |
|
205 fetchLimit = 10 |
|
206 self._reset() |
|
207 |
|
208 QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) |
|
209 QApplication.processEvents() |
|
210 |
|
211 limit = self.limitSpinBox.value() |
|
212 if startRev is None: |
|
213 start = pysvn.Revision(pysvn.opt_revision_kind.head) |
|
214 else: |
|
215 try: |
|
216 start = pysvn.Revision(pysvn.opt_revision_kind.number, int(startRev)) |
|
217 except TypeError: |
|
218 start = pysvn.Revision(pysvn.opt_revision_kind.head) |
|
219 |
|
220 locker = QMutexLocker(self.vcs.vcsExecutionMutex) |
|
221 cwd = os.getcwd() |
|
222 os.chdir(self.dname) |
|
223 try: |
|
224 fetched = 0 |
|
225 logs = [] |
|
226 while fetched < limit: |
|
227 flimit = min(fetchLimit, limit - fetched) |
|
228 if fetched == 0: |
|
229 revstart = start |
|
230 else: |
|
231 revstart = pysvn.Revision(\ |
|
232 pysvn.opt_revision_kind.number, nextRev) |
|
233 allLogs = self.client.log(self.fname, |
|
234 revision_start = revstart, |
|
235 discover_changed_paths = True, |
|
236 limit = flimit + 1, |
|
237 strict_node_history = self.stopCheckBox.isChecked()) |
|
238 if len(allLogs) <= flimit or self._clientCancelCallback(): |
|
239 logs.extend(allLogs) |
|
240 break |
|
241 else: |
|
242 logs.extend(allLogs[:-1]) |
|
243 nextRev = allLogs[-1]["revision"].number |
|
244 fetched += fetchLimit |
|
245 locker.unlock() |
|
246 |
|
247 for log in logs: |
|
248 self.__generateLogItem(log["author"], log["date"], |
|
249 log["message"], log["revision"], log['changed_paths']) |
|
250 dt = dateFromTime_t(log["date"]) |
|
251 if not self.__maxDate.isValid() and not self.__minDate.isValid(): |
|
252 self.__maxDate = dt |
|
253 self.__minDate = dt |
|
254 else: |
|
255 if self.__maxDate < dt: |
|
256 self.__maxDate = dt |
|
257 if self.__minDate > dt: |
|
258 self.__minDate = dt |
|
259 if len(logs) < limit and not self.cancelled: |
|
260 self.nextButton.setEnabled(False) |
|
261 self.limitSpinBox.setEnabled(False) |
|
262 self.__filterLogsEnabled = False |
|
263 self.fromDate.setMinimumDate(self.__minDate) |
|
264 self.fromDate.setMaximumDate(self.__maxDate) |
|
265 self.fromDate.setDate(self.__minDate) |
|
266 self.toDate.setMinimumDate(self.__minDate) |
|
267 self.toDate.setMaximumDate(self.__maxDate) |
|
268 self.toDate.setDate(self.__maxDate) |
|
269 self.__filterLogsEnabled = True |
|
270 |
|
271 self.__resizeColumnsLog() |
|
272 self.__resortLog() |
|
273 self.__filterLogs() |
|
274 except pysvn.ClientError, e: |
|
275 locker.unlock() |
|
276 self.__showError(e.args[0]) |
|
277 os.chdir(cwd) |
|
278 self.__finish() |
|
279 |
|
280 def start(self, fn): |
|
281 """ |
|
282 Public slot to start the svn log command. |
|
283 |
|
284 @param fn filename to show the log for (string) |
|
285 """ |
|
286 self.filename = fn |
|
287 self.dname, self.fname = self.vcs.splitPath(fn) |
|
288 |
|
289 self.activateWindow() |
|
290 self.raise_() |
|
291 |
|
292 self.logTree.clear() |
|
293 self.__getLogEntries() |
|
294 self.logTree.setCurrentItem(self.logTree.topLevelItem(0)) |
|
295 |
|
296 def __finish(self): |
|
297 """ |
|
298 Private slot called when the user pressed the button. |
|
299 """ |
|
300 QApplication.restoreOverrideCursor() |
|
301 |
|
302 self.buttonBox.button(QDialogButtonBox.Close).setEnabled(True) |
|
303 self.buttonBox.button(QDialogButtonBox.Cancel).setEnabled(False) |
|
304 self.buttonBox.button(QDialogButtonBox.Close).setDefault(True) |
|
305 |
|
306 self._cancel() |
|
307 |
|
308 def __diffRevisions(self, rev1, rev2, peg_rev): |
|
309 """ |
|
310 Private method to do a diff of two revisions. |
|
311 |
|
312 @param rev1 first revision number (integer) |
|
313 @param rev2 second revision number (integer) |
|
314 @param peg_rev revision number to use as a reference (integer) |
|
315 """ |
|
316 if self.diff is None: |
|
317 self.diff = SvnDiffDialog(self.vcs) |
|
318 self.diff.show() |
|
319 QApplication.processEvents() |
|
320 self.diff.start(self.filename, [rev1, rev2], pegRev = peg_rev) |
|
321 |
|
322 def on_buttonBox_clicked(self, button): |
|
323 """ |
|
324 Private slot called by a button of the button box clicked. |
|
325 |
|
326 @param button button that was clicked (QAbstractButton) |
|
327 """ |
|
328 if button == self.buttonBox.button(QDialogButtonBox.Close): |
|
329 self.close() |
|
330 elif button == self.buttonBox.button(QDialogButtonBox.Cancel): |
|
331 self.cancelled = True |
|
332 self.__finish() |
|
333 |
|
334 @pyqtSlot(QTreeWidgetItem, QTreeWidgetItem) |
|
335 def on_logTree_currentItemChanged(self, current, previous): |
|
336 """ |
|
337 Private slot called, when the current item of the log tree changes. |
|
338 |
|
339 @param current reference to the new current item (QTreeWidgetItem) |
|
340 @param previous reference to the old current item (QTreeWidgetItem) |
|
341 """ |
|
342 self.messageEdit.setPlainText(current.data(0, self.__messageRole).toString()) |
|
343 |
|
344 self.filesTree.clear() |
|
345 # TODO: change this for QVariant v2 to not use eval |
|
346 changes = eval(unicode(current.data(0, self.__changesRole).toString())) |
|
347 if len(changes) > 0: |
|
348 for change in changes: |
|
349 self.__generateFileItem(change["action"], change["path"], |
|
350 change["copyfrom_path"], change["copyfrom_revision"]) |
|
351 self.__resizeColumnsFiles() |
|
352 self.__resortFiles() |
|
353 |
|
354 self.diffPreviousButton.setEnabled(\ |
|
355 current != self.logTree.topLevelItem(self.logTree.topLevelItemCount() - 1)) |
|
356 |
|
357 @pyqtSlot() |
|
358 def on_logTree_itemSelectionChanged(self): |
|
359 """ |
|
360 Private slot called, when the selection has changed. |
|
361 """ |
|
362 self.diffRevisionsButton.setEnabled(len(self.logTree.selectedItems()) == 2) |
|
363 |
|
364 @pyqtSlot() |
|
365 def on_nextButton_clicked(self): |
|
366 """ |
|
367 Private slot to handle the Next button. |
|
368 """ |
|
369 if self.__lastRev > 1: |
|
370 self.__getLogEntries(self.__lastRev - 1) |
|
371 |
|
372 @pyqtSlot() |
|
373 def on_diffPreviousButton_clicked(self): |
|
374 """ |
|
375 Private slot to handle the Diff to Previous button. |
|
376 """ |
|
377 itm = self.logTree.topLevelItem(0) |
|
378 if itm is None: |
|
379 self.diffPreviousButton.setEnabled(False) |
|
380 return |
|
381 peg_rev = int(itm.text(0)) |
|
382 |
|
383 itm = self.logTree.currentItem() |
|
384 if itm is None: |
|
385 self.diffPreviousButton.setEnabled(False) |
|
386 return |
|
387 rev2 = int(itm.text(0)) |
|
388 |
|
389 itm = self.logTree.topLevelItem(self.logTree.indexOfTopLevelItem(itm) + 1) |
|
390 if itm is None: |
|
391 self.diffPreviousButton.setEnabled(False) |
|
392 return |
|
393 rev1 = int(itm.text(0)) |
|
394 |
|
395 self.__diffRevisions(rev1, rev2, peg_rev) |
|
396 |
|
397 @pyqtSlot() |
|
398 def on_diffRevisionsButton_clicked(self): |
|
399 """ |
|
400 Private slot to handle the Compare Revisions button. |
|
401 """ |
|
402 items = self.logTree.selectedItems() |
|
403 if len(items) != 2: |
|
404 self.diffRevisionsButton.setEnabled(False) |
|
405 return |
|
406 |
|
407 rev2 = int(items[0].text(0)) |
|
408 rev1 = int(items[1].text(0)) |
|
409 |
|
410 itm = self.logTree.topLevelItem(0) |
|
411 if itm is None: |
|
412 self.diffPreviousButton.setEnabled(False) |
|
413 return |
|
414 peg_rev = int(itm.text(0)) |
|
415 |
|
416 self.__diffRevisions(min(rev1, rev2), max(rev1, rev2), peg_rev) |
|
417 |
|
418 def __showError(self, msg): |
|
419 """ |
|
420 Private slot to show an error message. |
|
421 |
|
422 @param msg error message to show (string) |
|
423 """ |
|
424 QMessageBox.critical(self, |
|
425 self.trUtf8("Subversion Error"), |
|
426 msg) |
|
427 |
|
428 @pyqtSlot(QDate) |
|
429 def on_fromDate_dateChanged(self, date): |
|
430 """ |
|
431 Private slot called, when the from date changes. |
|
432 |
|
433 @param date new date (QDate) |
|
434 """ |
|
435 self.__filterLogs() |
|
436 |
|
437 @pyqtSlot(QDate) |
|
438 def on_toDate_dateChanged(self, date): |
|
439 """ |
|
440 Private slot called, when the from date changes. |
|
441 |
|
442 @param date new date (QDate) |
|
443 """ |
|
444 self.__filterLogs() |
|
445 |
|
446 @pyqtSlot(str) |
|
447 def on_fieldCombo_activated(self, txt): |
|
448 """ |
|
449 Private slot called, when a new filter field is selected. |
|
450 |
|
451 @param txt text of the selected field (string) |
|
452 """ |
|
453 self.__filterLogs() |
|
454 |
|
455 @pyqtSlot(str) |
|
456 def on_rxEdit_textChanged(self, txt): |
|
457 """ |
|
458 Private slot called, when a filter expression is entered. |
|
459 |
|
460 @param txt filter expression (string) |
|
461 """ |
|
462 self.__filterLogs() |
|
463 |
|
464 def __filterLogs(self): |
|
465 """ |
|
466 Private method to filter the log entries. |
|
467 """ |
|
468 if self.__filterLogsEnabled: |
|
469 from_ = self.fromDate.date().toString("yyyy-MM-dd") |
|
470 to_ = self.toDate.date().addDays(1).toString("yyyy-MM-dd") |
|
471 txt = self.fieldCombo.currentText() |
|
472 if txt == self.trUtf8("Author"): |
|
473 fieldIndex = 1 |
|
474 searchRx = QRegExp(self.rxEdit.text(), Qt.CaseInsensitive) |
|
475 elif txt == self.trUtf8("Revision"): |
|
476 fieldIndex = 0 |
|
477 txt = self.rxEdit.text() |
|
478 if txt.startswith("^"): |
|
479 searchRx = QRegExp("^\s*%s" % txt[1:], Qt.CaseInsensitive) |
|
480 else: |
|
481 searchRx = QRegExp(txt, Qt.CaseInsensitive) |
|
482 else: |
|
483 fieldIndex = 3 |
|
484 searchRx = QRegExp(self.rxEdit.text(), Qt.CaseInsensitive) |
|
485 |
|
486 currentItem = self.logTree.currentItem() |
|
487 for topIndex in range(self.logTree.topLevelItemCount()): |
|
488 topItem = self.logTree.topLevelItem(topIndex) |
|
489 if topItem.text(2) <= to_ and topItem.text(2) >= from_ and \ |
|
490 searchRx.indexIn(topItem.text(fieldIndex)) > -1: |
|
491 topItem.setHidden(False) |
|
492 if topItem is currentItem: |
|
493 self.on_logTree_currentItemChanged(topItem, None) |
|
494 else: |
|
495 topItem.setHidden(True) |
|
496 if topItem is currentItem: |
|
497 self.messageEdit.clear() |
|
498 self.filesTree.clear() |
|
499 |
|
500 @pyqtSlot() |
|
501 def on_clearRxEditButton_clicked(self): |
|
502 """ |
|
503 Private slot called by a click of the clear RX edit button. |
|
504 """ |
|
505 self.rxEdit.clear() |
|
506 |
|
507 @pyqtSlot(bool) |
|
508 def on_stopCheckBox_clicked(self, checked): |
|
509 """ |
|
510 Private slot called, when the stop on copy/move checkbox is clicked |
|
511 """ |
|
512 self.vcs.getPlugin().setPreferences("StopLogOnCopy", |
|
513 int(self.stopCheckBox.isChecked())) |
|
514 self.nextButton.setEnabled(True) |
|
515 self.limitSpinBox.setEnabled(True) |