Plugins/VcsPlugins/vcsMercurial/HgLogBrowserDialog.py

changeset 178
dd9f0bca5e2f
child 179
09260f69bf37
equal deleted inserted replaced
177:c822ccc4d138 178:dd9f0bca5e2f
1 # -*- coding: utf-8 -*-
2
3 # Copyright (c) 2010 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 from PyQt4.QtCore import pyqtSlot, SIGNAL, Qt, QDate, QProcess, QTimer, QRegExp
13 from PyQt4.QtGui import QDialog, QDialogButtonBox, QHeaderView, QTreeWidgetItem, \
14 QApplication, QMessageBox, QCursor, QWidget, QLineEdit
15
16 from .Ui_HgLogBrowserDialog import Ui_HgLogBrowserDialog
17 from .HgDiffDialog import HgDiffDialog
18
19 import UI.PixmapCache
20
21 import Preferences
22
23 class HgLogBrowserDialog(QDialog, Ui_HgLogBrowserDialog):
24 """
25 Class implementing a dialog to browse the log history.
26 """
27 def __init__(self, vcs, parent = None):
28 """
29 Constructor
30
31 @param vcs reference to the vcs object
32 @param parent parent widget (QWidget)
33 """
34 QDialog.__init__(self, parent)
35 self.setupUi(self)
36
37 self.buttonBox.button(QDialogButtonBox.Close).setEnabled(False)
38 self.buttonBox.button(QDialogButtonBox.Cancel).setDefault(True)
39
40 self.filesTree.headerItem().setText(self.filesTree.columnCount(), "")
41 self.filesTree.header().setSortIndicator(0, Qt.AscendingOrder)
42
43 self.vcs = vcs
44
45 self.__maxDate = QDate()
46 self.__minDate = QDate()
47 self.__filterLogsEnabled = True
48
49 self.fromDate.setDisplayFormat("yyyy-MM-dd")
50 self.toDate.setDisplayFormat("yyyy-MM-dd")
51 self.fromDate.setDate(QDate.currentDate())
52 self.toDate.setDate(QDate.currentDate())
53 self.fieldCombo.setCurrentIndex(self.fieldCombo.findText(self.trUtf8("Message")))
54 self.clearRxEditButton.setIcon(UI.PixmapCache.getIcon("clearLeft.png"))
55 self.limitSpinBox.setValue(self.vcs.getPlugin().getPreferences("LogLimit"))
56 self.stopCheckBox.setChecked(self.vcs.getPlugin().getPreferences("StopLogOnCopy"))
57
58 self.__messageRole = Qt.UserRole
59 self.__changesRole = Qt.UserRole + 1
60
61 self.process = QProcess()
62 self.connect(self.process, SIGNAL('finished(int, QProcess::ExitStatus)'),
63 self.__procFinished)
64 self.connect(self.process, SIGNAL('readyReadStandardOutput()'),
65 self.__readStdout)
66 self.connect(self.process, SIGNAL('readyReadStandardError()'),
67 self.__readStderr)
68
69 self.flags = {
70 'A' : self.trUtf8('Added'),
71 'D' : self.trUtf8('Deleted'),
72 'M' : self.trUtf8('Modified'),
73 }
74
75 self.buf = [] # buffer for stdout
76 self.diff = None
77 self.__started = False
78 self.__lastRev = 0
79
80 def closeEvent(self, e):
81 """
82 Private slot implementing a close event handler.
83
84 @param e close event (QCloseEvent)
85 """
86 if self.process is not None and \
87 self.process.state() != QProcess.NotRunning:
88 self.process.terminate()
89 QTimer.singleShot(2000, self.process.kill)
90 self.process.waitForFinished(3000)
91
92 e.accept()
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 (string)
131 @param message text of the log message (list of strings)
132 @param revision revision info (string)
133 @param changedPaths list of dictionary objects containing
134 info about the changed files/directories
135 @return reference to the generated item (QTreeWidgetItem)
136 """
137 msg = []
138 for line in message:
139 msg.append(line.strip())
140
141 rev, node = revision.split(":")
142 itm = QTreeWidgetItem(self.logTree, [
143 "{0:>7}:{1}".format(rev, node),
144 author,
145 date,
146 " ".join(msg),
147 ])
148
149 itm.setData(0, self.__messageRole, message)
150 itm.setData(0, self.__changesRole, changedPaths)
151
152 itm.setTextAlignment(0, Qt.AlignLeft)
153 itm.setTextAlignment(1, Qt.AlignLeft)
154 itm.setTextAlignment(2, Qt.AlignLeft)
155 itm.setTextAlignment(3, Qt.AlignLeft)
156 itm.setTextAlignment(4, Qt.AlignLeft)
157
158 try:
159 self.__lastRev = int(revision.split(":")[0])
160 except ValueError:
161 self.__lastRev = 0
162
163 return itm
164
165 def __generateFileItem(self, action, path):
166 """
167 Private method to generate a changed files tree entry.
168
169 @param action indicator for the change action ("A", "D" or "M")
170 @param path path of the file in the repository (string)
171 @return reference to the generated item (QTreeWidgetItem)
172 """
173 itm = QTreeWidgetItem(self.filesTree, [
174 self.flags[action],
175 path,
176 ])
177
178 return itm
179
180 def __getLogEntries(self, startRev = None):
181 """
182 Private method to retrieve log entries from the repository.
183
184 @param startRev revision number to start from (integer, string)
185 """
186 self.buttonBox.button(QDialogButtonBox.Close).setEnabled(False)
187 self.buttonBox.button(QDialogButtonBox.Cancel).setEnabled(True)
188 self.buttonBox.button(QDialogButtonBox.Cancel).setDefault(True)
189 QApplication.processEvents()
190
191 QApplication.setOverrideCursor(QCursor(Qt.WaitCursor))
192 QApplication.processEvents()
193
194 self.intercept = False
195 self.process.kill()
196
197 self.buf = []
198 self.cancelled = False
199 self.errors.clear()
200
201 self.inputGroup.show()
202
203 args = []
204 args.append('log')
205 self.vcs.addArguments(args, self.vcs.options['global'])
206 self.vcs.addArguments(args, self.vcs.options['log'])
207 args.append('--verbose')
208 args.append('--limit')
209 args.append(str(self.limitSpinBox.value()))
210 if startRev is not None:
211 args.append('--rev')
212 args.append('{0}:0'.format(startRev))
213 if not self.stopCheckBox.isChecked():
214 args.append('--follow')
215 args.append('--template')
216 args.append("change|{rev}:{node|short}\n"
217 "user|{author}\n"
218 "date|{date|isodate}\n"
219 "description|{desc}\n"
220 "file_adds|{file_adds}\n"
221 "files_mods|{file_mods}\n"
222 "file_dels|{file_dels}\n"
223 "@@@\n")
224 if self.fname != "." or self.dname != self.repodir:
225 args.append(self.filename)
226
227 self.process.setWorkingDirectory(self.repodir)
228
229 self.process.start('hg', args)
230 procStarted = self.process.waitForStarted()
231 if not procStarted:
232 self.inputGroup.setEnabled(False)
233 QMessageBox.critical(None,
234 self.trUtf8('Process Generation Error'),
235 self.trUtf8(
236 'The process {0} could not be started. '
237 'Ensure, that it is in the search path.'
238 ).format('hg'))
239
240 def start(self, fn):
241 """
242 Public slot to start the svn log command.
243
244 @param fn filename to show the log for (string)
245 """
246 self.errorGroup.hide()
247 QApplication.processEvents()
248
249 self.filename = fn
250 self.dname, self.fname = self.vcs.splitPath(fn)
251
252 # find the root of the repo
253 self.repodir = self.dname
254 while not os.path.isdir(os.path.join(self.repodir, self.vcs.adminDir)):
255 self.repodir = os.path.dirname(self.repodir)
256 if self.repodir == os.sep:
257 return
258
259 self.activateWindow()
260 self.raise_()
261
262 self.logTree.clear()
263 self.__started = True
264 self.__getLogEntries()
265
266 def __procFinished(self, exitCode, exitStatus):
267 """
268 Private slot connected to the finished signal.
269
270 @param exitCode exit code of the process (integer)
271 @param exitStatus exit status of the process (QProcess.ExitStatus)
272 """
273 self.__processBuffer()
274 self.__finish()
275
276 def __finish(self):
277 """
278 Private slot called when the process finished or the user pressed the button.
279 """
280 if self.process is not None and \
281 self.process.state() != QProcess.NotRunning:
282 self.process.terminate()
283 QTimer.singleShot(2000, self.process.kill)
284 self.process.waitForFinished(3000)
285
286 QApplication.restoreOverrideCursor()
287
288 self.buttonBox.button(QDialogButtonBox.Close).setEnabled(True)
289 self.buttonBox.button(QDialogButtonBox.Cancel).setEnabled(False)
290 self.buttonBox.button(QDialogButtonBox.Close).setDefault(True)
291
292 self.inputGroup.setEnabled(False)
293 self.inputGroup.hide()
294
295 def __processBuffer(self):
296 """
297 Private method to process the buffered output of the svn log command.
298 """
299 noEntries = 0
300 log = {"message" : []}
301 changedPaths = []
302 for s in self.buf:
303 if s != "@@@\n":
304 try:
305 key, value = s.split("|", 1)
306 except ValueError:
307 key = ""
308 value = s
309 if key == "change":
310 log["revision"] = value.strip()
311 elif key == "user":
312 log["author"] = value.strip()
313 elif key == "date":
314 log["date"] = " ".join(value.strip().split()[:2])
315 elif key == "description":
316 log["message"].append(value.strip())
317 elif key == "file_adds":
318 if value.strip():
319 for f in value.strip().split():
320 changedPaths.append({\
321 "action" : "A",
322 "path" : f,
323 })
324 elif key == "files_mods":
325 if value.strip():
326 for f in value.strip().split():
327 changedPaths.append({\
328 "action" : "M",
329 "path" : f,
330 })
331 elif key == "file_dels":
332 if value.strip():
333 for f in value.strip().split():
334 changedPaths.append({\
335 "action" : "D",
336 "path" : f,
337 })
338 else:
339 if value.strip():
340 log["message"].append(value.strip())
341 else:
342 if len(log) > 1:
343 self.__generateLogItem(log["author"], log["date"],
344 log["message"], log["revision"], changedPaths)
345 dt = QDate.fromString(log["date"], Qt.ISODate)
346 if not self.__maxDate.isValid() and not self.__minDate.isValid():
347 self.__maxDate = dt
348 self.__minDate = dt
349 else:
350 if self.__maxDate < dt:
351 self.__maxDate = dt
352 if self.__minDate > dt:
353 self.__minDate = dt
354 noEntries += 1
355 log = {"message" : []}
356 changedPaths = []
357
358 self.logTree.doItemsLayout()
359 self.__resizeColumnsLog()
360 self.__resortLog()
361
362 if self.__started:
363 self.logTree.setCurrentItem(self.logTree.topLevelItem(0))
364 self.__started = False
365
366 if noEntries < self.limitSpinBox.value() and not self.cancelled:
367 self.nextButton.setEnabled(False)
368 self.limitSpinBox.setEnabled(False)
369
370 self.__filterLogsEnabled = False
371 self.fromDate.setMinimumDate(self.__minDate)
372 self.fromDate.setMaximumDate(self.__maxDate)
373 self.fromDate.setDate(self.__minDate)
374 self.toDate.setMinimumDate(self.__minDate)
375 self.toDate.setMaximumDate(self.__maxDate)
376 self.toDate.setDate(self.__maxDate)
377 self.__filterLogsEnabled = True
378 self.__filterLogs()
379
380 def __readStdout(self):
381 """
382 Private slot to handle the readyReadStandardOutput signal.
383
384 It reads the output of the process and inserts it into a buffer.
385 """
386 self.process.setReadChannel(QProcess.StandardOutput)
387
388 while self.process.canReadLine():
389 line = str(self.process.readLine(),
390 Preferences.getSystem("IOEncoding"),
391 'replace')
392 self.buf.append(line)
393
394 def __readStderr(self):
395 """
396 Private slot to handle the readyReadStandardError signal.
397
398 It reads the error output of the process and inserts it into the
399 error pane.
400 """
401 if self.process is not None:
402 self.errorGroup.show()
403 s = str(self.process.readAllStandardError(),
404 Preferences.getSystem("IOEncoding"),
405 'replace')
406 self.errors.insertPlainText(s)
407 self.errors.ensureCursorVisible()
408
409 def __diffRevisions(self, rev1, rev2):
410 """
411 Private method to do a diff of two revisions.
412
413 @param rev1 first revision number (integer)
414 @param rev2 second revision number (integer)
415 """
416 if self.diff:
417 self.diff.close()
418 del self.diff
419 self.diff = HgDiffDialog(self.vcs)
420 self.diff.show()
421 self.diff.start(self.filename, [rev1, rev2])
422
423 def on_buttonBox_clicked(self, button):
424 """
425 Private slot called by a button of the button box clicked.
426
427 @param button button that was clicked (QAbstractButton)
428 """
429 if button == self.buttonBox.button(QDialogButtonBox.Close):
430 self.close()
431 elif button == self.buttonBox.button(QDialogButtonBox.Cancel):
432 self.cancelled = True
433 self.__finish()
434
435 @pyqtSlot(QTreeWidgetItem, QTreeWidgetItem)
436 def on_logTree_currentItemChanged(self, current, previous):
437 """
438 Private slot called, when the current item of the log tree changes.
439
440 @param current reference to the new current item (QTreeWidgetItem)
441 @param previous reference to the old current item (QTreeWidgetItem)
442 """
443 self.messageEdit.clear()
444 for line in current.data(0, self.__messageRole):
445 self.messageEdit.append(line.strip())
446
447 self.filesTree.clear()
448 changes = current.data(0, self.__changesRole)
449 if len(changes) > 0:
450 for change in changes:
451 self.__generateFileItem(change["action"], change["path"])
452 self.__resizeColumnsFiles()
453 self.__resortFiles()
454
455 self.diffPreviousButton.setEnabled(
456 current != self.logTree.topLevelItem(self.logTree.topLevelItemCount() - 1))
457
458 @pyqtSlot()
459 def on_logTree_itemSelectionChanged(self):
460 """
461 Private slot called, when the selection has changed.
462 """
463 self.diffRevisionsButton.setEnabled(len(self.logTree.selectedItems()) == 2)
464
465 @pyqtSlot()
466 def on_nextButton_clicked(self):
467 """
468 Private slot to handle the Next button.
469 """
470 if self.__lastRev > 1:
471 self.__getLogEntries(self.__lastRev - 1)
472
473 @pyqtSlot()
474 def on_diffPreviousButton_clicked(self):
475 """
476 Private slot to handle the Diff to Previous button.
477 """
478 itm = self.logTree.currentItem()
479 if itm is None:
480 self.diffPreviousButton.setEnabled(False)
481 return
482 rev2 = int(itm.text(0).split(":")[0])
483
484 itm = self.logTree.topLevelItem(self.logTree.indexOfTopLevelItem(itm) + 1)
485 if itm is None:
486 self.diffPreviousButton.setEnabled(False)
487 return
488 rev1 = int(itm.text(0).split(":")[0])
489
490 self.__diffRevisions(rev1, rev2)
491
492 @pyqtSlot()
493 def on_diffRevisionsButton_clicked(self):
494 """
495 Private slot to handle the Compare Revisions button.
496 """
497 items = self.logTree.selectedItems()
498 if len(items) != 2:
499 self.diffRevisionsButton.setEnabled(False)
500 return
501
502 rev2 = int(items[0].text(0).split(":")[0])
503 rev1 = int(items[1].text(0).split(":")[0])
504
505 self.__diffRevisions(min(rev1, rev2), max(rev1, rev2))
506
507 @pyqtSlot(QDate)
508 def on_fromDate_dateChanged(self, date):
509 """
510 Private slot called, when the from date changes.
511
512 @param date new date (QDate)
513 """
514 self.__filterLogs()
515
516 @pyqtSlot(QDate)
517 def on_toDate_dateChanged(self, date):
518 """
519 Private slot called, when the from date changes.
520
521 @param date new date (QDate)
522 """
523 self.__filterLogs()
524
525 @pyqtSlot(str)
526 def on_fieldCombo_activated(self, txt):
527 """
528 Private slot called, when a new filter field is selected.
529
530 @param txt text of the selected field (string)
531 """
532 self.__filterLogs()
533
534 @pyqtSlot(str)
535 def on_rxEdit_textChanged(self, txt):
536 """
537 Private slot called, when a filter expression is entered.
538
539 @param txt filter expression (string)
540 """
541 self.__filterLogs()
542
543 def __filterLogs(self):
544 """
545 Private method to filter the log entries.
546 """
547 if self.__filterLogsEnabled:
548 from_ = self.fromDate.date().toString("yyyy-MM-dd")
549 to_ = self.toDate.date().addDays(1).toString("yyyy-MM-dd")
550 txt = self.fieldCombo.currentText()
551 if txt == self.trUtf8("Author"):
552 fieldIndex = 1
553 searchRx = QRegExp(self.rxEdit.text(), Qt.CaseInsensitive)
554 elif txt == self.trUtf8("Revision"):
555 fieldIndex = 0
556 txt = self.rxEdit.text()
557 if txt.startswith("^"):
558 searchRx = QRegExp("^\s*%s" % txt[1:], Qt.CaseInsensitive)
559 else:
560 searchRx = QRegExp(txt, Qt.CaseInsensitive)
561 else:
562 fieldIndex = 3
563 searchRx = QRegExp(self.rxEdit.text(), Qt.CaseInsensitive)
564
565 currentItem = self.logTree.currentItem()
566 for topIndex in range(self.logTree.topLevelItemCount()):
567 topItem = self.logTree.topLevelItem(topIndex)
568 if topItem.text(2) <= to_ and topItem.text(2) >= from_ and \
569 searchRx.indexIn(topItem.text(fieldIndex)) > -1:
570 topItem.setHidden(False)
571 if topItem is currentItem:
572 self.on_logTree_currentItemChanged(topItem, None)
573 else:
574 topItem.setHidden(True)
575 if topItem is currentItem:
576 self.messageEdit.clear()
577 self.filesTree.clear()
578
579 @pyqtSlot()
580 def on_clearRxEditButton_clicked(self):
581 """
582 Private slot called by a click of the clear RX edit button.
583 """
584 self.rxEdit.clear()
585
586 @pyqtSlot(bool)
587 def on_stopCheckBox_clicked(self, checked):
588 """
589 Private slot called, when the stop on copy/move checkbox is clicked
590 """
591 self.vcs.getPlugin().setPreferences("StopLogOnCopy",
592 self.stopCheckBox.isChecked())
593 self.nextButton.setEnabled(True)
594 self.limitSpinBox.setEnabled(True)
595
596 def on_passwordCheckBox_toggled(self, isOn):
597 """
598 Private slot to handle the password checkbox toggled.
599
600 @param isOn flag indicating the status of the check box (boolean)
601 """
602 if isOn:
603 self.input.setEchoMode(QLineEdit.Password)
604 else:
605 self.input.setEchoMode(QLineEdit.Normal)
606
607 @pyqtSlot()
608 def on_sendButton_clicked(self):
609 """
610 Private slot to send the input to the merurial process.
611 """
612 input = self.input.text()
613 input += os.linesep
614
615 if self.passwordCheckBox.isChecked():
616 self.errors.insertPlainText(os.linesep)
617 self.errors.ensureCursorVisible()
618 else:
619 self.errors.insertPlainText(input)
620 self.errors.ensureCursorVisible()
621 self.errorGroup.show()
622
623 self.process.write(input)
624
625 self.passwordCheckBox.setChecked(False)
626 self.input.clear()
627
628 def on_input_returnPressed(self):
629 """
630 Private slot to handle the press of the return key in the input field.
631 """
632 self.intercept = True
633 self.on_sendButton_clicked()
634
635 def keyPressEvent(self, evt):
636 """
637 Protected slot to handle a key press event.
638
639 @param evt the key press event (QKeyEvent)
640 """
641 if self.intercept:
642 self.intercept = False
643 evt.accept()
644 return
645 QWidget.keyPressEvent(self, evt)

eric ide

mercurial