--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/Plugins/VcsPlugins/vcsMercurial/HgLogBrowserDialog.py Mon Apr 12 18:00:42 2010 +0000 @@ -0,0 +1,645 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2010 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing a dialog to browse the log history. +""" + +import os + +from PyQt4.QtCore import pyqtSlot, SIGNAL, Qt, QDate, QProcess, QTimer, QRegExp +from PyQt4.QtGui import QDialog, QDialogButtonBox, QHeaderView, QTreeWidgetItem, \ + QApplication, QMessageBox, QCursor, QWidget, QLineEdit + +from .Ui_HgLogBrowserDialog import Ui_HgLogBrowserDialog +from .HgDiffDialog import HgDiffDialog + +import UI.PixmapCache + +import Preferences + +class HgLogBrowserDialog(QDialog, Ui_HgLogBrowserDialog): + """ + Class implementing a dialog to browse the log history. + """ + def __init__(self, vcs, parent = None): + """ + Constructor + + @param vcs reference to the vcs object + @param parent parent widget (QWidget) + """ + QDialog.__init__(self, parent) + self.setupUi(self) + + self.buttonBox.button(QDialogButtonBox.Close).setEnabled(False) + self.buttonBox.button(QDialogButtonBox.Cancel).setDefault(True) + + self.filesTree.headerItem().setText(self.filesTree.columnCount(), "") + self.filesTree.header().setSortIndicator(0, Qt.AscendingOrder) + + self.vcs = vcs + + self.__maxDate = QDate() + self.__minDate = QDate() + self.__filterLogsEnabled = True + + self.fromDate.setDisplayFormat("yyyy-MM-dd") + self.toDate.setDisplayFormat("yyyy-MM-dd") + self.fromDate.setDate(QDate.currentDate()) + self.toDate.setDate(QDate.currentDate()) + self.fieldCombo.setCurrentIndex(self.fieldCombo.findText(self.trUtf8("Message"))) + self.clearRxEditButton.setIcon(UI.PixmapCache.getIcon("clearLeft.png")) + self.limitSpinBox.setValue(self.vcs.getPlugin().getPreferences("LogLimit")) + self.stopCheckBox.setChecked(self.vcs.getPlugin().getPreferences("StopLogOnCopy")) + + self.__messageRole = Qt.UserRole + self.__changesRole = Qt.UserRole + 1 + + self.process = QProcess() + self.connect(self.process, SIGNAL('finished(int, QProcess::ExitStatus)'), + self.__procFinished) + self.connect(self.process, SIGNAL('readyReadStandardOutput()'), + self.__readStdout) + self.connect(self.process, SIGNAL('readyReadStandardError()'), + self.__readStderr) + + self.flags = { + 'A' : self.trUtf8('Added'), + 'D' : self.trUtf8('Deleted'), + 'M' : self.trUtf8('Modified'), + } + + self.buf = [] # buffer for stdout + self.diff = None + self.__started = False + self.__lastRev = 0 + + def closeEvent(self, e): + """ + Private slot implementing a close event handler. + + @param e close event (QCloseEvent) + """ + if self.process is not None and \ + self.process.state() != QProcess.NotRunning: + self.process.terminate() + QTimer.singleShot(2000, self.process.kill) + self.process.waitForFinished(3000) + + e.accept() + + def __resizeColumnsLog(self): + """ + Private method to resize the log tree columns. + """ + self.logTree.header().resizeSections(QHeaderView.ResizeToContents) + self.logTree.header().setStretchLastSection(True) + + def __resortLog(self): + """ + Private method to resort the log tree. + """ + self.logTree.sortItems(self.logTree.sortColumn(), + self.logTree.header().sortIndicatorOrder()) + + def __resizeColumnsFiles(self): + """ + Private method to resize the changed files tree columns. + """ + self.filesTree.header().resizeSections(QHeaderView.ResizeToContents) + self.filesTree.header().setStretchLastSection(True) + + def __resortFiles(self): + """ + Private method to resort the changed files tree. + """ + sortColumn = self.filesTree.sortColumn() + self.filesTree.sortItems(1, + self.filesTree.header().sortIndicatorOrder()) + self.filesTree.sortItems(sortColumn, + self.filesTree.header().sortIndicatorOrder()) + + def __generateLogItem(self, author, date, message, revision, changedPaths): + """ + Private method to generate a log tree entry. + + @param author author info (string) + @param date date info (string) + @param message text of the log message (list of strings) + @param revision revision info (string) + @param changedPaths list of dictionary objects containing + info about the changed files/directories + @return reference to the generated item (QTreeWidgetItem) + """ + msg = [] + for line in message: + msg.append(line.strip()) + + rev, node = revision.split(":") + itm = QTreeWidgetItem(self.logTree, [ + "{0:>7}:{1}".format(rev, node), + author, + date, + " ".join(msg), + ]) + + itm.setData(0, self.__messageRole, message) + itm.setData(0, self.__changesRole, changedPaths) + + itm.setTextAlignment(0, Qt.AlignLeft) + itm.setTextAlignment(1, Qt.AlignLeft) + itm.setTextAlignment(2, Qt.AlignLeft) + itm.setTextAlignment(3, Qt.AlignLeft) + itm.setTextAlignment(4, Qt.AlignLeft) + + try: + self.__lastRev = int(revision.split(":")[0]) + except ValueError: + self.__lastRev = 0 + + return itm + + def __generateFileItem(self, action, path): + """ + Private method to generate a changed files tree entry. + + @param action indicator for the change action ("A", "D" or "M") + @param path path of the file in the repository (string) + @return reference to the generated item (QTreeWidgetItem) + """ + itm = QTreeWidgetItem(self.filesTree, [ + self.flags[action], + path, + ]) + + return itm + + def __getLogEntries(self, startRev = None): + """ + Private method to retrieve log entries from the repository. + + @param startRev revision number to start from (integer, string) + """ + self.buttonBox.button(QDialogButtonBox.Close).setEnabled(False) + self.buttonBox.button(QDialogButtonBox.Cancel).setEnabled(True) + self.buttonBox.button(QDialogButtonBox.Cancel).setDefault(True) + QApplication.processEvents() + + QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) + QApplication.processEvents() + + self.intercept = False + self.process.kill() + + self.buf = [] + self.cancelled = False + self.errors.clear() + + self.inputGroup.show() + + args = [] + args.append('log') + self.vcs.addArguments(args, self.vcs.options['global']) + self.vcs.addArguments(args, self.vcs.options['log']) + args.append('--verbose') + args.append('--limit') + args.append(str(self.limitSpinBox.value())) + if startRev is not None: + args.append('--rev') + args.append('{0}:0'.format(startRev)) + if not self.stopCheckBox.isChecked(): + args.append('--follow') + args.append('--template') + args.append("change|{rev}:{node|short}\n" + "user|{author}\n" + "date|{date|isodate}\n" + "description|{desc}\n" + "file_adds|{file_adds}\n" + "files_mods|{file_mods}\n" + "file_dels|{file_dels}\n" + "@@@\n") + if self.fname != "." or self.dname != self.repodir: + args.append(self.filename) + + self.process.setWorkingDirectory(self.repodir) + + self.process.start('hg', args) + procStarted = self.process.waitForStarted() + if not procStarted: + self.inputGroup.setEnabled(False) + QMessageBox.critical(None, + self.trUtf8('Process Generation Error'), + self.trUtf8( + 'The process {0} could not be started. ' + 'Ensure, that it is in the search path.' + ).format('hg')) + + def start(self, fn): + """ + Public slot to start the svn log command. + + @param fn filename to show the log for (string) + """ + self.errorGroup.hide() + QApplication.processEvents() + + self.filename = fn + self.dname, self.fname = self.vcs.splitPath(fn) + + # find the root of the repo + self.repodir = self.dname + while not os.path.isdir(os.path.join(self.repodir, self.vcs.adminDir)): + self.repodir = os.path.dirname(self.repodir) + if self.repodir == os.sep: + return + + self.activateWindow() + self.raise_() + + self.logTree.clear() + self.__started = True + self.__getLogEntries() + + def __procFinished(self, exitCode, exitStatus): + """ + Private slot connected to the finished signal. + + @param exitCode exit code of the process (integer) + @param exitStatus exit status of the process (QProcess.ExitStatus) + """ + self.__processBuffer() + self.__finish() + + def __finish(self): + """ + Private slot called when the process finished or the user pressed the button. + """ + if self.process is not None and \ + self.process.state() != QProcess.NotRunning: + self.process.terminate() + QTimer.singleShot(2000, self.process.kill) + self.process.waitForFinished(3000) + + QApplication.restoreOverrideCursor() + + self.buttonBox.button(QDialogButtonBox.Close).setEnabled(True) + self.buttonBox.button(QDialogButtonBox.Cancel).setEnabled(False) + self.buttonBox.button(QDialogButtonBox.Close).setDefault(True) + + self.inputGroup.setEnabled(False) + self.inputGroup.hide() + + def __processBuffer(self): + """ + Private method to process the buffered output of the svn log command. + """ + noEntries = 0 + log = {"message" : []} + changedPaths = [] + for s in self.buf: + if s != "@@@\n": + try: + key, value = s.split("|", 1) + except ValueError: + key = "" + value = s + if key == "change": + log["revision"] = value.strip() + elif key == "user": + log["author"] = value.strip() + elif key == "date": + log["date"] = " ".join(value.strip().split()[:2]) + elif key == "description": + log["message"].append(value.strip()) + elif key == "file_adds": + if value.strip(): + for f in value.strip().split(): + changedPaths.append({\ + "action" : "A", + "path" : f, + }) + elif key == "files_mods": + if value.strip(): + for f in value.strip().split(): + changedPaths.append({\ + "action" : "M", + "path" : f, + }) + elif key == "file_dels": + if value.strip(): + for f in value.strip().split(): + changedPaths.append({\ + "action" : "D", + "path" : f, + }) + else: + if value.strip(): + log["message"].append(value.strip()) + else: + if len(log) > 1: + self.__generateLogItem(log["author"], log["date"], + log["message"], log["revision"], changedPaths) + dt = QDate.fromString(log["date"], Qt.ISODate) + if not self.__maxDate.isValid() and not self.__minDate.isValid(): + self.__maxDate = dt + self.__minDate = dt + else: + if self.__maxDate < dt: + self.__maxDate = dt + if self.__minDate > dt: + self.__minDate = dt + noEntries += 1 + log = {"message" : []} + changedPaths = [] + + self.logTree.doItemsLayout() + self.__resizeColumnsLog() + self.__resortLog() + + if self.__started: + self.logTree.setCurrentItem(self.logTree.topLevelItem(0)) + self.__started = False + + if noEntries < self.limitSpinBox.value() and not self.cancelled: + self.nextButton.setEnabled(False) + self.limitSpinBox.setEnabled(False) + + self.__filterLogsEnabled = False + self.fromDate.setMinimumDate(self.__minDate) + self.fromDate.setMaximumDate(self.__maxDate) + self.fromDate.setDate(self.__minDate) + self.toDate.setMinimumDate(self.__minDate) + self.toDate.setMaximumDate(self.__maxDate) + self.toDate.setDate(self.__maxDate) + self.__filterLogsEnabled = True + self.__filterLogs() + + def __readStdout(self): + """ + Private slot to handle the readyReadStandardOutput signal. + + It reads the output of the process and inserts it into a buffer. + """ + self.process.setReadChannel(QProcess.StandardOutput) + + while self.process.canReadLine(): + line = str(self.process.readLine(), + Preferences.getSystem("IOEncoding"), + 'replace') + self.buf.append(line) + + def __readStderr(self): + """ + Private slot to handle the readyReadStandardError signal. + + It reads the error output of the process and inserts it into the + error pane. + """ + if self.process is not None: + self.errorGroup.show() + s = str(self.process.readAllStandardError(), + Preferences.getSystem("IOEncoding"), + 'replace') + self.errors.insertPlainText(s) + self.errors.ensureCursorVisible() + + def __diffRevisions(self, rev1, rev2): + """ + Private method to do a diff of two revisions. + + @param rev1 first revision number (integer) + @param rev2 second revision number (integer) + """ + if self.diff: + self.diff.close() + del self.diff + self.diff = HgDiffDialog(self.vcs) + self.diff.show() + self.diff.start(self.filename, [rev1, rev2]) + + def on_buttonBox_clicked(self, button): + """ + Private slot called by a button of the button box clicked. + + @param button button that was clicked (QAbstractButton) + """ + if button == self.buttonBox.button(QDialogButtonBox.Close): + self.close() + elif button == self.buttonBox.button(QDialogButtonBox.Cancel): + self.cancelled = True + self.__finish() + + @pyqtSlot(QTreeWidgetItem, QTreeWidgetItem) + def on_logTree_currentItemChanged(self, current, previous): + """ + Private slot called, when the current item of the log tree changes. + + @param current reference to the new current item (QTreeWidgetItem) + @param previous reference to the old current item (QTreeWidgetItem) + """ + self.messageEdit.clear() + for line in current.data(0, self.__messageRole): + self.messageEdit.append(line.strip()) + + self.filesTree.clear() + changes = current.data(0, self.__changesRole) + if len(changes) > 0: + for change in changes: + self.__generateFileItem(change["action"], change["path"]) + self.__resizeColumnsFiles() + self.__resortFiles() + + self.diffPreviousButton.setEnabled( + current != self.logTree.topLevelItem(self.logTree.topLevelItemCount() - 1)) + + @pyqtSlot() + def on_logTree_itemSelectionChanged(self): + """ + Private slot called, when the selection has changed. + """ + self.diffRevisionsButton.setEnabled(len(self.logTree.selectedItems()) == 2) + + @pyqtSlot() + def on_nextButton_clicked(self): + """ + Private slot to handle the Next button. + """ + if self.__lastRev > 1: + self.__getLogEntries(self.__lastRev - 1) + + @pyqtSlot() + def on_diffPreviousButton_clicked(self): + """ + Private slot to handle the Diff to Previous button. + """ + itm = self.logTree.currentItem() + if itm is None: + self.diffPreviousButton.setEnabled(False) + return + rev2 = int(itm.text(0).split(":")[0]) + + itm = self.logTree.topLevelItem(self.logTree.indexOfTopLevelItem(itm) + 1) + if itm is None: + self.diffPreviousButton.setEnabled(False) + return + rev1 = int(itm.text(0).split(":")[0]) + + self.__diffRevisions(rev1, rev2) + + @pyqtSlot() + def on_diffRevisionsButton_clicked(self): + """ + Private slot to handle the Compare Revisions button. + """ + items = self.logTree.selectedItems() + if len(items) != 2: + self.diffRevisionsButton.setEnabled(False) + return + + rev2 = int(items[0].text(0).split(":")[0]) + rev1 = int(items[1].text(0).split(":")[0]) + + self.__diffRevisions(min(rev1, rev2), max(rev1, rev2)) + + @pyqtSlot(QDate) + def on_fromDate_dateChanged(self, date): + """ + Private slot called, when the from date changes. + + @param date new date (QDate) + """ + self.__filterLogs() + + @pyqtSlot(QDate) + def on_toDate_dateChanged(self, date): + """ + Private slot called, when the from date changes. + + @param date new date (QDate) + """ + self.__filterLogs() + + @pyqtSlot(str) + def on_fieldCombo_activated(self, txt): + """ + Private slot called, when a new filter field is selected. + + @param txt text of the selected field (string) + """ + self.__filterLogs() + + @pyqtSlot(str) + def on_rxEdit_textChanged(self, txt): + """ + Private slot called, when a filter expression is entered. + + @param txt filter expression (string) + """ + self.__filterLogs() + + def __filterLogs(self): + """ + Private method to filter the log entries. + """ + if self.__filterLogsEnabled: + from_ = self.fromDate.date().toString("yyyy-MM-dd") + to_ = self.toDate.date().addDays(1).toString("yyyy-MM-dd") + txt = self.fieldCombo.currentText() + if txt == self.trUtf8("Author"): + fieldIndex = 1 + searchRx = QRegExp(self.rxEdit.text(), Qt.CaseInsensitive) + elif txt == self.trUtf8("Revision"): + fieldIndex = 0 + txt = self.rxEdit.text() + if txt.startswith("^"): + searchRx = QRegExp("^\s*%s" % txt[1:], Qt.CaseInsensitive) + else: + searchRx = QRegExp(txt, Qt.CaseInsensitive) + else: + fieldIndex = 3 + searchRx = QRegExp(self.rxEdit.text(), Qt.CaseInsensitive) + + currentItem = self.logTree.currentItem() + for topIndex in range(self.logTree.topLevelItemCount()): + topItem = self.logTree.topLevelItem(topIndex) + if topItem.text(2) <= to_ and topItem.text(2) >= from_ and \ + searchRx.indexIn(topItem.text(fieldIndex)) > -1: + topItem.setHidden(False) + if topItem is currentItem: + self.on_logTree_currentItemChanged(topItem, None) + else: + topItem.setHidden(True) + if topItem is currentItem: + self.messageEdit.clear() + self.filesTree.clear() + + @pyqtSlot() + def on_clearRxEditButton_clicked(self): + """ + Private slot called by a click of the clear RX edit button. + """ + self.rxEdit.clear() + + @pyqtSlot(bool) + def on_stopCheckBox_clicked(self, checked): + """ + Private slot called, when the stop on copy/move checkbox is clicked + """ + self.vcs.getPlugin().setPreferences("StopLogOnCopy", + self.stopCheckBox.isChecked()) + self.nextButton.setEnabled(True) + self.limitSpinBox.setEnabled(True) + + def on_passwordCheckBox_toggled(self, isOn): + """ + Private slot to handle the password checkbox toggled. + + @param isOn flag indicating the status of the check box (boolean) + """ + if isOn: + self.input.setEchoMode(QLineEdit.Password) + else: + self.input.setEchoMode(QLineEdit.Normal) + + @pyqtSlot() + def on_sendButton_clicked(self): + """ + Private slot to send the input to the merurial process. + """ + input = self.input.text() + input += os.linesep + + if self.passwordCheckBox.isChecked(): + self.errors.insertPlainText(os.linesep) + self.errors.ensureCursorVisible() + else: + self.errors.insertPlainText(input) + self.errors.ensureCursorVisible() + self.errorGroup.show() + + self.process.write(input) + + self.passwordCheckBox.setChecked(False) + self.input.clear() + + def on_input_returnPressed(self): + """ + Private slot to handle the press of the return key in the input field. + """ + self.intercept = True + self.on_sendButton_clicked() + + def keyPressEvent(self, evt): + """ + Protected slot to handle a key press event. + + @param evt the key press event (QKeyEvent) + """ + if self.intercept: + self.intercept = False + evt.accept() + return + QWidget.keyPressEvent(self, evt)