Sat, 31 Aug 2019 12:58:11 +0200
Started removing runtime support for Python2 and PyQt4.
# -*- coding: utf-8 -*- # Copyright (c) 2014 - 2019 Detlev Offenbach <detlev@die-offenbachs.de> # """ Module implementing a dialog to browse the log history. """ from __future__ import unicode_literals import os import collections from PyQt5.QtCore import pyqtSlot, Qt, QDate, QProcess, QTimer, QRegExp, \ QSize, QPoint, QFileInfo from PyQt5.QtGui import QCursor, QColor, QPixmap, QPainter, QPen, QIcon, \ QTextCursor from PyQt5.QtWidgets import QWidget, QDialogButtonBox, QHeaderView, \ QTreeWidgetItem, QApplication, QLineEdit, QMenu, QInputDialog, QToolTip from E5Gui.E5Application import e5App from E5Gui import E5MessageBox, E5FileDialog from Globals import qVersionTuple, strToQByteArray from .Ui_GitLogBrowserDialog import Ui_GitLogBrowserDialog from .GitDiffHighlighter import GitDiffHighlighter from .GitDiffGenerator import GitDiffGenerator import UI.PixmapCache import Preferences import Utilities COLORNAMES = ["red", "green", "purple", "cyan", "olive", "magenta", "gray", "yellow", "darkred", "darkgreen", "darkblue", "darkcyan", "darkmagenta", "blue"] COLORS = [str(QColor(x).name()) for x in COLORNAMES] class GitLogBrowserDialog(QWidget, Ui_GitLogBrowserDialog): """ Class implementing a dialog to browse the log history. """ IconColumn = 0 CommitIdColumn = 1 AuthorColumn = 2 DateColumn = 3 CommitterColumn = 4 CommitDateColumn = 5 SubjectColumn = 6 BranchColumn = 7 TagsColumn = 8 def __init__(self, vcs, parent=None): """ Constructor @param vcs reference to the vcs object @param parent parent widget (QWidget) """ super(GitLogBrowserDialog, self).__init__(parent) self.setupUi(self) windowFlags = self.windowFlags() windowFlags |= Qt.WindowContextHelpButtonHint self.setWindowFlags(windowFlags) self.mainSplitter.setSizes([300, 400]) self.mainSplitter.setStretchFactor(0, 1) self.mainSplitter.setStretchFactor(1, 2) self.diffSplitter.setStretchFactor(0, 1) self.diffSplitter.setStretchFactor(1, 2) 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(1, Qt.AscendingOrder) self.upButton.setIcon(UI.PixmapCache.getIcon("1uparrow.png")) self.downButton.setIcon(UI.PixmapCache.getIcon("1downarrow.png")) self.refreshButton = self.buttonBox.addButton( self.tr("&Refresh"), QDialogButtonBox.ActionRole) self.refreshButton.setToolTip( self.tr("Press to refresh the list of commits")) self.refreshButton.setEnabled(False) self.findPrevButton.setIcon(UI.PixmapCache.getIcon("1leftarrow.png")) self.findNextButton.setIcon(UI.PixmapCache.getIcon("1rightarrow.png")) self.__findBackwards = False self.modeComboBox.addItem(self.tr("Find"), "find") self.modeComboBox.addItem(self.tr("Filter"), "filter") self.fieldCombo.addItem(self.tr("Commit ID"), "commitId") self.fieldCombo.addItem(self.tr("Author"), "author") self.fieldCombo.addItem(self.tr("Committer"), "committer") self.fieldCombo.addItem(self.tr("Subject"), "subject") self.fieldCombo.addItem(self.tr("File"), "file") self.__logTreeNormalFont = self.logTree.font() self.__logTreeNormalFont.setBold(False) self.__logTreeBoldFont = self.logTree.font() self.__logTreeBoldFont.setBold(True) font = Preferences.getEditorOtherFonts("MonospacedFont") self.diffEdit.setFontFamily(font.family()) self.diffEdit.setFontPointSize(font.pointSize()) self.diffHighlighter = GitDiffHighlighter(self.diffEdit.document()) self.__diffGenerator = GitDiffGenerator(vcs, self) self.__diffGenerator.finished.connect(self.__generatorFinished) self.vcs = vcs self.__detailsTemplate = self.tr( "<table>" "<tr><td><b>Commit ID</b></td><td>{0}</td></tr>" "<tr><td><b>Date</b></td><td>{1}</td></tr>" "<tr><td><b>Author</b></td><td>{2} <{3}></td></tr>" "<tr><td><b>Commit Date</b></td><td>{4}</td></tr>" "<tr><td><b>Committer</b></td><td>{5} <{6}></td></tr>" "{7}" "<tr><td><b>Subject</b></td><td>{8}</td></tr>" "{9}" "</table>" ) self.__parentsTemplate = self.tr( "<tr><td><b>Parents</b></td><td>{0}</td></tr>" ) self.__childrenTemplate = self.tr( "<tr><td><b>Children</b></td><td>{0}</td></tr>" ) self.__branchesTemplate = self.tr( "<tr><td><b>Branches</b></td><td>{0}</td></tr>" ) self.__tagsTemplate = self.tr( "<tr><td><b>Tags</b></td><td>{0}</td></tr>" ) self.__mesageTemplate = self.tr( "<tr><td><b>Message</b></td><td>{0}</td></tr>" ) self.__formatTemplate = ( 'format:recordstart%n' 'commit|%h%n' 'parents|%p%n' 'author|%an%n' 'authormail|%ae%n' 'authordate|%ai%n' 'committer|%cn%n' 'committermail|%ce%n' 'committerdate|%ci%n' 'refnames|%d%n' 'subject|%s%n' 'bodystart%n' '%b%n' 'bodyend%n' ) self.__filename = "" self.__isFile = False self.__selectedCommitIDs = [] self.intercept = False self.__initData() self.fromDate.setDisplayFormat("yyyy-MM-dd") self.toDate.setDisplayFormat("yyyy-MM-dd") self.__resetUI() # roles used in the log tree self.__subjectRole = Qt.UserRole self.__messageRole = Qt.UserRole + 1 self.__changesRole = Qt.UserRole + 2 self.__edgesRole = Qt.UserRole + 3 self.__parentsRole = Qt.UserRole + 4 self.__branchesRole = Qt.UserRole + 5 self.__authorMailRole = Qt.UserRole + 6 self.__committerMailRole = Qt.UserRole + 7 # roles used in the file tree self.__diffFileLineRole = Qt.UserRole self.process = QProcess() self.process.finished.connect(self.__procFinished) self.process.readyReadStandardOutput.connect(self.__readStdout) self.process.readyReadStandardError.connect(self.__readStderr) self.flags = { 'A': self.tr('Added'), 'D': self.tr('Deleted'), 'M': self.tr('Modified'), 'C': self.tr('Copied'), 'R': self.tr('Renamed'), 'T': self.tr('Type changed'), 'U': self.tr('Unmerged'), 'X': self.tr('Unknown'), } self.__dotRadius = 8 self.__rowHeight = 20 self.logTree.setIconSize( QSize(100 * self.__rowHeight, self.__rowHeight)) self.detailsEdit.anchorClicked.connect(self.__commitIdClicked) self.__initLogTreeContextMenu() self.__initActionsMenu() self.__finishCallbacks = [] def __addFinishCallback(self, callback): """ Private method to add a method to be called once the process finished. The callback methods are invoke in a FIFO style and are consumed. If a callback method needs to be called again, it must be added again. @param callback callback method @type function """ if callback not in self.__finishCallbacks: self.__finishCallbacks.append(callback) def __initLogTreeContextMenu(self): """ Private method to initialize the log tree context menu. """ self.__logTreeMenu = QMenu() # commit ID column act = self.__logTreeMenu.addAction( self.tr("Show Commit ID Column")) act.setToolTip(self.tr( "Press to show the commit ID column")) act.setCheckable(True) act.setChecked(self.vcs.getPlugin().getPreferences( "ShowCommitIdColumn")) act.triggered.connect(self.__showCommitIdColumn) # author and date columns act = self.__logTreeMenu.addAction( self.tr("Show Author Columns")) act.setToolTip(self.tr( "Press to show the author columns")) act.setCheckable(True) act.setChecked(self.vcs.getPlugin().getPreferences( "ShowAuthorColumns")) act.triggered.connect(self.__showAuthorColumns) # committer and commit date columns act = self.__logTreeMenu.addAction( self.tr("Show Committer Columns")) act.setToolTip(self.tr( "Press to show the committer columns")) act.setCheckable(True) act.setChecked(self.vcs.getPlugin().getPreferences( "ShowCommitterColumns")) act.triggered.connect(self.__showCommitterColumns) # branches column act = self.__logTreeMenu.addAction( self.tr("Show Branches Column")) act.setToolTip(self.tr( "Press to show the branches column")) act.setCheckable(True) act.setChecked(self.vcs.getPlugin().getPreferences( "ShowBranchesColumn")) act.triggered.connect(self.__showBranchesColumn) # tags column act = self.__logTreeMenu.addAction( self.tr("Show Tags Column")) act.setToolTip(self.tr( "Press to show the Tags column")) act.setCheckable(True) act.setChecked(self.vcs.getPlugin().getPreferences( "ShowTagsColumn")) act.triggered.connect(self.__showTagsColumn) # set column visibility as configured self.__showCommitIdColumn(self.vcs.getPlugin().getPreferences( "ShowCommitIdColumn")) self.__showAuthorColumns(self.vcs.getPlugin().getPreferences( "ShowAuthorColumns")) self.__showCommitterColumns(self.vcs.getPlugin().getPreferences( "ShowCommitterColumns")) self.__showBranchesColumn(self.vcs.getPlugin().getPreferences( "ShowBranchesColumn")) self.__showTagsColumn(self.vcs.getPlugin().getPreferences( "ShowTagsColumn")) def __initActionsMenu(self): """ Private method to initialize the actions menu. """ self.__actionsMenu = QMenu() self.__actionsMenu.setTearOffEnabled(True) if qVersionTuple() >= (5, 1, 0): self.__actionsMenu.setToolTipsVisible(True) else: self.__actionsMenu.hovered.connect(self.__actionsMenuHovered) self.__cherryAct = self.__actionsMenu.addAction( self.tr("Copy Commits"), self.__cherryActTriggered) self.__cherryAct.setToolTip(self.tr( "Cherry-pick the selected commits to the current branch")) self.__actionsMenu.addSeparator() self.__tagAct = self.__actionsMenu.addAction( self.tr("Tag"), self.__tagActTriggered) self.__tagAct.setToolTip(self.tr("Tag the selected commit")) self.__branchAct = self.__actionsMenu.addAction( self.tr("Branch"), self.__branchActTriggered) self.__branchAct.setToolTip(self.tr( "Create a new branch at the selected commit.")) self.__branchSwitchAct = self.__actionsMenu.addAction( self.tr("Branch && Switch"), self.__branchSwitchActTriggered) self.__branchSwitchAct.setToolTip(self.tr( "Create a new branch at the selected commit and switch" " the work tree to it.")) self.__switchAct = self.__actionsMenu.addAction( self.tr("Switch"), self.__switchActTriggered) self.__switchAct.setToolTip(self.tr( "Switch the working directory to the selected commit")) self.__actionsMenu.addSeparator() self.__shortlogAct = self.__actionsMenu.addAction( self.tr("Show Short Log"), self.__shortlogActTriggered) self.__shortlogAct.setToolTip(self.tr( "Show a dialog with a log output for release notes")) self.__describeAct = self.__actionsMenu.addAction( self.tr("Describe"), self.__describeActTriggered) self.__describeAct.setToolTip(self.tr( "Show the most recent tag reachable from a commit")) self.actionsButton.setIcon( UI.PixmapCache.getIcon("actionsToolButton.png")) self.actionsButton.setMenu(self.__actionsMenu) def __actionsMenuHovered(self, action): """ Private slot to show the tooltip for an action menu entry. @param action action to show tooltip for @type QAction """ QToolTip.showText( QCursor.pos(), action.toolTip(), self.__actionsMenu, self.__actionsMenu.actionGeometry(action)) def __initData(self): """ Private method to (re-)initialize some data. """ self.__maxDate = QDate() self.__minDate = QDate() self.__filterLogsEnabled = True self.buf = [] # buffer for stdout self.diff = None self.__started = False self.__skipEntries = 0 self.projectMode = False # attributes to store log graph data self.__commitIds = [] self.__commitColors = {} self.__commitColor = 0 self.__projectRevision = "" self.__childrenInfo = collections.defaultdict(list) def closeEvent(self, e): """ Protected 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) self.vcs.getPlugin().setPreferences( "LogBrowserGeometry", self.saveGeometry()) self.vcs.getPlugin().setPreferences( "LogBrowserSplitterStates", [ self.mainSplitter.saveState(), self.detailsSplitter.saveState(), self.diffSplitter.saveState(), ] ) e.accept() def show(self): """ Public slot to show the dialog. """ self.__reloadGeometry() self.__restoreSplitterStates() self.__resetUI() super(GitLogBrowserDialog, self).show() def __reloadGeometry(self): """ Private method to restore the geometry. """ geom = self.vcs.getPlugin().getPreferences("LogBrowserGeometry") if geom.isEmpty(): s = QSize(1000, 800) self.resize(s) else: self.restoreGeometry(geom) def __restoreSplitterStates(self): """ Private method to restore the state of the various splitters. """ states = self.vcs.getPlugin().getPreferences( "LogBrowserSplitterStates") if len(states) == 3: # we have three splitters self.mainSplitter.restoreState(states[0]) self.detailsSplitter.restoreState(states[1]) self.diffSplitter.restoreState(states[2]) def __resetUI(self): """ Private method to reset the user interface. """ self.fromDate.setDate(QDate.currentDate()) self.toDate.setDate(QDate.currentDate()) self.fieldCombo.setCurrentIndex(self.fieldCombo.findData("subject")) self.limitSpinBox.setValue(self.vcs.getPlugin().getPreferences( "LogLimit")) self.stopCheckBox.setChecked(self.vcs.getPlugin().getPreferences( "StopLogOnCopy")) self.logTree.clear() def __resizeColumnsLog(self): """ Private method to resize the log tree columns. """ self.logTree.header().resizeSections(QHeaderView.ResizeToContents) self.logTree.header().setStretchLastSection(True) 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. """ self.filesTree.setSortingEnabled(True) self.filesTree.sortItems(1, Qt.AscendingOrder) self.filesTree.setSortingEnabled(False) def __getColor(self, n): """ Private method to get the (rotating) name of the color given an index. @param n color index (integer) @return color name (string) """ return COLORS[n % len(COLORS)] def __generateEdges(self, commitId, parents): """ Private method to generate edge info for the give data. @param commitId commit id to calculate edge info for (string) @param parents list of parent commits (list of strings) @return tuple containing the column and color index for the given node and a list of tuples indicating the edges between the given node and its parents (integer, integer, [(integer, integer, integer), ...]) """ if commitId not in self.__commitIds: # new head self.__commitIds.append(commitId) self.__commitColors[commitId] = self.__commitColor self.__commitColor += 1 col = self.__commitIds.index(commitId) color = self.__commitColors.pop(commitId) nextCommitIds = self.__commitIds[:] # add parents to next addparents = [p for p in parents if p not in nextCommitIds] nextCommitIds[col:col + 1] = addparents # set colors for the parents for i, p in enumerate(addparents): if not i: self.__commitColors[p] = color else: self.__commitColors[p] = self.__commitColor self.__commitColor += 1 # add edges to the graph edges = [] if parents: for ecol, ecommitId in enumerate(self.__commitIds): if ecommitId in nextCommitIds: edges.append( (ecol, nextCommitIds.index(ecommitId), self.__commitColors[ecommitId])) elif ecommitId == commitId: for p in parents: edges.append( (ecol, nextCommitIds.index(p), self.__commitColors[p])) self.__commitIds = nextCommitIds return col, color, edges def __generateIcon(self, column, color, bottomedges, topedges, dotColor, currentCommit): """ Private method to generate an icon containing the revision tree for the given data. @param column column index of the revision (integer) @param color color of the node (integer) @param bottomedges list of edges for the bottom of the node (list of tuples of three integers) @param topedges list of edges for the top of the node (list of tuples of three integers) @param dotColor color to be used for the dot (QColor) @param currentCommit flag indicating to draw the icon for the current commit (boolean) @return icon for the node (QIcon) """ def col2x(col, radius): """ Local function to calculate a x-position for a column. @param col column number (integer) @param radius radius of the indicator circle (integer) """ return int(1.2 * radius) * col + radius // 2 + 3 radius = self.__dotRadius w = len(bottomedges) * radius + 20 h = self.__rowHeight dot_x = col2x(column, radius) - radius // 2 dot_y = h // 2 pix = QPixmap(w, h) pix.fill(QColor(0, 0, 0, 0)) painter = QPainter(pix) painter.setRenderHint(QPainter.Antialiasing) pen = QPen(Qt.blue) pen.setWidth(2) painter.setPen(pen) lpen = QPen(pen) lpen.setColor(Qt.black) painter.setPen(lpen) # draw the revision history lines for y1, y2, lines in ((0, h, bottomedges), (-h, 0, topedges)): if lines: for start, end, ecolor in lines: lpen = QPen(pen) lpen.setColor(QColor(self.__getColor(ecolor))) lpen.setWidth(2) painter.setPen(lpen) x1 = col2x(start, radius) x2 = col2x(end, radius) painter.drawLine(x1, dot_y + y1, x2, dot_y + y2) penradius = 1 pencolor = Qt.black dot_y = (h // 2) - radius // 2 # draw a dot for the revision if currentCommit: # enlarge dot for the current revision delta = 2 radius += 2 * delta dot_y -= delta dot_x -= delta painter.setBrush(dotColor) pen = QPen(pencolor) pen.setWidth(penradius) painter.setPen(pen) painter.drawEllipse(dot_x, dot_y, radius, radius) painter.end() return QIcon(pix) def __identifyProject(self): """ Private method to determine the revision of the project directory. """ errMsg = "" args = self.vcs.initCommand("show") args.append("--abbrev={0}".format( self.vcs.getPlugin().getPreferences("CommitIdLength"))) args.append("--format=%h") args.append("--no-patch") args.append("HEAD") output = "" process = QProcess() process.setWorkingDirectory(self.repodir) process.start('git', args) procStarted = process.waitForStarted(5000) if procStarted: finished = process.waitForFinished(30000) if finished and process.exitCode() == 0: output = str(process.readAllStandardOutput(), Preferences.getSystem("IOEncoding"), 'replace') else: if not finished: errMsg = self.tr( "The git process did not finish within 30s.") else: errMsg = self.tr("Could not start the git executable.") if errMsg: E5MessageBox.critical( self, self.tr("Git Error"), errMsg) if output: self.__projectRevision = output.strip() def __generateLogItem(self, author, date, committer, commitDate, subject, message, commitId, changedPaths, parents, refnames, authorMail, committerMail): """ Private method to generate a log tree entry. @param author author info (string) @param date date info (string) @param committer committer info (string) @param commitDate commit date info (string) @param subject subject of the log entry (string) @param message text of the log message (list of strings) @param commitId commit id info (string) @param changedPaths list of dictionary objects containing info about the changed files/directories @param parents list of parent revisions (list of integers) @param refnames tags and branches of the commit (string) @param authorMail author's email address (string) @param committerMail committer's email address (string) @return reference to the generated item (QTreeWidgetItem) """ branches = [] allBranches = [] tags = [] names = refnames.strip()[1:-1].split(",") for name in names: name = name.strip() if name: if "HEAD" in name: tags.append(name) elif name.startswith("tag: "): tags.append(name.split()[1]) else: if "/" not in name: branches.append(name) elif "refs/bisect/" in name: bname = name.replace("refs/", "").split("-", 1)[0] branches.append(bname) else: branches.append(name) allBranches.append(name) logMessageColumnWidth = self.vcs.getPlugin().getPreferences( "LogSubjectColumnWidth") msgtxt = subject if logMessageColumnWidth and len(msgtxt) > logMessageColumnWidth: msgtxt = "{0}...".format(msgtxt[:logMessageColumnWidth]) columnLabels = [ "", commitId, author, date.rsplit(None, 1)[0].rsplit(":", 1)[0], committer, commitDate.rsplit(None, 1)[0].rsplit(":", 1)[0], msgtxt, ", ".join(branches), ", ".join(tags), ] itm = QTreeWidgetItem(self.logTree, columnLabels) parents = [p.strip() for p in parents.split()] column, color, edges = self.__generateEdges(commitId, parents) itm.setData(0, self.__subjectRole, subject) itm.setData(0, self.__messageRole, message) itm.setData(0, self.__changesRole, changedPaths) itm.setData(0, self.__edgesRole, edges) itm.setData(0, self.__branchesRole, allBranches) itm.setData(0, self.__authorMailRole, authorMail) itm.setData(0, self.__committerMailRole, committerMail) if not parents: itm.setData(0, self.__parentsRole, []) else: itm.setData(0, self.__parentsRole, parents) for parent in parents: self.__childrenInfo[parent].append(commitId) if self.logTree.topLevelItemCount() > 1: topedges = \ self.logTree.topLevelItem( self.logTree.indexOfTopLevelItem(itm) - 1)\ .data(0, self.__edgesRole) else: topedges = None icon = self.__generateIcon(column, color, edges, topedges, QColor("blue"), commitId == self.__projectRevision) itm.setIcon(0, icon) return itm def __generateFileItem(self, action, path, copyfrom, additions, deletions): """ Private method to generate a changed files tree entry. @param action indicator for the change action ("A", "C", "D", "M", "R", "T", "U", "X") @param path path of the file in the repository (string) @param copyfrom path the file was copied from (string) @param additions number of added lines (int) @param deletions number of deleted lines (int) @return reference to the generated item (QTreeWidgetItem) """ if len(action) > 1: # includes confidence level confidence = int(action[1:]) actionTxt = self.tr("{0} ({1}%)", "action, confidence").format( self.flags[action[0]], confidence) else: actionTxt = self.flags[action] itm = QTreeWidgetItem(self.filesTree, [ actionTxt, path, str(additions), str(deletions), copyfrom, ]) itm.setTextAlignment(2, Qt.AlignRight) itm.setTextAlignment(3, Qt.AlignRight) return itm def __getLogEntries(self, skip=0, noEntries=0): """ Private method to retrieve log entries from the repository. @param skip number of log entries to skip (integer) @keyparam noEntries number of entries to get (0 = default) (int) """ 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.buf = [] self.cancelled = False self.errors.clear() self.intercept = False if noEntries == 0: noEntries = self.limitSpinBox.value() args = self.vcs.initCommand("log") args.append('--max-count={0}'.format(noEntries)) args.append('--numstat') args.append('--abbrev={0}'.format( self.vcs.getPlugin().getPreferences("CommitIdLength"))) if self.vcs.getPlugin().getPreferences("FindCopiesHarder"): args.append('--find-copies-harder') args.append('--format={0}'.format(self.__formatTemplate)) args.append('--full-history') args.append('--all') args.append('--skip={0}'.format(skip)) if not self.projectMode: if not self.stopCheckBox.isChecked(): args.append('--follow') args.append('--') args.append(self.__filename) self.process.kill() self.process.setWorkingDirectory(self.repodir) self.process.start('git', args) procStarted = self.process.waitForStarted(5000) if not procStarted: self.inputGroup.setEnabled(False) self.inputGroup.hide() E5MessageBox.critical( self, self.tr('Process Generation Error'), self.tr( 'The process {0} could not be started. ' 'Ensure, that it is in the search path.' ).format('git')) def start(self, fn, isFile=False, noEntries=0): """ Public slot to start the git log command. @param fn filename to show the log for (string) @keyparam isFile flag indicating log for a file is to be shown (boolean) @keyparam noEntries number of entries to get (0 = default) (int) """ self.__isFile = isFile self.sbsSelectLabel.clear() self.errorGroup.hide() QApplication.processEvents() self.__initData() 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 os.path.splitdrive(self.repodir)[1] == os.sep: return self.projectMode = (self.fname == "." and self.dname == self.repodir) self.stopCheckBox.setDisabled(self.projectMode or self.fname == ".") self.activateWindow() self.raise_() self.logTree.clear() self.__started = True self.__identifyProject() self.__getLogEntries(noEntries=noEntries) 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() self.refreshButton.setEnabled(True) while self.__finishCallbacks: self.__finishCallbacks.pop(0)() def __processBufferItem(self, logEntry): """ Private method to process a log entry. @param logEntry dictionary as generated by __processBuffer """ self.__generateLogItem( logEntry["author"], logEntry["authordate"], logEntry["committer"], logEntry["committerdate"], logEntry["subject"], logEntry["body"], logEntry["commit"], logEntry["changed_files"], logEntry["parents"], logEntry["refnames"], logEntry["authormail"], logEntry["committermail"] ) for date in [logEntry["authordate"], logEntry["committerdate"]]: dt = QDate.fromString(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 def __processBuffer(self): """ Private method to process the buffered output of the git log command. """ noEntries = 0 logEntry = {"changed_files": []} descriptionBody = False for line in self.buf: line = line.rstrip() if line == "recordstart": if len(logEntry) > 1: self.__processBufferItem(logEntry) noEntries += 1 logEntry = {"changed_files": []} descriptionBody = False fileChanges = False body = [] elif line == "bodystart": descriptionBody = True elif line == "bodyend": if bool(body) and not bool(body[-1]): body.pop() logEntry["body"] = body descriptionBody = False fileChanges = True elif descriptionBody: body.append(line) elif fileChanges: if line: if "changed_files" not in logEntry: logEntry["changed_files"] = [] changeInfo = line.strip().split("\t") if "=>" in changeInfo[2]: # copy/move if "{" in changeInfo[2] and "}" in changeInfo[2]: # change info of the form # test/{pack1 => pack2}/file1.py head, tail = changeInfo[2].split("{", 1) middle, tail = tail.split("}", 1) middleSrc, middleDst = middle.split("=>") src = head + middleSrc.strip() + tail dst = head + middleDst.strip() + tail else: src, dst = changeInfo[2].split("=>") logEntry["changed_files"].append({ "action": "C", "added": changeInfo[0].strip(), "deleted": changeInfo[1].strip(), "path": dst.strip(), "copyfrom": src.strip(), }) else: logEntry["changed_files"].append({ "action": "M", "added": changeInfo[0].strip(), "deleted": changeInfo[1].strip(), "path": changeInfo[2].strip(), "copyfrom": "", }) else: try: key, value = line.split("|", 1) except ValueError: key = "" value = line if key in ("commit", "parents", "author", "authormail", "authordate", "committer", "committermail", "committerdate", "refnames", "subject"): logEntry[key] = value.strip() if len(logEntry) > 1: self.__processBufferItem(logEntry) noEntries += 1 self.__resizeColumnsLog() if self.__started: if self.__selectedCommitIDs: self.logTree.setCurrentItem(self.logTree.findItems( self.__selectedCommitIDs[0], Qt.MatchExactly, self.CommitIdColumn)[0]) else: self.logTree.setCurrentItem(self.logTree.topLevelItem(0)) self.__started = False self.__skipEntries += noEntries if noEntries < self.limitSpinBox.value() and not self.cancelled: self.nextButton.setEnabled(False) self.limitSpinBox.setEnabled(False) else: self.nextButton.setEnabled(True) self.limitSpinBox.setEnabled(True) # update the log filters 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 if self.__actionMode() == "filter": self.__filterLogs() self.__updateToolMenuActions() # restore selected items if self.__selectedCommitIDs: for commitID in self.__selectedCommitIDs: items = self.logTree.findItems( commitID, Qt.MatchExactly, self.CommitIdColumn) if items: items[0].setSelected(True) self.__selectedCommitIDs = [] 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: s = str(self.process.readAllStandardError(), Preferences.getSystem("IOEncoding"), 'replace') self.__showError(s) def __showError(self, out): """ Private slot to show some error. @param out error to be shown (string) """ self.errorGroup.show() self.errors.insertPlainText(out) self.errors.ensureCursorVisible() # show input in case the process asked for some input self.inputGroup.setEnabled(True) self.inputGroup.show() 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() elif button == self.refreshButton: self.on_refreshButton_clicked() @pyqtSlot() def on_refreshButton_clicked(self): """ Private slot to refresh the log. """ self.buttonBox.button(QDialogButtonBox.Close).setEnabled(False) self.buttonBox.button(QDialogButtonBox.Cancel).setEnabled(True) self.buttonBox.button(QDialogButtonBox.Cancel).setDefault(True) self.refreshButton.setEnabled(False) # save the selected items commit IDs self.__selectedCommitIDs = [] for item in self.logTree.selectedItems(): self.__selectedCommitIDs.append(item.text(self.CommitIdColumn)) self.start(self.__filename, isFile=self.__isFile, noEntries=self.logTree.topLevelItemCount()) 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 git process. """ inputTxt = self.input.text() inputTxt += os.linesep if self.passwordCheckBox.isChecked(): self.errors.insertPlainText(os.linesep) self.errors.ensureCursorVisible() else: self.errors.insertPlainText(inputTxt) self.errors.ensureCursorVisible() self.errorGroup.show() self.process.write(strToQByteArray(inputTxt)) 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 super(GitLogBrowserDialog, self).keyPressEvent(evt) def __prepareFieldSearch(self): """ Private slot to prepare the filed search data. @return tuple of field index, search expression and flag indicating that the field index is a data role (integer, string, boolean) """ indexIsRole = False txt = self.fieldCombo.itemData(self.fieldCombo.currentIndex()) if txt == "author": fieldIndex = self.AuthorColumn searchRx = QRegExp(self.rxEdit.text(), Qt.CaseInsensitive) elif txt == "committer": fieldIndex = self.CommitterColumn searchRx = QRegExp(self.rxEdit.text(), Qt.CaseInsensitive) elif txt == "commitId": fieldIndex = self.CommitIdColumn txt = self.rxEdit.text() if txt.startswith("^"): searchRx = QRegExp(r"^\s*{0}".format(txt[1:]), Qt.CaseInsensitive) else: searchRx = QRegExp(txt, Qt.CaseInsensitive) elif txt == "file": fieldIndex = self.__changesRole searchRx = QRegExp(self.rxEdit.text(), Qt.CaseInsensitive) indexIsRole = True else: fieldIndex = self.__subjectRole searchRx = QRegExp(self.rxEdit.text(), Qt.CaseInsensitive) indexIsRole = True return fieldIndex, searchRx, indexIsRole 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") fieldIndex, searchRx, indexIsRole = self.__prepareFieldSearch() visibleItemCount = self.logTree.topLevelItemCount() currentItem = self.logTree.currentItem() for topIndex in range(self.logTree.topLevelItemCount()): topItem = self.logTree.topLevelItem(topIndex) if indexIsRole: if fieldIndex == self.__changesRole: changes = topItem.data(0, self.__changesRole) txt = "\n".join( [c["path"] for c in changes] + [c["copyfrom"] for c in changes] ) else: # Filter based on complete subject text txt = topItem.data(0, self.__subjectRole) else: txt = topItem.text(fieldIndex) if topItem.text(self.DateColumn) <= to_ and \ topItem.text(self.DateColumn) >= from_ and \ searchRx.indexIn(txt) > -1: topItem.setHidden(False) if topItem is currentItem: self.on_logTree_currentItemChanged(topItem, None) else: topItem.setHidden(True) if topItem is currentItem: self.filesTree.clear() visibleItemCount -= 1 self.logTree.header().setSectionHidden( self.IconColumn, visibleItemCount != self.logTree.topLevelItemCount()) def __updateSbsSelectLabel(self): """ Private slot to update the enabled status of the diff buttons. """ self.sbsSelectLabel.clear() if self.__isFile: selectedItems = self.logTree.selectedItems() if len(selectedItems) == 1: currentItem = selectedItems[0] commit2 = currentItem.text(self.CommitIdColumn).strip() parents = currentItem.data(0, self.__parentsRole) if parents: parentLinks = [] for index in range(len(parents)): parentLinks.append( '<a href="sbsdiff:{0}_{1}"> {2} </a>' .format(parents[index], commit2, index + 1)) self.sbsSelectLabel.setText( self.tr('Side-by-Side Diff to Parent {0}').format( " ".join(parentLinks))) elif len(selectedItems) == 2: commit2 = selectedItems[0].text(self.CommitIdColumn) commit1 = selectedItems[1].text(self.CommitIdColumn) index2 = self.logTree.indexOfTopLevelItem(selectedItems[0]) index1 = self.logTree.indexOfTopLevelItem(selectedItems[1]) if index2 < index1: # swap to always compare old to new commit1, commit2 = commit2, commit1 self.sbsSelectLabel.setText(self.tr( '<a href="sbsdiff:{0}_{1}">Side-by-Side Compare</a>') .format(commit1, commit2)) def __updateToolMenuActions(self): """ Private slot to update the status of the tool menu actions and the tool menu button. """ if self.projectMode: selectCount = len(self.logTree.selectedItems()) self.__cherryAct.setEnabled(selectCount > 0) self.__describeAct.setEnabled(selectCount > 0) self.__tagAct.setEnabled(selectCount == 1) self.__switchAct.setEnabled(selectCount == 1) self.__branchAct.setEnabled(selectCount == 1) self.__branchSwitchAct.setEnabled(selectCount == 1) self.__shortlogAct.setEnabled(selectCount == 1) self.actionsButton.setEnabled(True) else: self.actionsButton.setEnabled(False) def __updateDetailsAndFiles(self): """ Private slot to update the details and file changes panes. """ self.detailsEdit.clear() self.filesTree.clear() self.__diffUpdatesFiles = False selectedItems = self.logTree.selectedItems() if len(selectedItems) == 1: self.detailsEdit.setHtml( self.__generateDetailsTableText(selectedItems[0])) self.__updateFilesTree(self.filesTree, selectedItems[0]) self.__resizeColumnsFiles() self.__resortFiles() if self.filesTree.topLevelItemCount() == 0: self.__diffUpdatesFiles = True # give diff a chance to update the files list elif len(selectedItems) == 2: self.__diffUpdatesFiles = True index1 = self.logTree.indexOfTopLevelItem(selectedItems[0]) index2 = self.logTree.indexOfTopLevelItem(selectedItems[1]) if index1 > index2: # Swap the entries selectedItems[0], selectedItems[1] = \ selectedItems[1], selectedItems[0] html = "{0}<hr/>{1}".format( self.__generateDetailsTableText(selectedItems[0]), self.__generateDetailsTableText(selectedItems[1]), ) self.detailsEdit.setHtml(html) # self.filesTree is updated by the diff def __generateDetailsTableText(self, itm): """ Private method to generate an HTML table with the details of the given changeset. @param itm reference to the item the table should be based on @type QTreeWidgetItem @return HTML table containing details @rtype str """ if itm is not None: commitId = itm.text(self.CommitIdColumn) parentLinks = [] for parent in [str(x) for x in itm.data(0, self.__parentsRole)]: parentLinks.append('<a href="rev:{0}">{0}</a>'.format(parent)) if parentLinks: parentsStr = self.__parentsTemplate.format( ", ".join(parentLinks)) else: parentsStr = "" childLinks = [] for child in [str(x) for x in self.__childrenInfo[commitId]]: childLinks.append('<a href="rev:{0}">{0}</a>'.format(child)) if childLinks: childrenStr = self.__childrenTemplate.format( ", ".join(childLinks)) else: childrenStr = "" branchLinks = [] for branch, branchHead in self.__getBranchesForCommit(commitId): branchLinks.append('<a href="rev:{0}">{1}</a>'.format( branchHead, branch)) if branchLinks: branchesStr = self.__branchesTemplate.format( ", ".join(branchLinks)) else: branchesStr = "" tagLinks = [] for tag, tagCommit in self.__getTagsForCommit(commitId): if tagCommit: tagLinks.append('<a href="rev:{0}">{1}</a>'.format( tagCommit, tag)) else: tagLinks.append(tag) if tagLinks: tagsStr = self.__tagsTemplate.format( ", ".join(tagLinks)) else: tagsStr = "" if itm.data(0, self.__messageRole): messageStr = self.__mesageTemplate.format( "<br/>".join(itm.data(0, self.__messageRole))) else: messageStr = "" html = self.__detailsTemplate.format( commitId, itm.text(self.DateColumn), itm.text(self.AuthorColumn), itm.data(0, self.__authorMailRole).strip(), itm.text(self.CommitDateColumn), itm.text(self.CommitterColumn), itm.data(0, self.__committerMailRole).strip(), parentsStr + childrenStr + branchesStr + tagsStr, itm.data(0, self.__subjectRole), messageStr, ) else: html = "" return html def __updateFilesTree(self, parent, itm): """ Private method to update the files tree with changes of the given item. @param parent parent for the items to be added @type QTreeWidget or QTreeWidgetItem @param itm reference to the item the update should be based on @type QTreeWidgetItem """ if itm is not None: changes = itm.data(0, self.__changesRole) if len(changes) > 0: for change in changes: self.__generateFileItem( change["action"], change["path"], change["copyfrom"], change["added"], change["deleted"]) self.__resizeColumnsFiles() self.__resortFiles() def __getBranchesForCommit(self, commitId): """ Private method to get all branches reachable from a commit ID. @param commitId commit ID to get the branches for @type str @return list of tuples containing the branch name and the associated commit ID of its branch head @rtype tuple of (str, str) """ branches = [] args = self.vcs.initCommand("branch") args.append("--list") args.append("--verbose") args.append("--contains") args.append(commitId) output = "" process = QProcess() process.setWorkingDirectory(self.repodir) process.start('git', args) procStarted = process.waitForStarted(5000) if procStarted: finished = process.waitForFinished(30000) if finished and process.exitCode() == 0: output = str(process.readAllStandardOutput(), Preferences.getSystem("IOEncoding"), 'replace') if output: for line in output.splitlines(): name, commitId = line[2:].split(None, 2)[:2] branches.append((name, commitId)) return branches def __getTagsForCommit(self, commitId): """ Private method to get all tags reachable from a commit ID. @param commitId commit ID to get the tags for @type str @return list of tuples containing the tag name and the associated commit ID @rtype tuple of (str, str) """ tags = [] args = self.vcs.initCommand("tag") args.append("--list") args.append("--contains") args.append(commitId) output = "" process = QProcess() process.setWorkingDirectory(self.repodir) process.start('git', args) procStarted = process.waitForStarted(5000) if procStarted: finished = process.waitForFinished(30000) if finished and process.exitCode() == 0: output = str(process.readAllStandardOutput(), Preferences.getSystem("IOEncoding"), 'replace') if output: tagNames = [] for line in output.splitlines(): tagNames.append(line.strip()) # determine the commit IDs for the tags for tagName in tagNames: commitId = self.__getCommitForTag(tagName) tags.append((tagName, commitId)) return tags def __getCommitForTag(self, tag): """ Private method to get the commit id for a tag. @param tag tag name (string) @return commit id shortened to 10 characters (string) """ args = self.vcs.initCommand("show") args.append("--abbrev-commit") args.append("--abbrev={0}".format( self.vcs.getPlugin().getPreferences("CommitIdLength"))) args.append("--no-patch") args.append(tag) output = "" process = QProcess() process.setWorkingDirectory(self.repodir) process.start('git', args) procStarted = process.waitForStarted(5000) if procStarted: finished = process.waitForFinished(30000) if finished and process.exitCode() == 0: output = str(process.readAllStandardOutput(), Preferences.getSystem("IOEncoding"), 'replace') if output: for line in output.splitlines(): if line.startswith("commit "): commitId = line.split()[1].strip() return commitId return "" @pyqtSlot(QPoint) def on_logTree_customContextMenuRequested(self, pos): """ Private slot to show the context menu of the log tree. @param pos position of the mouse pointer (QPoint) """ self.__logTreeMenu.popup(self.logTree.mapToGlobal(pos)) @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.__updateToolMenuActions() # Highlight the current entry using a bold font for col in range(self.logTree.columnCount()): current and current.setFont(col, self.__logTreeBoldFont) previous and previous.setFont(col, self.__logTreeNormalFont) # set the state of the up and down buttons self.upButton.setEnabled( current is not None and self.logTree.indexOfTopLevelItem(current) > 0) self.downButton.setEnabled( current is not None and len(current.data(0, self.__parentsRole)) > 0 and (self.logTree.indexOfTopLevelItem(current) < self.logTree.topLevelItemCount() - 1 or self.nextButton.isEnabled())) @pyqtSlot() def on_logTree_itemSelectionChanged(self): """ Private slot called, when the selection has changed. """ self.__updateDetailsAndFiles() self.__updateSbsSelectLabel() self.__updateToolMenuActions() self.__generateDiffs() @pyqtSlot() def on_upButton_clicked(self): """ Private slot to move the current item up one entry. """ itm = self.logTree.itemAbove(self.logTree.currentItem()) if itm: self.logTree.setCurrentItem(itm) @pyqtSlot() def on_downButton_clicked(self): """ Private slot to move the current item down one entry. """ itm = self.logTree.itemBelow(self.logTree.currentItem()) if itm: self.logTree.setCurrentItem(itm) else: # load the next bunch and try again if self.nextButton.isEnabled(): self.__addFinishCallback(self.on_downButton_clicked) self.on_nextButton_clicked() @pyqtSlot() def on_nextButton_clicked(self): """ Private slot to handle the Next button. """ if self.__skipEntries > 0 and self.nextButton.isEnabled(): self.__getLogEntries(skip=self.__skipEntries) @pyqtSlot(QDate) def on_fromDate_dateChanged(self, date): """ Private slot called, when the from date changes. @param date new date (QDate) """ if self.__actionMode() == "filter": self.__filterLogs() @pyqtSlot(QDate) def on_toDate_dateChanged(self, date): """ Private slot called, when the from date changes. @param date new date (QDate) """ if self.__actionMode() == "filter": 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) """ if self.__actionMode() == "filter": self.__filterLogs() @pyqtSlot(str) def on_rxEdit_textChanged(self, txt): """ Private slot called, when a filter expression is entered. @param txt filter expression (string) """ if self.__actionMode() == "filter": self.__filterLogs() elif self.__actionMode() == "find": self.__findItem(self.__findBackwards, interactive=True) @pyqtSlot() def on_rxEdit_returnPressed(self): """ Private slot handling a press of the Return key in the rxEdit input. """ if self.__actionMode() == "find": self.__findItem(self.__findBackwards, interactive=True) @pyqtSlot(bool) def on_stopCheckBox_clicked(self, checked): """ Private slot called, when the stop on copy/move checkbox is clicked. @param checked flag indicating the state of the check box (boolean) """ self.vcs.getPlugin().setPreferences("StopLogOnCopy", self.stopCheckBox.isChecked()) self.nextButton.setEnabled(True) self.limitSpinBox.setEnabled(True) ################################################################## ## Tool button menu action methods below ################################################################## @pyqtSlot() def __cherryActTriggered(self): """ Private slot to handle the Copy Commits action. """ commits = {} for itm in self.logTree.selectedItems(): index = self.logTree.indexOfTopLevelItem(itm) commits[index] = itm.text(self.CommitIdColumn) if commits: pfile = e5App().getObject("Project").getProjectFile() lastModified = QFileInfo(pfile).lastModified().toString() shouldReopen = ( self.vcs.gitCherryPick( self.repodir, [commits[i] for i in sorted(commits.keys(), reverse=True)] ) or QFileInfo(pfile).lastModified().toString() != lastModified ) if shouldReopen: res = E5MessageBox.yesNo( None, self.tr("Copy Changesets"), self.tr( """The project should be reread. Do this now?"""), yesDefault=True) if res: e5App().getObject("Project").reopenProject() return self.on_refreshButton_clicked() @pyqtSlot() def __tagActTriggered(self): """ Private slot to tag the selected commit. """ if len(self.logTree.selectedItems()) == 1: itm = self.logTree.selectedItems()[0] commit = itm.text(self.CommitIdColumn) tag = itm.text(self.TagsColumn).strip().split(", ", 1)[0] res = self.vcs.vcsTag(self.repodir, revision=commit, tagName=tag) if res: self.on_refreshButton_clicked() @pyqtSlot() def __switchActTriggered(self): """ Private slot to switch the working directory to the selected commit. """ if len(self.logTree.selectedItems()) == 1: itm = self.logTree.selectedItems()[0] commit = itm.text(self.CommitIdColumn) branches = [b for b in itm.text(self.BranchColumn).split(", ") if "/" not in b] if len(branches) == 1: branch = branches[0] elif len(branches) > 1: branch, ok = QInputDialog.getItem( self, self.tr("Switch"), self.tr("Select a branch"), [""] + branches, 0, False) if not ok: return else: branch = "" if branch: rev = branch else: rev = commit pfile = e5App().getObject("Project").getProjectFile() lastModified = QFileInfo(pfile).lastModified().toString() shouldReopen = ( self.vcs.vcsUpdate(self.repodir, revision=rev) or QFileInfo(pfile).lastModified().toString() != lastModified ) if shouldReopen: res = E5MessageBox.yesNo( None, self.tr("Switch"), self.tr( """The project should be reread. Do this now?"""), yesDefault=True) if res: e5App().getObject("Project").reopenProject() return self.on_refreshButton_clicked() @pyqtSlot() def __branchActTriggered(self): """ Private slot to create a new branch starting at the selected commit. """ if len(self.logTree.selectedItems()) == 1: from .GitBranchDialog import GitBranchDialog itm = self.logTree.selectedItems()[0] commit = itm.text(self.CommitIdColumn) branches = [b for b in itm.text(self.BranchColumn).split(", ") if "/" not in b] if len(branches) == 1: branch = branches[0] elif len(branches) > 1: branch, ok = QInputDialog.getItem( self, self.tr("Branch"), self.tr("Select a default branch"), [""] + branches, 0, False) if not ok: return else: branch = "" res = self.vcs.gitBranch( self.repodir, revision=commit, branchName=branch, branchOp=GitBranchDialog.CreateBranch) if res: self.on_refreshButton_clicked() @pyqtSlot() def __branchSwitchActTriggered(self): """ Private slot to create a new branch starting at the selected commit and switch the work tree to it. """ if len(self.logTree.selectedItems()) == 1: from .GitBranchDialog import GitBranchDialog itm = self.logTree.selectedItems()[0] commit = itm.text(self.CommitIdColumn) branches = [b for b in itm.text(self.BranchColumn).split(", ") if "/" not in b] if len(branches) == 1: branch = branches[0] elif len(branches) > 1: branch, ok = QInputDialog.getItem( self, self.tr("Branch & Switch"), self.tr("Select a default branch"), [""] + branches, 0, False) if not ok: return else: branch = "" pfile = e5App().getObject("Project").getProjectFile() lastModified = QFileInfo(pfile).lastModified().toString() res, shouldReopen = self.vcs.gitBranch( self.repodir, revision=commit, branchName=branch, branchOp=GitBranchDialog.CreateSwitchBranch) shouldReopen = shouldReopen or \ QFileInfo(pfile).lastModified().toString() != lastModified if res: if shouldReopen: res = E5MessageBox.yesNo( None, self.tr("Switch"), self.tr( """The project should be reread. Do this now?"""), yesDefault=True) if res: e5App().getObject("Project").reopenProject() return self.on_refreshButton_clicked() @pyqtSlot() def __shortlogActTriggered(self): """ Private slot to show a short log suitable for release announcements. """ if len(self.logTree.selectedItems()) == 1: itm = self.logTree.selectedItems()[0] commit = itm.text(self.CommitIdColumn) branch = itm.text(self.BranchColumn).split(", ", 1)[0] branches = [b for b in itm.text(self.BranchColumn).split(", ") if "/" not in b] if len(branches) == 1: branch = branches[0] elif len(branches) > 1: branch, ok = QInputDialog.getItem( self, self.tr("Show Short Log"), self.tr("Select a branch"), [""] + branches, 0, False) if not ok: return else: branch = "" if branch: rev = branch else: rev = commit self.vcs.gitShortlog(self.repodir, commit=rev) @pyqtSlot() def __describeActTriggered(self): """ Private slot to show the most recent tag reachable from a commit. """ commits = [] for itm in self.logTree.selectedItems(): commits.append(itm.text(self.CommitIdColumn)) if commits: self.vcs.gitDescribe(self.repodir, commits) ################################################################## ## Log context menu action methods below ################################################################## @pyqtSlot(bool) def __showCommitterColumns(self, on): """ Private slot to show/hide the committer columns. @param on flag indicating the selection state (boolean) """ self.logTree.setColumnHidden(self.CommitterColumn, not on) self.logTree.setColumnHidden(self.CommitDateColumn, not on) self.vcs.getPlugin().setPreferences("ShowCommitterColumns", on) self.__resizeColumnsLog() @pyqtSlot(bool) def __showAuthorColumns(self, on): """ Private slot to show/hide the committer columns. @param on flag indicating the selection state (boolean) """ self.logTree.setColumnHidden(self.AuthorColumn, not on) self.logTree.setColumnHidden(self.DateColumn, not on) self.vcs.getPlugin().setPreferences("ShowAuthorColumns", on) self.__resizeColumnsLog() @pyqtSlot(bool) def __showCommitIdColumn(self, on): """ Private slot to show/hide the commit ID column. @param on flag indicating the selection state (boolean) """ self.logTree.setColumnHidden(self.CommitIdColumn, not on) self.vcs.getPlugin().setPreferences("ShowCommitIdColumn", on) self.__resizeColumnsLog() @pyqtSlot(bool) def __showBranchesColumn(self, on): """ Private slot to show/hide the branches column. @param on flag indicating the selection state (boolean) """ self.logTree.setColumnHidden(self.BranchColumn, not on) self.vcs.getPlugin().setPreferences("ShowBranchesColumn", on) self.__resizeColumnsLog() @pyqtSlot(bool) def __showTagsColumn(self, on): """ Private slot to show/hide the tags column. @param on flag indicating the selection state (boolean) """ self.logTree.setColumnHidden(self.TagsColumn, not on) self.vcs.getPlugin().setPreferences("ShowTagsColumn", on) self.__resizeColumnsLog() ################################################################## ## Search and filter methods below ################################################################## def __actionMode(self): """ Private method to get the selected action mode. @return selected action mode (string, one of filter or find) """ return self.modeComboBox.itemData( self.modeComboBox.currentIndex()) @pyqtSlot(int) def on_modeComboBox_currentIndexChanged(self, index): """ Private slot to react on mode changes. @param index index of the selected entry (integer) """ mode = self.modeComboBox.itemData(index) findMode = mode == "find" filterMode = mode == "filter" self.fromDate.setEnabled(filterMode) self.toDate.setEnabled(filterMode) self.findPrevButton.setVisible(findMode) self.findNextButton.setVisible(findMode) if findMode: for topIndex in range(self.logTree.topLevelItemCount()): self.logTree.topLevelItem(topIndex).setHidden(False) self.logTree.header().setSectionHidden(self.IconColumn, False) elif filterMode: self.__filterLogs() @pyqtSlot() def on_findPrevButton_clicked(self): """ Private slot to find the previous item matching the entered criteria. """ self.__findItem(True) @pyqtSlot() def on_findNextButton_clicked(self): """ Private slot to find the next item matching the entered criteria. """ self.__findItem(False) def __findItem(self, backwards=False, interactive=False): """ Private slot to find an item matching the entered criteria. @param backwards flag indicating to search backwards (boolean) @param interactive flag indicating an interactive search (boolean) """ self.__findBackwards = backwards fieldIndex, searchRx, indexIsRole = self.__prepareFieldSearch() currentIndex = self.logTree.indexOfTopLevelItem( self.logTree.currentItem()) if backwards: if interactive: indexes = range(currentIndex, -1, -1) else: indexes = range(currentIndex - 1, -1, -1) else: if interactive: indexes = range(currentIndex, self.logTree.topLevelItemCount()) else: indexes = range(currentIndex + 1, self.logTree.topLevelItemCount()) for index in indexes: topItem = self.logTree.topLevelItem(index) if indexIsRole: if fieldIndex == self.__changesRole: changes = topItem.data(0, self.__changesRole) txt = "\n".join( [c["path"] for c in changes] + [c["copyfrom"] for c in changes] ) else: # Filter based on complete subject text txt = topItem.data(0, self.__subjectRole) else: txt = topItem.text(fieldIndex) if searchRx.indexIn(txt) > -1: self.logTree.setCurrentItem(self.logTree.topLevelItem(index)) break else: E5MessageBox.information( self, self.tr("Find Commit"), self.tr("""'{0}' was not found.""").format(self.rxEdit.text())) ################################################################## ## Commit navigation methods below ################################################################## def __commitIdClicked(self, url): """ Private slot to handle the anchorClicked signal of the changeset details pane. @param url URL that was clicked @type QUrl """ if url.scheme() == "rev": # a commit ID was clicked, show the respective item commitId = url.path() items = self.logTree.findItems(commitId, Qt.MatchStartsWith, self.CommitIdColumn) if items: itm = items[0] if itm.isHidden(): itm.setHidden(False) self.logTree.setCurrentItem(itm) else: # load the next batch and try again if self.nextButton.isEnabled(): self.__addFinishCallback( lambda: self.__commitIdClicked(url)) self.on_nextButton_clicked() ########################################################################### ## Diff handling methods below ########################################################################### def __generateDiffs(self, parent=1): """ Private slot to generate diff outputs for the selected item. @param parent number of parent to diff against @type int """ self.diffEdit.clear() self.diffLabel.setText(self.tr("Differences")) self.diffSelectLabel.clear() try: self.diffHighlighter.regenerateRules() except AttributeError: # backward compatibility pass selectedItems = self.logTree.selectedItems() if len(selectedItems) == 1: currentItem = selectedItems[0] commit2 = currentItem.text(self.CommitIdColumn) parents = currentItem.data(0, self.__parentsRole) if len(parents) >= parent: self.diffLabel.setText( self.tr("Differences to Parent {0}").format(parent)) commit1 = parents[parent - 1] self.__diffGenerator.start(self.__filename, [commit1, commit2]) if len(parents) > 1: parentLinks = [] for index in range(1, len(parents) + 1): if parent == index: parentLinks.append(" {0} ".format(index)) else: parentLinks.append( '<a href="diff:{0}"> {0} </a>' .format(index)) self.diffSelectLabel.setText( self.tr('Diff to Parent {0}') .format(" ".join(parentLinks))) elif len(selectedItems) == 2: commit2 = selectedItems[0].text(self.CommitIdColumn) commit1 = selectedItems[1].text(self.CommitIdColumn) index2 = self.logTree.indexOfTopLevelItem(selectedItems[0]) index1 = self.logTree.indexOfTopLevelItem(selectedItems[1]) if index2 < index1: # swap to always compare old to new commit1, commit2 = commit2, commit1 self.__diffGenerator.start(self.__filename, [commit1, commit2]) def __generatorFinished(self): """ Private slot connected to the finished signal of the diff generator. """ diff, _, errors, fileSeparators = self.__diffGenerator.getResult() if diff: self.diffEdit.setPlainText("".join(diff)) elif errors: self.diffEdit.setPlainText("".join(errors)) else: self.diffEdit.setPlainText(self.tr('There is no difference.')) self.saveLabel.setVisible(bool(diff)) fileSeparators = self.__mergeFileSeparators(fileSeparators) if self.__diffUpdatesFiles: for oldFileName, newFileName, lineNumber, _ in fileSeparators: if oldFileName == newFileName: item = QTreeWidgetItem(self.filesTree, ["", oldFileName]) elif oldFileName == "/dev/null": item = QTreeWidgetItem(self.filesTree, ["", newFileName]) else: item = QTreeWidgetItem( self.filesTree, ["", newFileName, "", "", oldFileName]) item.setData(0, self.__diffFileLineRole, lineNumber) self.__resizeColumnsFiles() self.__resortFiles() else: for oldFileName, newFileName, lineNumber, _ in fileSeparators: for fileName in (oldFileName, newFileName): if fileName != "/dev/null": items = self.filesTree.findItems( fileName, Qt.MatchExactly, 1) for item in items: item.setData(0, self.__diffFileLineRole, lineNumber) tc = self.diffEdit.textCursor() tc.movePosition(QTextCursor.Start) self.diffEdit.setTextCursor(tc) self.diffEdit.ensureCursorVisible() def __mergeFileSeparators(self, fileSeparators): """ Private method to merge the file separator entries. @param fileSeparators list of file separator entries to be merged @return merged list of file separator entries """ separators = {} for oldFile, newFile, pos1, pos2 in sorted(fileSeparators): if (oldFile, newFile) not in separators: separators[(oldFile, newFile)] = [oldFile, newFile, pos1, pos2] else: if pos1 != -2: separators[(oldFile, newFile)][2] = pos1 if pos2 != -2: separators[(oldFile, newFile)][3] = pos2 return list(separators.values()) @pyqtSlot(QTreeWidgetItem, QTreeWidgetItem) def on_filesTree_currentItemChanged(self, current, previous): """ Private slot called, when the current item of the files tree changes. @param current reference to the new current item (QTreeWidgetItem) @param previous reference to the old current item (QTreeWidgetItem) """ if current: para = current.data(0, self.__diffFileLineRole) if para is not None: if para == 0: tc = self.diffEdit.textCursor() tc.movePosition(QTextCursor.Start) self.diffEdit.setTextCursor(tc) self.diffEdit.ensureCursorVisible() elif para == -1: tc = self.diffEdit.textCursor() tc.movePosition(QTextCursor.End) self.diffEdit.setTextCursor(tc) self.diffEdit.ensureCursorVisible() else: # step 1: move cursor to end tc = self.diffEdit.textCursor() tc.movePosition(QTextCursor.End) self.diffEdit.setTextCursor(tc) self.diffEdit.ensureCursorVisible() # step 2: move cursor to desired line tc = self.diffEdit.textCursor() delta = tc.blockNumber() - para tc.movePosition(QTextCursor.PreviousBlock, QTextCursor.MoveAnchor, delta) self.diffEdit.setTextCursor(tc) self.diffEdit.ensureCursorVisible() @pyqtSlot(str) def on_diffSelectLabel_linkActivated(self, link): """ Private slot to handle the selection of a diff target. @param link activated link @type str """ if ":" in link: scheme, parent = link.split(":", 1) if scheme == "diff": try: parent = int(parent) self.__generateDiffs(parent) except ValueError: # ignore silently pass @pyqtSlot(str) def on_saveLabel_linkActivated(self, link): """ Private slot to handle the selection of the save link. @param link activated link @type str """ if ":" not in link: return scheme, rest = link.split(":", 1) if scheme != "save" or rest != "me": return if self.projectMode: fname = self.vcs.splitPath(self.__filename)[0] fname += "/{0}.diff".format(os.path.split(fname)[-1]) else: dname, fname = self.vcs.splitPath(self.__filename) if fname != '.': fname = "{0}.diff".format(self.__filename) else: fname = dname fname, selectedFilter = E5FileDialog.getSaveFileNameAndFilter( self, self.tr("Save Diff"), fname, self.tr("Patch Files (*.diff)"), None, E5FileDialog.Options(E5FileDialog.DontConfirmOverwrite)) if not fname: return # user aborted ext = QFileInfo(fname).suffix() if not ext: ex = selectedFilter.split("(*")[1].split(")")[0] if ex: fname += ex if QFileInfo(fname).exists(): res = E5MessageBox.yesNo( self, self.tr("Save Diff"), self.tr("<p>The patch file <b>{0}</b> already exists." " Overwrite it?</p>").format(fname), icon=E5MessageBox.Warning) if not res: return fname = Utilities.toNativeSeparators(fname) eol = e5App().getObject("Project").getEolString() try: f = open(fname, "w", encoding="utf-8", newline="") f.write(eol.join(self.diffEdit.toPlainText().splitlines())) f.write(eol) f.close() except IOError as why: E5MessageBox.critical( self, self.tr('Save Diff'), self.tr( '<p>The patch file <b>{0}</b> could not be saved.' '<br>Reason: {1}</p>') .format(fname, str(why))) @pyqtSlot(str) def on_sbsSelectLabel_linkActivated(self, link): """ Private slot to handle selection of a side-by-side link. @param link text of the selected link @type str """ if ":" in link: scheme, path = link.split(":", 1) if scheme == "sbsdiff" and "_" in path: commit1, commit2 = path.split("_", 1) self.vcs.gitSbsDiff(self.__filename, revisions=(commit1, commit2))