"Fossies" - the Fresh Open Source Software Archive

Member "eric6-20.9/eric/eric6/Plugins/VcsPlugins/vcsGit/GitLogBrowserDialog.py" (2 May 2020, 88198 Bytes) of package /linux/misc/eric6-20.9.tar.gz:


As a special service "Fossies" has tried to format the requested source page into HTML format using (guessed) Python source code syntax highlighting (style: standard) with prefixed line numbers. Alternatively you can here view or download the uninterpreted source code file. For more information about "GitLogBrowserDialog.py" see the Fossies "Dox" file reference documentation.

    1 # -*- coding: utf-8 -*-
    2 
    3 # Copyright (c) 2014 - 2020 Detlev Offenbach <detlev@die-offenbachs.de>
    4 #
    5 
    6 """
    7 Module implementing a dialog to browse the log history.
    8 """
    9 
   10 
   11 import os
   12 import collections
   13 
   14 from PyQt5.QtCore import (
   15     pyqtSlot, Qt, QDate, QProcess, QTimer, QRegExp, QSize, QPoint, QFileInfo
   16 )
   17 from PyQt5.QtGui import (
   18     QCursor, QColor, QPixmap, QPainter, QPen, QIcon, QTextCursor, QPalette
   19 )
   20 from PyQt5.QtWidgets import (
   21     QWidget, QDialogButtonBox, QHeaderView, QTreeWidgetItem, QApplication,
   22     QLineEdit, QMenu, QInputDialog
   23 )
   24 
   25 from E5Gui.E5Application import e5App
   26 from E5Gui import E5MessageBox, E5FileDialog
   27 
   28 from Globals import strToQByteArray
   29 
   30 from .Ui_GitLogBrowserDialog import Ui_GitLogBrowserDialog
   31 
   32 from .GitDiffHighlighter import GitDiffHighlighter
   33 from .GitDiffGenerator import GitDiffGenerator
   34 
   35 import UI.PixmapCache
   36 import Preferences
   37 import Utilities
   38 
   39 COLORNAMES = ["red", "green", "purple", "cyan", "olive", "magenta",
   40               "gray", "yellow", "darkred", "darkgreen", "darkblue",
   41               "darkcyan", "darkmagenta", "blue"]
   42 COLORS = [str(QColor(x).name()) for x in COLORNAMES]
   43 
   44 LIGHTCOLORS = ["#aaaaff", "#7faa7f", "#ffaaaa", "#aaffaa", "#7f7faa",
   45                "#ffaaff", "#aaffff", "#d5d579", "#ffaaff", "#d57979",
   46                "#d579d5", "#79d5d5", "#d5d5d5", "#d5d500",
   47                ]
   48 
   49 
   50 class GitLogBrowserDialog(QWidget, Ui_GitLogBrowserDialog):
   51     """
   52     Class implementing a dialog to browse the log history.
   53     """
   54     IconColumn = 0
   55     CommitIdColumn = 1
   56     AuthorColumn = 2
   57     DateColumn = 3
   58     CommitterColumn = 4
   59     CommitDateColumn = 5
   60     SubjectColumn = 6
   61     BranchColumn = 7
   62     TagsColumn = 8
   63     
   64     def __init__(self, vcs, parent=None):
   65         """
   66         Constructor
   67         
   68         @param vcs reference to the vcs object
   69         @param parent parent widget (QWidget)
   70         """
   71         super(GitLogBrowserDialog, self).__init__(parent)
   72         self.setupUi(self)
   73         
   74         windowFlags = self.windowFlags()
   75         windowFlags |= Qt.WindowContextHelpButtonHint
   76         self.setWindowFlags(windowFlags)
   77         
   78         self.mainSplitter.setSizes([300, 400])
   79         self.mainSplitter.setStretchFactor(0, 1)
   80         self.mainSplitter.setStretchFactor(1, 2)
   81         self.diffSplitter.setStretchFactor(0, 1)
   82         self.diffSplitter.setStretchFactor(1, 2)
   83         
   84         self.buttonBox.button(QDialogButtonBox.Close).setEnabled(False)
   85         self.buttonBox.button(QDialogButtonBox.Cancel).setDefault(True)
   86         
   87         self.filesTree.headerItem().setText(self.filesTree.columnCount(), "")
   88         self.filesTree.header().setSortIndicator(1, Qt.AscendingOrder)
   89         
   90         self.upButton.setIcon(UI.PixmapCache.getIcon("1uparrow"))
   91         self.downButton.setIcon(UI.PixmapCache.getIcon("1downarrow"))
   92         
   93         self.refreshButton = self.buttonBox.addButton(
   94             self.tr("&Refresh"), QDialogButtonBox.ActionRole)
   95         self.refreshButton.setToolTip(
   96             self.tr("Press to refresh the list of commits"))
   97         self.refreshButton.setEnabled(False)
   98         
   99         self.findPrevButton.setIcon(UI.PixmapCache.getIcon("1leftarrow"))
  100         self.findNextButton.setIcon(UI.PixmapCache.getIcon("1rightarrow"))
  101         self.__findBackwards = False
  102         
  103         self.modeComboBox.addItem(self.tr("Find"), "find")
  104         self.modeComboBox.addItem(self.tr("Filter"), "filter")
  105         
  106         self.fieldCombo.addItem(self.tr("Commit ID"), "commitId")
  107         self.fieldCombo.addItem(self.tr("Author"), "author")
  108         self.fieldCombo.addItem(self.tr("Committer"), "committer")
  109         self.fieldCombo.addItem(self.tr("Subject"), "subject")
  110         self.fieldCombo.addItem(self.tr("File"), "file")
  111         
  112         self.__logTreeNormalFont = self.logTree.font()
  113         self.__logTreeNormalFont.setBold(False)
  114         self.__logTreeBoldFont = self.logTree.font()
  115         self.__logTreeBoldFont.setBold(True)
  116         self.__logTreeHasDarkBackground = e5App().usesDarkPalette()
  117         
  118         font = Preferences.getEditorOtherFonts("MonospacedFont")
  119         self.diffEdit.setFontFamily(font.family())
  120         self.diffEdit.setFontPointSize(font.pointSize())
  121         
  122         self.diffHighlighter = GitDiffHighlighter(self.diffEdit.document())
  123         self.__diffGenerator = GitDiffGenerator(vcs, self)
  124         self.__diffGenerator.finished.connect(self.__generatorFinished)
  125         
  126         self.vcs = vcs
  127         
  128         self.__detailsTemplate = self.tr(
  129             "<table>"
  130             "<tr><td><b>Commit ID</b></td><td>{0}</td></tr>"
  131             "<tr><td><b>Date</b></td><td>{1}</td></tr>"
  132             "<tr><td><b>Author</b></td><td>{2} &lt;{3}&gt;</td></tr>"
  133             "<tr><td><b>Commit Date</b></td><td>{4}</td></tr>"
  134             "<tr><td><b>Committer</b></td><td>{5} &lt;{6}&gt;</td></tr>"
  135             "{7}"
  136             "<tr><td><b>Subject</b></td><td>{8}</td></tr>"
  137             "{9}"
  138             "</table>"
  139         )
  140         self.__parentsTemplate = self.tr(
  141             "<tr><td><b>Parents</b></td><td>{0}</td></tr>"
  142         )
  143         self.__childrenTemplate = self.tr(
  144             "<tr><td><b>Children</b></td><td>{0}</td></tr>"
  145         )
  146         self.__branchesTemplate = self.tr(
  147             "<tr><td><b>Branches</b></td><td>{0}</td></tr>"
  148         )
  149         self.__tagsTemplate = self.tr(
  150             "<tr><td><b>Tags</b></td><td>{0}</td></tr>"
  151         )
  152         self.__mesageTemplate = self.tr(
  153             "<tr><td><b>Message</b></td><td>{0}</td></tr>"
  154         )
  155         
  156         self.__formatTemplate = (
  157             'format:recordstart%n'
  158             'commit|%h%n'
  159             'parents|%p%n'
  160             'author|%an%n'
  161             'authormail|%ae%n'
  162             'authordate|%ai%n'
  163             'committer|%cn%n'
  164             'committermail|%ce%n'
  165             'committerdate|%ci%n'
  166             'refnames|%d%n'
  167             'subject|%s%n'
  168             'bodystart%n'
  169             '%b%n'
  170             'bodyend%n'
  171         )
  172         
  173         self.__filename = ""
  174         self.__isFile = False
  175         self.__selectedCommitIDs = []
  176         self.intercept = False
  177         
  178         self.__initData()
  179         
  180         self.fromDate.setDisplayFormat("yyyy-MM-dd")
  181         self.toDate.setDisplayFormat("yyyy-MM-dd")
  182         self.__resetUI()
  183         
  184         # roles used in the log tree
  185         self.__subjectRole = Qt.UserRole
  186         self.__messageRole = Qt.UserRole + 1
  187         self.__changesRole = Qt.UserRole + 2
  188         self.__edgesRole = Qt.UserRole + 3
  189         self.__parentsRole = Qt.UserRole + 4
  190         self.__branchesRole = Qt.UserRole + 5
  191         self.__authorMailRole = Qt.UserRole + 6
  192         self.__committerMailRole = Qt.UserRole + 7
  193         
  194         # roles used in the file tree
  195         self.__diffFileLineRole = Qt.UserRole
  196         
  197         self.process = QProcess()
  198         self.process.finished.connect(self.__procFinished)
  199         self.process.readyReadStandardOutput.connect(self.__readStdout)
  200         self.process.readyReadStandardError.connect(self.__readStderr)
  201         
  202         self.flags = {
  203             'A': self.tr('Added'),
  204             'D': self.tr('Deleted'),
  205             'M': self.tr('Modified'),
  206             'C': self.tr('Copied'),
  207             'R': self.tr('Renamed'),
  208             'T': self.tr('Type changed'),
  209             'U': self.tr('Unmerged'),
  210             'X': self.tr('Unknown'),
  211         }
  212         
  213         self.__dotRadius = 8
  214         self.__rowHeight = 20
  215         
  216         self.logTree.setIconSize(
  217             QSize(100 * self.__rowHeight, self.__rowHeight))
  218         
  219         self.detailsEdit.anchorClicked.connect(self.__commitIdClicked)
  220         
  221         self.__initLogTreeContextMenu()
  222         self.__initActionsMenu()
  223         
  224         self.__finishCallbacks = []
  225     
  226     def __addFinishCallback(self, callback):
  227         """
  228         Private method to add a method to be called once the process finished.
  229         
  230         The callback methods are invoke in a FIFO style and are consumed. If
  231         a callback method needs to be called again, it must be added again.
  232         
  233         @param callback callback method
  234         @type function
  235         """
  236         if callback not in self.__finishCallbacks:
  237             self.__finishCallbacks.append(callback)
  238     
  239     def __initLogTreeContextMenu(self):
  240         """
  241         Private method to initialize the log tree context menu.
  242         """
  243         self.__logTreeMenu = QMenu()
  244         
  245         # commit ID column
  246         act = self.__logTreeMenu.addAction(
  247             self.tr("Show Commit ID Column"))
  248         act.setToolTip(self.tr(
  249             "Press to show the commit ID column"))
  250         act.setCheckable(True)
  251         act.setChecked(self.vcs.getPlugin().getPreferences(
  252             "ShowCommitIdColumn"))
  253         act.triggered.connect(self.__showCommitIdColumn)
  254         
  255         # author and date columns
  256         act = self.__logTreeMenu.addAction(
  257             self.tr("Show Author Columns"))
  258         act.setToolTip(self.tr(
  259             "Press to show the author columns"))
  260         act.setCheckable(True)
  261         act.setChecked(self.vcs.getPlugin().getPreferences(
  262             "ShowAuthorColumns"))
  263         act.triggered.connect(self.__showAuthorColumns)
  264         
  265         # committer and commit date columns
  266         act = self.__logTreeMenu.addAction(
  267             self.tr("Show Committer Columns"))
  268         act.setToolTip(self.tr(
  269             "Press to show the committer columns"))
  270         act.setCheckable(True)
  271         act.setChecked(self.vcs.getPlugin().getPreferences(
  272             "ShowCommitterColumns"))
  273         act.triggered.connect(self.__showCommitterColumns)
  274         
  275         # branches column
  276         act = self.__logTreeMenu.addAction(
  277             self.tr("Show Branches Column"))
  278         act.setToolTip(self.tr(
  279             "Press to show the branches column"))
  280         act.setCheckable(True)
  281         act.setChecked(self.vcs.getPlugin().getPreferences(
  282             "ShowBranchesColumn"))
  283         act.triggered.connect(self.__showBranchesColumn)
  284         
  285         # tags column
  286         act = self.__logTreeMenu.addAction(
  287             self.tr("Show Tags Column"))
  288         act.setToolTip(self.tr(
  289             "Press to show the Tags column"))
  290         act.setCheckable(True)
  291         act.setChecked(self.vcs.getPlugin().getPreferences(
  292             "ShowTagsColumn"))
  293         act.triggered.connect(self.__showTagsColumn)
  294         
  295         # set column visibility as configured
  296         self.__showCommitIdColumn(self.vcs.getPlugin().getPreferences(
  297             "ShowCommitIdColumn"))
  298         self.__showAuthorColumns(self.vcs.getPlugin().getPreferences(
  299             "ShowAuthorColumns"))
  300         self.__showCommitterColumns(self.vcs.getPlugin().getPreferences(
  301             "ShowCommitterColumns"))
  302         self.__showBranchesColumn(self.vcs.getPlugin().getPreferences(
  303             "ShowBranchesColumn"))
  304         self.__showTagsColumn(self.vcs.getPlugin().getPreferences(
  305             "ShowTagsColumn"))
  306     
  307     def __initActionsMenu(self):
  308         """
  309         Private method to initialize the actions menu.
  310         """
  311         self.__actionsMenu = QMenu()
  312         self.__actionsMenu.setTearOffEnabled(True)
  313         self.__actionsMenu.setToolTipsVisible(True)
  314         
  315         self.__cherryAct = self.__actionsMenu.addAction(
  316             self.tr("Copy Commits"), self.__cherryActTriggered)
  317         self.__cherryAct.setToolTip(self.tr(
  318             "Cherry-pick the selected commits to the current branch"))
  319         
  320         self.__actionsMenu.addSeparator()
  321         
  322         self.__tagAct = self.__actionsMenu.addAction(
  323             self.tr("Tag"), self.__tagActTriggered)
  324         self.__tagAct.setToolTip(self.tr("Tag the selected commit"))
  325         
  326         self.__branchAct = self.__actionsMenu.addAction(
  327             self.tr("Branch"), self.__branchActTriggered)
  328         self.__branchAct.setToolTip(self.tr(
  329             "Create a new branch at the selected commit."))
  330         self.__branchSwitchAct = self.__actionsMenu.addAction(
  331             self.tr("Branch && Switch"), self.__branchSwitchActTriggered)
  332         self.__branchSwitchAct.setToolTip(self.tr(
  333             "Create a new branch at the selected commit and switch"
  334             " the work tree to it."))
  335         
  336         self.__switchAct = self.__actionsMenu.addAction(
  337             self.tr("Switch"), self.__switchActTriggered)
  338         self.__switchAct.setToolTip(self.tr(
  339             "Switch the working directory to the selected commit"))
  340         self.__actionsMenu.addSeparator()
  341         
  342         self.__shortlogAct = self.__actionsMenu.addAction(
  343             self.tr("Show Short Log"), self.__shortlogActTriggered)
  344         self.__shortlogAct.setToolTip(self.tr(
  345             "Show a dialog with a log output for release notes"))
  346         
  347         self.__describeAct = self.__actionsMenu.addAction(
  348             self.tr("Describe"), self.__describeActTriggered)
  349         self.__describeAct.setToolTip(self.tr(
  350             "Show the most recent tag reachable from a commit"))
  351         
  352         self.actionsButton.setIcon(
  353             UI.PixmapCache.getIcon("actionsToolButton"))
  354         self.actionsButton.setMenu(self.__actionsMenu)
  355     
  356     def __initData(self):
  357         """
  358         Private method to (re-)initialize some data.
  359         """
  360         self.__maxDate = QDate()
  361         self.__minDate = QDate()
  362         self.__filterLogsEnabled = True
  363         
  364         self.buf = []        # buffer for stdout
  365         self.diff = None
  366         self.__started = False
  367         self.__skipEntries = 0
  368         self.projectMode = False
  369         
  370         # attributes to store log graph data
  371         self.__commitIds = []
  372         self.__commitColors = {}
  373         self.__commitColor = 0
  374         
  375         self.__projectRevision = ""
  376         
  377         self.__childrenInfo = collections.defaultdict(list)
  378     
  379     def closeEvent(self, e):
  380         """
  381         Protected slot implementing a close event handler.
  382         
  383         @param e close event (QCloseEvent)
  384         """
  385         if (
  386             self.process is not None and
  387             self.process.state() != QProcess.NotRunning
  388         ):
  389             self.process.terminate()
  390             QTimer.singleShot(2000, self.process.kill)
  391             self.process.waitForFinished(3000)
  392         
  393         self.vcs.getPlugin().setPreferences(
  394             "LogBrowserGeometry", self.saveGeometry())
  395         self.vcs.getPlugin().setPreferences(
  396             "LogBrowserSplitterStates", [
  397                 self.mainSplitter.saveState(),
  398                 self.detailsSplitter.saveState(),
  399                 self.diffSplitter.saveState(),
  400             ]
  401         )
  402         
  403         e.accept()
  404     
  405     def show(self):
  406         """
  407         Public slot to show the dialog.
  408         """
  409         self.__reloadGeometry()
  410         self.__restoreSplitterStates()
  411         self.__resetUI()
  412         
  413         super(GitLogBrowserDialog, self).show()
  414     
  415     def __reloadGeometry(self):
  416         """
  417         Private method to restore the geometry.
  418         """
  419         geom = self.vcs.getPlugin().getPreferences("LogBrowserGeometry")
  420         if geom.isEmpty():
  421             s = QSize(1000, 800)
  422             self.resize(s)
  423         else:
  424             self.restoreGeometry(geom)
  425     
  426     def __restoreSplitterStates(self):
  427         """
  428         Private method to restore the state of the various splitters.
  429         """
  430         states = self.vcs.getPlugin().getPreferences(
  431             "LogBrowserSplitterStates")
  432         if len(states) == 3:
  433             # we have three splitters
  434             self.mainSplitter.restoreState(states[0])
  435             self.detailsSplitter.restoreState(states[1])
  436             self.diffSplitter.restoreState(states[2])
  437     
  438     def __resetUI(self):
  439         """
  440         Private method to reset the user interface.
  441         """
  442         self.fromDate.setDate(QDate.currentDate())
  443         self.toDate.setDate(QDate.currentDate())
  444         self.fieldCombo.setCurrentIndex(self.fieldCombo.findData("subject"))
  445         self.limitSpinBox.setValue(self.vcs.getPlugin().getPreferences(
  446             "LogLimit"))
  447         self.stopCheckBox.setChecked(self.vcs.getPlugin().getPreferences(
  448             "StopLogOnCopy"))
  449         
  450         self.logTree.clear()
  451     
  452     def __resizeColumnsLog(self):
  453         """
  454         Private method to resize the log tree columns.
  455         """
  456         self.logTree.header().resizeSections(QHeaderView.ResizeToContents)
  457         self.logTree.header().setStretchLastSection(True)
  458     
  459     def __resizeColumnsFiles(self):
  460         """
  461         Private method to resize the changed files tree columns.
  462         """
  463         self.filesTree.header().resizeSections(QHeaderView.ResizeToContents)
  464         self.filesTree.header().setStretchLastSection(True)
  465     
  466     def __resortFiles(self):
  467         """
  468         Private method to resort the changed files tree.
  469         """
  470         self.filesTree.setSortingEnabled(True)
  471         self.filesTree.sortItems(1, Qt.AscendingOrder)
  472         self.filesTree.setSortingEnabled(False)
  473     
  474     def __getColor(self, n):
  475         """
  476         Private method to get the (rotating) name of the color given an index.
  477         
  478         @param n color index
  479         @type int
  480         @return color name
  481         @rtype str
  482         """
  483         if self.__logTreeHasDarkBackground:
  484             return LIGHTCOLORS[n % len(LIGHTCOLORS)]
  485         else:
  486             return COLORS[n % len(COLORS)]
  487     
  488     def __generateEdges(self, commitId, parents):
  489         """
  490         Private method to generate edge info for the give data.
  491         
  492         @param commitId commit id to calculate edge info for (string)
  493         @param parents list of parent commits (list of strings)
  494         @return tuple containing the column and color index for
  495             the given node and a list of tuples indicating the edges
  496             between the given node and its parents
  497             (integer, integer, [(integer, integer, integer), ...])
  498         """
  499         if commitId not in self.__commitIds:
  500             # new head
  501             self.__commitIds.append(commitId)
  502             self.__commitColors[commitId] = self.__commitColor
  503             self.__commitColor += 1
  504         
  505         col = self.__commitIds.index(commitId)
  506         color = self.__commitColors.pop(commitId)
  507         nextCommitIds = self.__commitIds[:]
  508         
  509         # add parents to next
  510         addparents = [p for p in parents if p not in nextCommitIds]
  511         nextCommitIds[col:col + 1] = addparents
  512         
  513         # set colors for the parents
  514         for i, p in enumerate(addparents):
  515             if not i:
  516                 self.__commitColors[p] = color
  517             else:
  518                 self.__commitColors[p] = self.__commitColor
  519                 self.__commitColor += 1
  520         
  521         # add edges to the graph
  522         edges = []
  523         if parents:
  524             for ecol, ecommitId in enumerate(self.__commitIds):
  525                 if ecommitId in nextCommitIds:
  526                     edges.append(
  527                         (ecol, nextCommitIds.index(ecommitId),
  528                          self.__commitColors[ecommitId]))
  529                 elif ecommitId == commitId:
  530                     for p in parents:
  531                         edges.append(
  532                             (ecol, nextCommitIds.index(p),
  533                              self.__commitColors[p]))
  534         
  535         self.__commitIds = nextCommitIds
  536         return col, color, edges
  537     
  538     def __generateIcon(self, column, color, bottomedges, topedges, dotColor,
  539                        currentCommit):
  540         """
  541         Private method to generate an icon containing the revision tree for the
  542         given data.
  543         
  544         @param column column index of the revision (integer)
  545         @param color color of the node (integer)
  546         @param bottomedges list of edges for the bottom of the node
  547             (list of tuples of three integers)
  548         @param topedges list of edges for the top of the node
  549             (list of tuples of three integers)
  550         @param dotColor color to be used for the dot (QColor)
  551         @param currentCommit flag indicating to draw the icon for the
  552             current commit (boolean)
  553         @return icon for the node (QIcon)
  554         """
  555         def col2x(col, radius):
  556             """
  557             Local function to calculate a x-position for a column.
  558             
  559             @param col column number (integer)
  560             @param radius radius of the indicator circle (integer)
  561             """
  562             return int(1.2 * radius) * col + radius // 2 + 3
  563         
  564         radius = self.__dotRadius
  565         w = len(bottomedges) * radius + 20
  566         h = self.__rowHeight
  567         
  568         dot_x = col2x(column, radius) - radius // 2
  569         dot_y = h // 2
  570         
  571         pix = QPixmap(w, h)
  572         pix.fill(QColor(0, 0, 0, 0))        # draw transparent background
  573         painter = QPainter(pix)
  574         painter.setRenderHint(QPainter.Antialiasing)
  575         
  576         # draw the revision history lines
  577         for y1, y2, lines in ((0, h, bottomedges),
  578                               (-h, 0, topedges)):
  579             if lines:
  580                 for start, end, ecolor in lines:
  581                     lpen = QPen(QColor(self.__getColor(ecolor)))
  582                     lpen.setWidth(2)
  583                     painter.setPen(lpen)
  584                     x1 = col2x(start, radius)
  585                     x2 = col2x(end, radius)
  586                     painter.drawLine(x1, dot_y + y1, x2, dot_y + y2)
  587         
  588         penradius = 1
  589         pencolor = self.logTree.palette().color(QPalette.Text)
  590         
  591         dot_y = (h // 2) - radius // 2
  592         
  593         # draw a dot for the revision
  594         if currentCommit:
  595             # enlarge dot for the current revision
  596             delta = 2
  597             radius += 2 * delta
  598             dot_y -= delta
  599             dot_x -= delta
  600         painter.setBrush(dotColor)
  601         pen = QPen(pencolor)
  602         pen.setWidth(penradius)
  603         painter.setPen(pen)
  604         painter.drawEllipse(dot_x, dot_y, radius, radius)
  605         painter.end()
  606         return QIcon(pix)
  607     
  608     def __identifyProject(self):
  609         """
  610         Private method to determine the revision of the project directory.
  611         """
  612         errMsg = ""
  613         
  614         args = self.vcs.initCommand("show")
  615         args.append("--abbrev={0}".format(
  616             self.vcs.getPlugin().getPreferences("CommitIdLength")))
  617         args.append("--format=%h")
  618         args.append("--no-patch")
  619         args.append("HEAD")
  620         
  621         output = ""
  622         process = QProcess()
  623         process.setWorkingDirectory(self.repodir)
  624         process.start('git', args)
  625         procStarted = process.waitForStarted(5000)
  626         if procStarted:
  627             finished = process.waitForFinished(30000)
  628             if finished and process.exitCode() == 0:
  629                 output = str(process.readAllStandardOutput(),
  630                              Preferences.getSystem("IOEncoding"),
  631                              'replace')
  632             else:
  633                 if not finished:
  634                     errMsg = self.tr(
  635                         "The git process did not finish within 30s.")
  636         else:
  637             errMsg = self.tr("Could not start the git executable.")
  638         
  639         if errMsg:
  640             E5MessageBox.critical(
  641                 self,
  642                 self.tr("Git Error"),
  643                 errMsg)
  644         
  645         if output:
  646             self.__projectRevision = output.strip()
  647     
  648     def __generateLogItem(self, author, date, committer, commitDate, subject,
  649                           message, commitId, changedPaths, parents, refnames,
  650                           authorMail, committerMail):
  651         """
  652         Private method to generate a log tree entry.
  653         
  654         @param author author info (string)
  655         @param date date info (string)
  656         @param committer committer info (string)
  657         @param commitDate commit date info (string)
  658         @param subject subject of the log entry (string)
  659         @param message text of the log message (list of strings)
  660         @param commitId commit id info (string)
  661         @param changedPaths list of dictionary objects containing
  662             info about the changed files/directories
  663         @param parents list of parent revisions (list of integers)
  664         @param refnames tags and branches of the commit (string)
  665         @param authorMail author's email address (string)
  666         @param committerMail committer's email address (string)
  667         @return reference to the generated item (QTreeWidgetItem)
  668         """
  669         branches = []
  670         allBranches = []
  671         tags = []
  672         names = refnames.strip()[1:-1].split(",")
  673         for name in names:
  674             name = name.strip()
  675             if name:
  676                 if "HEAD" in name:
  677                     tags.append(name)
  678                 elif name.startswith("tag: "):
  679                     tags.append(name.split()[1])
  680                 else:
  681                     if "/" not in name:
  682                         branches.append(name)
  683                     elif "refs/bisect/" in name:
  684                         bname = name.replace("refs/", "").split("-", 1)[0]
  685                         branches.append(bname)
  686                     else:
  687                         branches.append(name)
  688                     allBranches.append(name)
  689         
  690         logMessageColumnWidth = self.vcs.getPlugin().getPreferences(
  691             "LogSubjectColumnWidth")
  692         msgtxt = subject
  693         if logMessageColumnWidth and len(msgtxt) > logMessageColumnWidth:
  694             msgtxt = "{0}...".format(msgtxt[:logMessageColumnWidth])
  695         columnLabels = [
  696             "",
  697             commitId,
  698             author,
  699             date.rsplit(None, 1)[0].rsplit(":", 1)[0],
  700             committer,
  701             commitDate.rsplit(None, 1)[0].rsplit(":", 1)[0],
  702             msgtxt,
  703             ", ".join(branches),
  704             ", ".join(tags),
  705         ]
  706         itm = QTreeWidgetItem(self.logTree, columnLabels)
  707         
  708         parents = [p.strip() for p in parents.split()]
  709         column, color, edges = self.__generateEdges(commitId, parents)
  710         
  711         itm.setData(0, self.__subjectRole, subject)
  712         itm.setData(0, self.__messageRole, message)
  713         itm.setData(0, self.__changesRole, changedPaths)
  714         itm.setData(0, self.__edgesRole, edges)
  715         itm.setData(0, self.__branchesRole, allBranches)
  716         itm.setData(0, self.__authorMailRole, authorMail)
  717         itm.setData(0, self.__committerMailRole, committerMail)
  718         if not parents:
  719             itm.setData(0, self.__parentsRole, [])
  720         else:
  721             itm.setData(0, self.__parentsRole, parents)
  722             for parent in parents:
  723                 self.__childrenInfo[parent].append(commitId)
  724         
  725         if self.logTree.topLevelItemCount() > 1:
  726             topedges = (
  727                 self.logTree.topLevelItem(
  728                     self.logTree.indexOfTopLevelItem(itm) - 1)
  729                 .data(0, self.__edgesRole)
  730             )
  731         else:
  732             topedges = None
  733         
  734         icon = self.__generateIcon(column, color, edges, topedges,
  735                                    QColor("blue"),
  736                                    commitId == self.__projectRevision)
  737         itm.setIcon(0, icon)
  738         
  739         return itm
  740     
  741     def __generateFileItem(self, action, path, copyfrom, additions, deletions):
  742         """
  743         Private method to generate a changed files tree entry.
  744         
  745         @param action indicator for the change action ("A", "C", "D", "M",
  746             "R", "T", "U", "X")
  747         @param path path of the file in the repository (string)
  748         @param copyfrom path the file was copied from (string)
  749         @param additions number of added lines (int)
  750         @param deletions number of deleted lines (int)
  751         @return reference to the generated item (QTreeWidgetItem)
  752         """
  753         if len(action) > 1:
  754             # includes confidence level
  755             confidence = int(action[1:])
  756             actionTxt = self.tr("{0} ({1}%)", "action, confidence").format(
  757                 self.flags[action[0]], confidence)
  758         else:
  759             actionTxt = self.flags[action]
  760         itm = QTreeWidgetItem(self.filesTree, [
  761             actionTxt,
  762             path,
  763             str(additions),
  764             str(deletions),
  765             copyfrom,
  766         ])
  767         
  768         itm.setTextAlignment(2, Qt.AlignRight)
  769         itm.setTextAlignment(3, Qt.AlignRight)
  770         
  771         return itm
  772     
  773     def __getLogEntries(self, skip=0, noEntries=0):
  774         """
  775         Private method to retrieve log entries from the repository.
  776         
  777         @param skip number of log entries to skip (integer)
  778         @keyparam noEntries number of entries to get (0 = default) (int)
  779         """
  780         self.buttonBox.button(QDialogButtonBox.Close).setEnabled(False)
  781         self.buttonBox.button(QDialogButtonBox.Cancel).setEnabled(True)
  782         self.buttonBox.button(QDialogButtonBox.Cancel).setDefault(True)
  783         QApplication.processEvents()
  784         
  785         QApplication.setOverrideCursor(QCursor(Qt.WaitCursor))
  786         QApplication.processEvents()
  787         
  788         self.buf = []
  789         self.cancelled = False
  790         self.errors.clear()
  791         self.intercept = False
  792         
  793         if noEntries == 0:
  794             noEntries = self.limitSpinBox.value()
  795         
  796         args = self.vcs.initCommand("log")
  797         args.append('--max-count={0}'.format(noEntries))
  798         args.append('--numstat')
  799         args.append('--abbrev={0}'.format(
  800             self.vcs.getPlugin().getPreferences("CommitIdLength")))
  801         if self.vcs.getPlugin().getPreferences("FindCopiesHarder"):
  802             args.append('--find-copies-harder')
  803         args.append('--format={0}'.format(self.__formatTemplate))
  804         args.append('--full-history')
  805         args.append('--all')
  806         args.append('--skip={0}'.format(skip))
  807         if not self.projectMode:
  808             if not self.stopCheckBox.isChecked():
  809                 args.append('--follow')
  810             args.append('--')
  811             args.append(self.__filename)
  812         
  813         self.process.kill()
  814         
  815         self.process.setWorkingDirectory(self.repodir)
  816         
  817         self.process.start('git', args)
  818         procStarted = self.process.waitForStarted(5000)
  819         if not procStarted:
  820             self.inputGroup.setEnabled(False)
  821             self.inputGroup.hide()
  822             E5MessageBox.critical(
  823                 self,
  824                 self.tr('Process Generation Error'),
  825                 self.tr(
  826                     'The process {0} could not be started. '
  827                     'Ensure, that it is in the search path.'
  828                 ).format('git'))
  829     
  830     def start(self, fn, isFile=False, noEntries=0):
  831         """
  832         Public slot to start the git log command.
  833         
  834         @param fn filename to show the log for (string)
  835         @keyparam isFile flag indicating log for a file is to be shown
  836             (boolean)
  837         @keyparam noEntries number of entries to get (0 = default) (int)
  838         """
  839         self.__isFile = isFile
  840         
  841         self.sbsSelectLabel.clear()
  842         
  843         self.errorGroup.hide()
  844         QApplication.processEvents()
  845         
  846         self.__initData()
  847         
  848         self.__filename = fn
  849         self.dname, self.fname = self.vcs.splitPath(fn)
  850         
  851         # find the root of the repo
  852         self.repodir = self.dname
  853         while not os.path.isdir(os.path.join(self.repodir, self.vcs.adminDir)):
  854             self.repodir = os.path.dirname(self.repodir)
  855             if os.path.splitdrive(self.repodir)[1] == os.sep:
  856                 return
  857         
  858         self.projectMode = (self.fname == "." and self.dname == self.repodir)
  859         self.stopCheckBox.setDisabled(self.projectMode or self.fname == ".")
  860         self.activateWindow()
  861         self.raise_()
  862         
  863         self.logTree.clear()
  864         self.__started = True
  865         self.__identifyProject()
  866         self.__getLogEntries(noEntries=noEntries)
  867     
  868     def __procFinished(self, exitCode, exitStatus):
  869         """
  870         Private slot connected to the finished signal.
  871         
  872         @param exitCode exit code of the process (integer)
  873         @param exitStatus exit status of the process (QProcess.ExitStatus)
  874         """
  875         self.__processBuffer()
  876         self.__finish()
  877     
  878     def __finish(self):
  879         """
  880         Private slot called when the process finished or the user pressed
  881         the button.
  882         """
  883         if (
  884             self.process is not None and
  885             self.process.state() != QProcess.NotRunning
  886         ):
  887             self.process.terminate()
  888             QTimer.singleShot(2000, self.process.kill)
  889             self.process.waitForFinished(3000)
  890         
  891         QApplication.restoreOverrideCursor()
  892         
  893         self.buttonBox.button(QDialogButtonBox.Close).setEnabled(True)
  894         self.buttonBox.button(QDialogButtonBox.Cancel).setEnabled(False)
  895         self.buttonBox.button(QDialogButtonBox.Close).setDefault(True)
  896         
  897         self.inputGroup.setEnabled(False)
  898         self.inputGroup.hide()
  899         self.refreshButton.setEnabled(True)
  900         
  901         while self.__finishCallbacks:
  902             self.__finishCallbacks.pop(0)()
  903     
  904     def __processBufferItem(self, logEntry):
  905         """
  906         Private method to process a log entry.
  907         
  908         @param logEntry dictionary as generated by __processBuffer
  909         """
  910         self.__generateLogItem(
  911             logEntry["author"], logEntry["authordate"],
  912             logEntry["committer"], logEntry["committerdate"],
  913             logEntry["subject"], logEntry["body"],
  914             logEntry["commit"], logEntry["changed_files"],
  915             logEntry["parents"], logEntry["refnames"],
  916             logEntry["authormail"], logEntry["committermail"]
  917         )
  918         for date in [logEntry["authordate"], logEntry["committerdate"]]:
  919             dt = QDate.fromString(date, Qt.ISODate)
  920             if (
  921                 not self.__maxDate.isValid() and
  922                 not self.__minDate.isValid()
  923             ):
  924                 self.__maxDate = dt
  925                 self.__minDate = dt
  926             else:
  927                 if self.__maxDate < dt:
  928                     self.__maxDate = dt
  929                 if self.__minDate > dt:
  930                     self.__minDate = dt
  931     
  932     def __processBuffer(self):
  933         """
  934         Private method to process the buffered output of the git log command.
  935         """
  936         noEntries = 0
  937         logEntry = {"changed_files": []}
  938         descriptionBody = False
  939         
  940         for line in self.buf:
  941             line = line.rstrip()
  942             if line == "recordstart":
  943                 if len(logEntry) > 1:
  944                     self.__processBufferItem(logEntry)
  945                     noEntries += 1
  946                 logEntry = {"changed_files": []}
  947                 descriptionBody = False
  948                 fileChanges = False
  949                 body = []
  950             elif line == "bodystart":
  951                 descriptionBody = True
  952             elif line == "bodyend":
  953                 if bool(body) and not bool(body[-1]):
  954                     body.pop()
  955                 logEntry["body"] = body
  956                 descriptionBody = False
  957                 fileChanges = True
  958             elif descriptionBody:
  959                 body.append(line)
  960             elif fileChanges:
  961                 if line:
  962                     if "changed_files" not in logEntry:
  963                         logEntry["changed_files"] = []
  964                     changeInfo = line.strip().split("\t")
  965                     if "=>" in changeInfo[2]:
  966                         # copy/move
  967                         if "{" in changeInfo[2] and "}" in changeInfo[2]:
  968                             # change info of the form
  969                             # test/{pack1 => pack2}/file1.py
  970                             head, tail = changeInfo[2].split("{", 1)
  971                             middle, tail = tail.split("}", 1)
  972                             middleSrc, middleDst = middle.split("=>")
  973                             src = head + middleSrc.strip() + tail
  974                             dst = head + middleDst.strip() + tail
  975                         else:
  976                             src, dst = changeInfo[2].split("=>")
  977                         logEntry["changed_files"].append({
  978                             "action": "C",
  979                             "added": changeInfo[0].strip(),
  980                             "deleted": changeInfo[1].strip(),
  981                             "path": dst.strip(),
  982                             "copyfrom": src.strip(),
  983                         })
  984                     else:
  985                         logEntry["changed_files"].append({
  986                             "action": "M",
  987                             "added": changeInfo[0].strip(),
  988                             "deleted": changeInfo[1].strip(),
  989                             "path": changeInfo[2].strip(),
  990                             "copyfrom": "",
  991                         })
  992             else:
  993                 try:
  994                     key, value = line.split("|", 1)
  995                 except ValueError:
  996                     key = ""
  997                     value = line
  998                 if key in ("commit", "parents", "author", "authormail",
  999                            "authordate", "committer", "committermail",
 1000                            "committerdate", "refnames", "subject"):
 1001                     logEntry[key] = value.strip()
 1002         if len(logEntry) > 1:
 1003             self.__processBufferItem(logEntry)
 1004             noEntries += 1
 1005         
 1006         self.__resizeColumnsLog()
 1007         
 1008         if self.__started:
 1009             if self.__selectedCommitIDs:
 1010                 self.logTree.setCurrentItem(self.logTree.findItems(
 1011                     self.__selectedCommitIDs[0], Qt.MatchExactly,
 1012                     self.CommitIdColumn)[0])
 1013             else:
 1014                 self.logTree.setCurrentItem(self.logTree.topLevelItem(0))
 1015             self.__started = False
 1016         
 1017         self.__skipEntries += noEntries
 1018         if noEntries < self.limitSpinBox.value() and not self.cancelled:
 1019             self.nextButton.setEnabled(False)
 1020             self.limitSpinBox.setEnabled(False)
 1021         else:
 1022             self.nextButton.setEnabled(True)
 1023             self.limitSpinBox.setEnabled(True)
 1024         
 1025         # update the log filters
 1026         self.__filterLogsEnabled = False
 1027         self.fromDate.setMinimumDate(self.__minDate)
 1028         self.fromDate.setMaximumDate(self.__maxDate)
 1029         self.fromDate.setDate(self.__minDate)
 1030         self.toDate.setMinimumDate(self.__minDate)
 1031         self.toDate.setMaximumDate(self.__maxDate)
 1032         self.toDate.setDate(self.__maxDate)
 1033         
 1034         self.__filterLogsEnabled = True
 1035         if self.__actionMode() == "filter":
 1036             self.__filterLogs()
 1037         
 1038         self.__updateToolMenuActions()
 1039         
 1040         # restore selected items
 1041         if self.__selectedCommitIDs:
 1042             for commitID in self.__selectedCommitIDs:
 1043                 items = self.logTree.findItems(
 1044                     commitID, Qt.MatchExactly, self.CommitIdColumn)
 1045                 if items:
 1046                     items[0].setSelected(True)
 1047             self.__selectedCommitIDs = []
 1048     
 1049     def __readStdout(self):
 1050         """
 1051         Private slot to handle the readyReadStandardOutput signal.
 1052         
 1053         It reads the output of the process and inserts it into a buffer.
 1054         """
 1055         self.process.setReadChannel(QProcess.StandardOutput)
 1056         
 1057         while self.process.canReadLine():
 1058             line = str(self.process.readLine(),
 1059                        Preferences.getSystem("IOEncoding"),
 1060                        'replace')
 1061             self.buf.append(line)
 1062     
 1063     def __readStderr(self):
 1064         """
 1065         Private slot to handle the readyReadStandardError signal.
 1066         
 1067         It reads the error output of the process and inserts it into the
 1068         error pane.
 1069         """
 1070         if self.process is not None:
 1071             s = str(self.process.readAllStandardError(),
 1072                     Preferences.getSystem("IOEncoding"),
 1073                     'replace')
 1074             self.__showError(s)
 1075     
 1076     def __showError(self, out):
 1077         """
 1078         Private slot to show some error.
 1079         
 1080         @param out error to be shown (string)
 1081         """
 1082         self.errorGroup.show()
 1083         self.errors.insertPlainText(out)
 1084         self.errors.ensureCursorVisible()
 1085         
 1086         # show input in case the process asked for some input
 1087         self.inputGroup.setEnabled(True)
 1088         self.inputGroup.show()
 1089     
 1090     def on_buttonBox_clicked(self, button):
 1091         """
 1092         Private slot called by a button of the button box clicked.
 1093         
 1094         @param button button that was clicked (QAbstractButton)
 1095         """
 1096         if button == self.buttonBox.button(QDialogButtonBox.Close):
 1097             self.close()
 1098         elif button == self.buttonBox.button(QDialogButtonBox.Cancel):
 1099             self.cancelled = True
 1100             self.__finish()
 1101         elif button == self.refreshButton:
 1102             self.on_refreshButton_clicked()
 1103     
 1104     @pyqtSlot()
 1105     def on_refreshButton_clicked(self):
 1106         """
 1107         Private slot to refresh the log.
 1108         """
 1109         self.buttonBox.button(QDialogButtonBox.Close).setEnabled(False)
 1110         self.buttonBox.button(QDialogButtonBox.Cancel).setEnabled(True)
 1111         self.buttonBox.button(QDialogButtonBox.Cancel).setDefault(True)
 1112         
 1113         self.refreshButton.setEnabled(False)
 1114         
 1115         # save the selected items commit IDs
 1116         self.__selectedCommitIDs = []
 1117         for item in self.logTree.selectedItems():
 1118             self.__selectedCommitIDs.append(item.text(self.CommitIdColumn))
 1119         
 1120         self.start(self.__filename, isFile=self.__isFile,
 1121                    noEntries=self.logTree.topLevelItemCount())
 1122     
 1123     def on_passwordCheckBox_toggled(self, isOn):
 1124         """
 1125         Private slot to handle the password checkbox toggled.
 1126         
 1127         @param isOn flag indicating the status of the check box (boolean)
 1128         """
 1129         if isOn:
 1130             self.input.setEchoMode(QLineEdit.Password)
 1131         else:
 1132             self.input.setEchoMode(QLineEdit.Normal)
 1133     
 1134     @pyqtSlot()
 1135     def on_sendButton_clicked(self):
 1136         """
 1137         Private slot to send the input to the git process.
 1138         """
 1139         inputTxt = self.input.text()
 1140         inputTxt += os.linesep
 1141         
 1142         if self.passwordCheckBox.isChecked():
 1143             self.errors.insertPlainText(os.linesep)
 1144             self.errors.ensureCursorVisible()
 1145         else:
 1146             self.errors.insertPlainText(inputTxt)
 1147             self.errors.ensureCursorVisible()
 1148         self.errorGroup.show()
 1149         
 1150         self.process.write(strToQByteArray(inputTxt))
 1151         
 1152         self.passwordCheckBox.setChecked(False)
 1153         self.input.clear()
 1154     
 1155     def on_input_returnPressed(self):
 1156         """
 1157         Private slot to handle the press of the return key in the input field.
 1158         """
 1159         self.intercept = True
 1160         self.on_sendButton_clicked()
 1161     
 1162     def keyPressEvent(self, evt):
 1163         """
 1164         Protected slot to handle a key press event.
 1165         
 1166         @param evt the key press event (QKeyEvent)
 1167         """
 1168         if self.intercept:
 1169             self.intercept = False
 1170             evt.accept()
 1171             return
 1172         super(GitLogBrowserDialog, self).keyPressEvent(evt)
 1173     
 1174     def __prepareFieldSearch(self):
 1175         """
 1176         Private slot to prepare the filed search data.
 1177         
 1178         @return tuple of field index, search expression and flag indicating
 1179             that the field index is a data role (integer, string, boolean)
 1180         """
 1181         indexIsRole = False
 1182         txt = self.fieldCombo.itemData(self.fieldCombo.currentIndex())
 1183         if txt == "author":
 1184             fieldIndex = self.AuthorColumn
 1185             searchRx = QRegExp(self.rxEdit.text(), Qt.CaseInsensitive)
 1186         elif txt == "committer":
 1187             fieldIndex = self.CommitterColumn
 1188             searchRx = QRegExp(self.rxEdit.text(), Qt.CaseInsensitive)
 1189         elif txt == "commitId":
 1190             fieldIndex = self.CommitIdColumn
 1191             txt = self.rxEdit.text()
 1192             if txt.startswith("^"):
 1193                 searchRx = QRegExp(r"^\s*{0}".format(txt[1:]),
 1194                                    Qt.CaseInsensitive)
 1195             else:
 1196                 searchRx = QRegExp(txt, Qt.CaseInsensitive)
 1197         elif txt == "file":
 1198             fieldIndex = self.__changesRole
 1199             searchRx = QRegExp(self.rxEdit.text(), Qt.CaseInsensitive)
 1200             indexIsRole = True
 1201         else:
 1202             fieldIndex = self.__subjectRole
 1203             searchRx = QRegExp(self.rxEdit.text(), Qt.CaseInsensitive)
 1204             indexIsRole = True
 1205         
 1206         return fieldIndex, searchRx, indexIsRole
 1207     
 1208     def __filterLogs(self):
 1209         """
 1210         Private method to filter the log entries.
 1211         """
 1212         if self.__filterLogsEnabled:
 1213             from_ = self.fromDate.date().toString("yyyy-MM-dd")
 1214             to_ = self.toDate.date().addDays(1).toString("yyyy-MM-dd")
 1215             fieldIndex, searchRx, indexIsRole = self.__prepareFieldSearch()
 1216             
 1217             visibleItemCount = self.logTree.topLevelItemCount()
 1218             currentItem = self.logTree.currentItem()
 1219             for topIndex in range(self.logTree.topLevelItemCount()):
 1220                 topItem = self.logTree.topLevelItem(topIndex)
 1221                 if indexIsRole:
 1222                     if fieldIndex == self.__changesRole:
 1223                         changes = topItem.data(0, self.__changesRole)
 1224                         txt = "\n".join(
 1225                             [c["path"] for c in changes] +
 1226                             [c["copyfrom"] for c in changes]
 1227                         )
 1228                     else:
 1229                         # Filter based on complete subject text
 1230                         txt = topItem.data(0, self.__subjectRole)
 1231                 else:
 1232                     txt = topItem.text(fieldIndex)
 1233                 if (
 1234                     topItem.text(self.DateColumn) <= to_ and
 1235                     topItem.text(self.DateColumn) >= from_ and
 1236                     searchRx.indexIn(txt) > -1
 1237                 ):
 1238                     topItem.setHidden(False)
 1239                     if topItem is currentItem:
 1240                         self.on_logTree_currentItemChanged(topItem, None)
 1241                 else:
 1242                     topItem.setHidden(True)
 1243                     if topItem is currentItem:
 1244                         self.filesTree.clear()
 1245                     visibleItemCount -= 1
 1246             self.logTree.header().setSectionHidden(
 1247                 self.IconColumn,
 1248                 visibleItemCount != self.logTree.topLevelItemCount())
 1249     
 1250     def __updateSbsSelectLabel(self):
 1251         """
 1252         Private slot to update the enabled status of the diff buttons.
 1253         """
 1254         self.sbsSelectLabel.clear()
 1255         if self.__isFile:
 1256             selectedItems = self.logTree.selectedItems()
 1257             if len(selectedItems) == 1:
 1258                 currentItem = selectedItems[0]
 1259                 commit2 = currentItem.text(self.CommitIdColumn).strip()
 1260                 parents = currentItem.data(0, self.__parentsRole)
 1261                 if parents:
 1262                     parentLinks = []
 1263                     for index in range(len(parents)):
 1264                         parentLinks.append(
 1265                             '<a href="sbsdiff:{0}_{1}">&nbsp;{2}&nbsp;</a>'
 1266                             .format(parents[index], commit2, index + 1))
 1267                     self.sbsSelectLabel.setText(
 1268                         self.tr('Side-by-Side Diff to Parent {0}').format(
 1269                             " ".join(parentLinks)))
 1270             elif len(selectedItems) == 2:
 1271                 commit2 = selectedItems[0].text(self.CommitIdColumn)
 1272                 commit1 = selectedItems[1].text(self.CommitIdColumn)
 1273                 index2 = self.logTree.indexOfTopLevelItem(selectedItems[0])
 1274                 index1 = self.logTree.indexOfTopLevelItem(selectedItems[1])
 1275                 
 1276                 if index2 < index1:
 1277                     # swap to always compare old to new
 1278                     commit1, commit2 = commit2, commit1
 1279                 self.sbsSelectLabel.setText(self.tr(
 1280                     '<a href="sbsdiff:{0}_{1}">Side-by-Side Compare</a>')
 1281                     .format(commit1, commit2))
 1282     
 1283     def __updateToolMenuActions(self):
 1284         """
 1285         Private slot to update the status of the tool menu actions and
 1286         the tool menu button.
 1287         """
 1288         if self.projectMode:
 1289             selectCount = len(self.logTree.selectedItems())
 1290             self.__cherryAct.setEnabled(selectCount > 0)
 1291             self.__describeAct.setEnabled(selectCount > 0)
 1292             self.__tagAct.setEnabled(selectCount == 1)
 1293             self.__switchAct.setEnabled(selectCount == 1)
 1294             self.__branchAct.setEnabled(selectCount == 1)
 1295             self.__branchSwitchAct.setEnabled(selectCount == 1)
 1296             self.__shortlogAct.setEnabled(selectCount == 1)
 1297             
 1298             self.actionsButton.setEnabled(True)
 1299         else:
 1300             self.actionsButton.setEnabled(False)
 1301     
 1302     def __updateDetailsAndFiles(self):
 1303         """
 1304         Private slot to update the details and file changes panes.
 1305         """
 1306         self.detailsEdit.clear()
 1307         self.filesTree.clear()
 1308         self.__diffUpdatesFiles = False
 1309         
 1310         selectedItems = self.logTree.selectedItems()
 1311         if len(selectedItems) == 1:
 1312             self.detailsEdit.setHtml(
 1313                 self.__generateDetailsTableText(selectedItems[0]))
 1314             self.__updateFilesTree(self.filesTree, selectedItems[0])
 1315             self.__resizeColumnsFiles()
 1316             self.__resortFiles()
 1317             if self.filesTree.topLevelItemCount() == 0:
 1318                 self.__diffUpdatesFiles = True
 1319                 # give diff a chance to update the files list
 1320         elif len(selectedItems) == 2:
 1321             self.__diffUpdatesFiles = True
 1322             index1 = self.logTree.indexOfTopLevelItem(selectedItems[0])
 1323             index2 = self.logTree.indexOfTopLevelItem(selectedItems[1])
 1324             if index1 > index2:
 1325                 # Swap the entries
 1326                 selectedItems[0], selectedItems[1] = (
 1327                     selectedItems[1], selectedItems[0]
 1328                 )
 1329             html = "{0}<hr/>{1}".format(
 1330                 self.__generateDetailsTableText(selectedItems[0]),
 1331                 self.__generateDetailsTableText(selectedItems[1]),
 1332             )
 1333             self.detailsEdit.setHtml(html)
 1334             # self.filesTree is updated by the diff
 1335     
 1336     def __generateDetailsTableText(self, itm):
 1337         """
 1338         Private method to generate an HTML table with the details of the given
 1339         changeset.
 1340         
 1341         @param itm reference to the item the table should be based on
 1342         @type QTreeWidgetItem
 1343         @return HTML table containing details
 1344         @rtype str
 1345         """
 1346         if itm is not None:
 1347             commitId = itm.text(self.CommitIdColumn)
 1348             
 1349             parentLinks = []
 1350             for parent in [str(x) for x in itm.data(0, self.__parentsRole)]:
 1351                 parentLinks.append('<a href="rev:{0}">{0}</a>'.format(parent))
 1352             if parentLinks:
 1353                 parentsStr = self.__parentsTemplate.format(
 1354                     ", ".join(parentLinks))
 1355             else:
 1356                 parentsStr = ""
 1357             
 1358             childLinks = []
 1359             for child in [str(x) for x in self.__childrenInfo[commitId]]:
 1360                 childLinks.append('<a href="rev:{0}">{0}</a>'.format(child))
 1361             if childLinks:
 1362                 childrenStr = self.__childrenTemplate.format(
 1363                     ", ".join(childLinks))
 1364             else:
 1365                 childrenStr = ""
 1366             
 1367             branchLinks = []
 1368             for branch, branchHead in self.__getBranchesForCommit(commitId):
 1369                 branchLinks.append('<a href="rev:{0}">{1}</a>'.format(
 1370                     branchHead, branch))
 1371             if branchLinks:
 1372                 branchesStr = self.__branchesTemplate.format(
 1373                     ", ".join(branchLinks))
 1374             else:
 1375                 branchesStr = ""
 1376             
 1377             tagLinks = []
 1378             for tag, tagCommit in self.__getTagsForCommit(commitId):
 1379                 if tagCommit:
 1380                     tagLinks.append('<a href="rev:{0}">{1}</a>'.format(
 1381                         tagCommit, tag))
 1382                 else:
 1383                     tagLinks.append(tag)
 1384             if tagLinks:
 1385                 tagsStr = self.__tagsTemplate.format(
 1386                     ", ".join(tagLinks))
 1387             else:
 1388                 tagsStr = ""
 1389             
 1390             if itm.data(0, self.__messageRole):
 1391                 messageStr = self.__mesageTemplate.format(
 1392                     "<br/>".join(itm.data(0, self.__messageRole)))
 1393             else:
 1394                 messageStr = ""
 1395             
 1396             html = self.__detailsTemplate.format(
 1397                 commitId,
 1398                 itm.text(self.DateColumn),
 1399                 itm.text(self.AuthorColumn),
 1400                 itm.data(0, self.__authorMailRole).strip(),
 1401                 itm.text(self.CommitDateColumn),
 1402                 itm.text(self.CommitterColumn),
 1403                 itm.data(0, self.__committerMailRole).strip(),
 1404                 parentsStr + childrenStr + branchesStr + tagsStr,
 1405                 itm.data(0, self.__subjectRole),
 1406                 messageStr,
 1407             )
 1408         else:
 1409             html = ""
 1410         
 1411         return html
 1412     
 1413     def __updateFilesTree(self, parent, itm):
 1414         """
 1415         Private method to update the files tree with changes of the given item.
 1416         
 1417         @param parent parent for the items to be added
 1418         @type QTreeWidget or QTreeWidgetItem
 1419         @param itm reference to the item the update should be based on
 1420         @type QTreeWidgetItem
 1421         """
 1422         if itm is not None:
 1423             changes = itm.data(0, self.__changesRole)
 1424             if len(changes) > 0:
 1425                 for change in changes:
 1426                     self.__generateFileItem(
 1427                         change["action"], change["path"], change["copyfrom"],
 1428                         change["added"], change["deleted"])
 1429                 self.__resizeColumnsFiles()
 1430                 self.__resortFiles()
 1431     
 1432     def __getBranchesForCommit(self, commitId):
 1433         """
 1434         Private method to get all branches reachable from a commit ID.
 1435         
 1436         @param commitId commit ID to get the branches for
 1437         @type str
 1438         @return list of tuples containing the branch name and the associated
 1439             commit ID of its branch head
 1440         @rtype tuple of (str, str)
 1441         """
 1442         branches = []
 1443         
 1444         args = self.vcs.initCommand("branch")
 1445         args.append("--list")
 1446         args.append("--verbose")
 1447         args.append("--contains")
 1448         args.append(commitId)
 1449         
 1450         output = ""
 1451         process = QProcess()
 1452         process.setWorkingDirectory(self.repodir)
 1453         process.start('git', args)
 1454         procStarted = process.waitForStarted(5000)
 1455         if procStarted:
 1456             finished = process.waitForFinished(30000)
 1457             if finished and process.exitCode() == 0:
 1458                 output = str(process.readAllStandardOutput(),
 1459                              Preferences.getSystem("IOEncoding"),
 1460                              'replace')
 1461         
 1462         if output:
 1463             for line in output.splitlines():
 1464                 name, commitId = line[2:].split(None, 2)[:2]
 1465                 branches.append((name, commitId))
 1466         
 1467         return branches
 1468     
 1469     def __getTagsForCommit(self, commitId):
 1470         """
 1471         Private method to get all tags reachable from a commit ID.
 1472         
 1473         @param commitId commit ID to get the tags for
 1474         @type str
 1475         @return list of tuples containing the tag name and the associated
 1476             commit ID
 1477         @rtype tuple of (str, str)
 1478         """
 1479         tags = []
 1480         
 1481         args = self.vcs.initCommand("tag")
 1482         args.append("--list")
 1483         args.append("--contains")
 1484         args.append(commitId)
 1485         
 1486         output = ""
 1487         process = QProcess()
 1488         process.setWorkingDirectory(self.repodir)
 1489         process.start('git', args)
 1490         procStarted = process.waitForStarted(5000)
 1491         if procStarted:
 1492             finished = process.waitForFinished(30000)
 1493             if finished and process.exitCode() == 0:
 1494                 output = str(process.readAllStandardOutput(),
 1495                              Preferences.getSystem("IOEncoding"),
 1496                              'replace')
 1497         
 1498         if output:
 1499             tagNames = []
 1500             for line in output.splitlines():
 1501                 tagNames.append(line.strip())
 1502             
 1503             # determine the commit IDs for the tags
 1504             for tagName in tagNames:
 1505                 commitId = self.__getCommitForTag(tagName)
 1506                 tags.append((tagName, commitId))
 1507         
 1508         return tags
 1509     
 1510     def __getCommitForTag(self, tag):
 1511         """
 1512         Private method to get the commit id for a tag.
 1513         
 1514         @param tag tag name (string)
 1515         @return commit id shortened to 10 characters (string)
 1516         """
 1517         args = self.vcs.initCommand("show")
 1518         args.append("--abbrev-commit")
 1519         args.append("--abbrev={0}".format(
 1520             self.vcs.getPlugin().getPreferences("CommitIdLength")))
 1521         args.append("--no-patch")
 1522         args.append(tag)
 1523         
 1524         output = ""
 1525         process = QProcess()
 1526         process.setWorkingDirectory(self.repodir)
 1527         process.start('git', args)
 1528         procStarted = process.waitForStarted(5000)
 1529         if procStarted:
 1530             finished = process.waitForFinished(30000)
 1531             if finished and process.exitCode() == 0:
 1532                 output = str(process.readAllStandardOutput(),
 1533                              Preferences.getSystem("IOEncoding"),
 1534                              'replace')
 1535         
 1536         if output:
 1537             for line in output.splitlines():
 1538                 if line.startswith("commit "):
 1539                     commitId = line.split()[1].strip()
 1540                     return commitId
 1541         
 1542         return ""
 1543     
 1544     @pyqtSlot(QPoint)
 1545     def on_logTree_customContextMenuRequested(self, pos):
 1546         """
 1547         Private slot to show the context menu of the log tree.
 1548         
 1549         @param pos position of the mouse pointer (QPoint)
 1550         """
 1551         self.__logTreeMenu.popup(self.logTree.mapToGlobal(pos))
 1552     
 1553     @pyqtSlot(QTreeWidgetItem, QTreeWidgetItem)
 1554     def on_logTree_currentItemChanged(self, current, previous):
 1555         """
 1556         Private slot called, when the current item of the log tree changes.
 1557         
 1558         @param current reference to the new current item (QTreeWidgetItem)
 1559         @param previous reference to the old current item (QTreeWidgetItem)
 1560         """
 1561         self.__updateToolMenuActions()
 1562         
 1563         # Highlight the current entry using a bold font
 1564         for col in range(self.logTree.columnCount()):
 1565             current and current.setFont(col, self.__logTreeBoldFont)
 1566             previous and previous.setFont(col, self.__logTreeNormalFont)
 1567         
 1568         # set the state of the up and down buttons
 1569         self.upButton.setEnabled(
 1570             current is not None and
 1571             self.logTree.indexOfTopLevelItem(current) > 0)
 1572         self.downButton.setEnabled(
 1573             current is not None and
 1574             len(current.data(0, self.__parentsRole)) > 0 and
 1575             (self.logTree.indexOfTopLevelItem(current) <
 1576                 self.logTree.topLevelItemCount() - 1 or
 1577              self.nextButton.isEnabled()))
 1578     
 1579     @pyqtSlot()
 1580     def on_logTree_itemSelectionChanged(self):
 1581         """
 1582         Private slot called, when the selection has changed.
 1583         """
 1584         self.__updateDetailsAndFiles()
 1585         self.__updateSbsSelectLabel()
 1586         self.__updateToolMenuActions()
 1587         self.__generateDiffs()
 1588     
 1589     @pyqtSlot()
 1590     def on_upButton_clicked(self):
 1591         """
 1592         Private slot to move the current item up one entry.
 1593         """
 1594         itm = self.logTree.itemAbove(self.logTree.currentItem())
 1595         if itm:
 1596             self.logTree.setCurrentItem(itm)
 1597     
 1598     @pyqtSlot()
 1599     def on_downButton_clicked(self):
 1600         """
 1601         Private slot to move the current item down one entry.
 1602         """
 1603         itm = self.logTree.itemBelow(self.logTree.currentItem())
 1604         if itm:
 1605             self.logTree.setCurrentItem(itm)
 1606         else:
 1607             # load the next bunch and try again
 1608             if self.nextButton.isEnabled():
 1609                 self.__addFinishCallback(self.on_downButton_clicked)
 1610                 self.on_nextButton_clicked()
 1611     
 1612     @pyqtSlot()
 1613     def on_nextButton_clicked(self):
 1614         """
 1615         Private slot to handle the Next button.
 1616         """
 1617         if self.__skipEntries > 0 and self.nextButton.isEnabled():
 1618             self.__getLogEntries(skip=self.__skipEntries)
 1619     
 1620     @pyqtSlot(QDate)
 1621     def on_fromDate_dateChanged(self, date):
 1622         """
 1623         Private slot called, when the from date changes.
 1624         
 1625         @param date new date (QDate)
 1626         """
 1627         if self.__actionMode() == "filter":
 1628             self.__filterLogs()
 1629     
 1630     @pyqtSlot(QDate)
 1631     def on_toDate_dateChanged(self, date):
 1632         """
 1633         Private slot called, when the from date changes.
 1634         
 1635         @param date new date (QDate)
 1636         """
 1637         if self.__actionMode() == "filter":
 1638             self.__filterLogs()
 1639     
 1640     @pyqtSlot(str)
 1641     def on_fieldCombo_activated(self, txt):
 1642         """
 1643         Private slot called, when a new filter field is selected.
 1644         
 1645         @param txt text of the selected field (string)
 1646         """
 1647         if self.__actionMode() == "filter":
 1648             self.__filterLogs()
 1649     
 1650     @pyqtSlot(str)
 1651     def on_rxEdit_textChanged(self, txt):
 1652         """
 1653         Private slot called, when a filter expression is entered.
 1654         
 1655         @param txt filter expression (string)
 1656         """
 1657         if self.__actionMode() == "filter":
 1658             self.__filterLogs()
 1659         elif self.__actionMode() == "find":
 1660             self.__findItem(self.__findBackwards, interactive=True)
 1661     
 1662     @pyqtSlot()
 1663     def on_rxEdit_returnPressed(self):
 1664         """
 1665         Private slot handling a press of the Return key in the rxEdit input.
 1666         """
 1667         if self.__actionMode() == "find":
 1668             self.__findItem(self.__findBackwards, interactive=True)
 1669     
 1670     @pyqtSlot(bool)
 1671     def on_stopCheckBox_clicked(self, checked):
 1672         """
 1673         Private slot called, when the stop on copy/move checkbox is clicked.
 1674         
 1675         @param checked flag indicating the state of the check box (boolean)
 1676         """
 1677         self.vcs.getPlugin().setPreferences("StopLogOnCopy",
 1678                                             self.stopCheckBox.isChecked())
 1679         self.nextButton.setEnabled(True)
 1680         self.limitSpinBox.setEnabled(True)
 1681     
 1682     ##################################################################
 1683     ## Tool button menu action methods below
 1684     ##################################################################
 1685     
 1686     @pyqtSlot()
 1687     def __cherryActTriggered(self):
 1688         """
 1689         Private slot to handle the Copy Commits action.
 1690         """
 1691         commits = {}
 1692         
 1693         for itm in self.logTree.selectedItems():
 1694             index = self.logTree.indexOfTopLevelItem(itm)
 1695             commits[index] = itm.text(self.CommitIdColumn)
 1696         
 1697         if commits:
 1698             pfile = e5App().getObject("Project").getProjectFile()
 1699             lastModified = QFileInfo(pfile).lastModified().toString()
 1700             shouldReopen = (
 1701                 self.vcs.gitCherryPick(
 1702                     self.repodir,
 1703                     [commits[i] for i in sorted(commits.keys(), reverse=True)]
 1704                 ) or
 1705                 QFileInfo(pfile).lastModified().toString() != lastModified
 1706             )
 1707             if shouldReopen:
 1708                 res = E5MessageBox.yesNo(
 1709                     None,
 1710                     self.tr("Copy Changesets"),
 1711                     self.tr(
 1712                         """The project should be reread. Do this now?"""),
 1713                     yesDefault=True)
 1714                 if res:
 1715                     e5App().getObject("Project").reopenProject()
 1716                     return
 1717             
 1718             self.on_refreshButton_clicked()
 1719     
 1720     @pyqtSlot()
 1721     def __tagActTriggered(self):
 1722         """
 1723         Private slot to tag the selected commit.
 1724         """
 1725         if len(self.logTree.selectedItems()) == 1:
 1726             itm = self.logTree.selectedItems()[0]
 1727             commit = itm.text(self.CommitIdColumn)
 1728             tag = itm.text(self.TagsColumn).strip().split(", ", 1)[0]
 1729             res = self.vcs.vcsTag(self.repodir, revision=commit, tagName=tag)
 1730             if res:
 1731                 self.on_refreshButton_clicked()
 1732     
 1733     @pyqtSlot()
 1734     def __switchActTriggered(self):
 1735         """
 1736         Private slot to switch the working directory to the
 1737         selected commit.
 1738         """
 1739         if len(self.logTree.selectedItems()) == 1:
 1740             itm = self.logTree.selectedItems()[0]
 1741             commit = itm.text(self.CommitIdColumn)
 1742             branches = [b for b in itm.text(self.BranchColumn).split(", ")
 1743                         if "/" not in b]
 1744             if len(branches) == 1:
 1745                 branch = branches[0]
 1746             elif len(branches) > 1:
 1747                 branch, ok = QInputDialog.getItem(
 1748                     self,
 1749                     self.tr("Switch"),
 1750                     self.tr("Select a branch"),
 1751                     [""] + branches,
 1752                     0, False)
 1753                 if not ok:
 1754                     return
 1755             else:
 1756                 branch = ""
 1757             if branch:
 1758                 rev = branch
 1759             else:
 1760                 rev = commit
 1761             pfile = e5App().getObject("Project").getProjectFile()
 1762             lastModified = QFileInfo(pfile).lastModified().toString()
 1763             shouldReopen = (
 1764                 self.vcs.vcsUpdate(self.repodir, revision=rev) or
 1765                 QFileInfo(pfile).lastModified().toString() != lastModified
 1766             )
 1767             if shouldReopen:
 1768                 res = E5MessageBox.yesNo(
 1769                     None,
 1770                     self.tr("Switch"),
 1771                     self.tr(
 1772                         """The project should be reread. Do this now?"""),
 1773                     yesDefault=True)
 1774                 if res:
 1775                     e5App().getObject("Project").reopenProject()
 1776                     return
 1777             
 1778             self.on_refreshButton_clicked()
 1779     
 1780     @pyqtSlot()
 1781     def __branchActTriggered(self):
 1782         """
 1783         Private slot to create a new branch starting at the selected commit.
 1784         """
 1785         if len(self.logTree.selectedItems()) == 1:
 1786             from .GitBranchDialog import GitBranchDialog
 1787             itm = self.logTree.selectedItems()[0]
 1788             commit = itm.text(self.CommitIdColumn)
 1789             branches = [b for b in itm.text(self.BranchColumn).split(", ")
 1790                         if "/" not in b]
 1791             if len(branches) == 1:
 1792                 branch = branches[0]
 1793             elif len(branches) > 1:
 1794                 branch, ok = QInputDialog.getItem(
 1795                     self,
 1796                     self.tr("Branch"),
 1797                     self.tr("Select a default branch"),
 1798                     [""] + branches,
 1799                     0, False)
 1800                 if not ok:
 1801                     return
 1802             else:
 1803                 branch = ""
 1804             res = self.vcs.gitBranch(
 1805                 self.repodir, revision=commit, branchName=branch,
 1806                 branchOp=GitBranchDialog.CreateBranch)
 1807             if res:
 1808                 self.on_refreshButton_clicked()
 1809     
 1810     @pyqtSlot()
 1811     def __branchSwitchActTriggered(self):
 1812         """
 1813         Private slot to create a new branch starting at the selected commit
 1814         and switch the work tree to it.
 1815         """
 1816         if len(self.logTree.selectedItems()) == 1:
 1817             from .GitBranchDialog import GitBranchDialog
 1818             itm = self.logTree.selectedItems()[0]
 1819             commit = itm.text(self.CommitIdColumn)
 1820             branches = [b for b in itm.text(self.BranchColumn).split(", ")
 1821                         if "/" not in b]
 1822             if len(branches) == 1:
 1823                 branch = branches[0]
 1824             elif len(branches) > 1:
 1825                 branch, ok = QInputDialog.getItem(
 1826                     self,
 1827                     self.tr("Branch & Switch"),
 1828                     self.tr("Select a default branch"),
 1829                     [""] + branches,
 1830                     0, False)
 1831                 if not ok:
 1832                     return
 1833             else:
 1834                 branch = ""
 1835             pfile = e5App().getObject("Project").getProjectFile()
 1836             lastModified = QFileInfo(pfile).lastModified().toString()
 1837             res, shouldReopen = self.vcs.gitBranch(
 1838                 self.repodir, revision=commit, branchName=branch,
 1839                 branchOp=GitBranchDialog.CreateSwitchBranch)
 1840             shouldReopen = (
 1841                 shouldReopen or
 1842                 QFileInfo(pfile).lastModified().toString() != lastModified
 1843             )
 1844             if res:
 1845                 if shouldReopen:
 1846                     res = E5MessageBox.yesNo(
 1847                         None,
 1848                         self.tr("Switch"),
 1849                         self.tr(
 1850                             """The project should be reread. Do this now?"""),
 1851                         yesDefault=True)
 1852                     if res:
 1853                         e5App().getObject("Project").reopenProject()
 1854                         return
 1855                 
 1856                 self.on_refreshButton_clicked()
 1857     
 1858     @pyqtSlot()
 1859     def __shortlogActTriggered(self):
 1860         """
 1861         Private slot to show a short log suitable for release announcements.
 1862         """
 1863         if len(self.logTree.selectedItems()) == 1:
 1864             itm = self.logTree.selectedItems()[0]
 1865             commit = itm.text(self.CommitIdColumn)
 1866             branch = itm.text(self.BranchColumn).split(", ", 1)[0]
 1867             branches = [b for b in itm.text(self.BranchColumn).split(", ")
 1868                         if "/" not in b]
 1869             if len(branches) == 1:
 1870                 branch = branches[0]
 1871             elif len(branches) > 1:
 1872                 branch, ok = QInputDialog.getItem(
 1873                     self,
 1874                     self.tr("Show Short Log"),
 1875                     self.tr("Select a branch"),
 1876                     [""] + branches,
 1877                     0, False)
 1878                 if not ok:
 1879                     return
 1880             else:
 1881                 branch = ""
 1882             if branch:
 1883                 rev = branch
 1884             else:
 1885                 rev = commit
 1886             self.vcs.gitShortlog(self.repodir, commit=rev)
 1887     
 1888     @pyqtSlot()
 1889     def __describeActTriggered(self):
 1890         """
 1891         Private slot to show the most recent tag reachable from a commit.
 1892         """
 1893         commits = []
 1894         
 1895         for itm in self.logTree.selectedItems():
 1896             commits.append(itm.text(self.CommitIdColumn))
 1897         
 1898         if commits:
 1899             self.vcs.gitDescribe(self.repodir, commits)
 1900     
 1901     ##################################################################
 1902     ## Log context menu action methods below
 1903     ##################################################################
 1904     
 1905     @pyqtSlot(bool)
 1906     def __showCommitterColumns(self, on):
 1907         """
 1908         Private slot to show/hide the committer columns.
 1909         
 1910         @param on flag indicating the selection state (boolean)
 1911         """
 1912         self.logTree.setColumnHidden(self.CommitterColumn, not on)
 1913         self.logTree.setColumnHidden(self.CommitDateColumn, not on)
 1914         self.vcs.getPlugin().setPreferences("ShowCommitterColumns", on)
 1915         self.__resizeColumnsLog()
 1916     
 1917     @pyqtSlot(bool)
 1918     def __showAuthorColumns(self, on):
 1919         """
 1920         Private slot to show/hide the committer columns.
 1921         
 1922         @param on flag indicating the selection state (boolean)
 1923         """
 1924         self.logTree.setColumnHidden(self.AuthorColumn, not on)
 1925         self.logTree.setColumnHidden(self.DateColumn, not on)
 1926         self.vcs.getPlugin().setPreferences("ShowAuthorColumns", on)
 1927         self.__resizeColumnsLog()
 1928     
 1929     @pyqtSlot(bool)
 1930     def __showCommitIdColumn(self, on):
 1931         """
 1932         Private slot to show/hide the commit ID column.
 1933         
 1934         @param on flag indicating the selection state (boolean)
 1935         """
 1936         self.logTree.setColumnHidden(self.CommitIdColumn, not on)
 1937         self.vcs.getPlugin().setPreferences("ShowCommitIdColumn", on)
 1938         self.__resizeColumnsLog()
 1939     
 1940     @pyqtSlot(bool)
 1941     def __showBranchesColumn(self, on):
 1942         """
 1943         Private slot to show/hide the branches column.
 1944         
 1945         @param on flag indicating the selection state (boolean)
 1946         """
 1947         self.logTree.setColumnHidden(self.BranchColumn, not on)
 1948         self.vcs.getPlugin().setPreferences("ShowBranchesColumn", on)
 1949         self.__resizeColumnsLog()
 1950     
 1951     @pyqtSlot(bool)
 1952     def __showTagsColumn(self, on):
 1953         """
 1954         Private slot to show/hide the tags column.
 1955         
 1956         @param on flag indicating the selection state (boolean)
 1957         """
 1958         self.logTree.setColumnHidden(self.TagsColumn, not on)
 1959         self.vcs.getPlugin().setPreferences("ShowTagsColumn", on)
 1960         self.__resizeColumnsLog()
 1961     
 1962     ##################################################################
 1963     ## Search and filter methods below
 1964     ##################################################################
 1965     
 1966     def __actionMode(self):
 1967         """
 1968         Private method to get the selected action mode.
 1969         
 1970         @return selected action mode (string, one of filter or find)
 1971         """
 1972         return self.modeComboBox.itemData(
 1973             self.modeComboBox.currentIndex())
 1974     
 1975     @pyqtSlot(int)
 1976     def on_modeComboBox_currentIndexChanged(self, index):
 1977         """
 1978         Private slot to react on mode changes.
 1979         
 1980         @param index index of the selected entry (integer)
 1981         """
 1982         mode = self.modeComboBox.itemData(index)
 1983         findMode = mode == "find"
 1984         filterMode = mode == "filter"
 1985         
 1986         self.fromDate.setEnabled(filterMode)
 1987         self.toDate.setEnabled(filterMode)
 1988         self.findPrevButton.setVisible(findMode)
 1989         self.findNextButton.setVisible(findMode)
 1990         
 1991         if findMode:
 1992             for topIndex in range(self.logTree.topLevelItemCount()):
 1993                 self.logTree.topLevelItem(topIndex).setHidden(False)
 1994             self.logTree.header().setSectionHidden(self.IconColumn, False)
 1995         elif filterMode:
 1996             self.__filterLogs()
 1997     
 1998     @pyqtSlot()
 1999     def on_findPrevButton_clicked(self):
 2000         """
 2001         Private slot to find the previous item matching the entered criteria.
 2002         """
 2003         self.__findItem(True)
 2004     
 2005     @pyqtSlot()
 2006     def on_findNextButton_clicked(self):
 2007         """
 2008         Private slot to find the next item matching the entered criteria.
 2009         """
 2010         self.__findItem(False)
 2011     
 2012     def __findItem(self, backwards=False, interactive=False):
 2013         """
 2014         Private slot to find an item matching the entered criteria.
 2015         
 2016         @param backwards flag indicating to search backwards (boolean)
 2017         @param interactive flag indicating an interactive search (boolean)
 2018         """
 2019         self.__findBackwards = backwards
 2020         
 2021         fieldIndex, searchRx, indexIsRole = self.__prepareFieldSearch()
 2022         currentIndex = self.logTree.indexOfTopLevelItem(
 2023             self.logTree.currentItem())
 2024         if backwards:
 2025             if interactive:
 2026                 indexes = range(currentIndex, -1, -1)
 2027             else:
 2028                 indexes = range(currentIndex - 1, -1, -1)
 2029         else:
 2030             if interactive:
 2031                 indexes = range(currentIndex, self.logTree.topLevelItemCount())
 2032             else:
 2033                 indexes = range(currentIndex + 1,
 2034                                 self.logTree.topLevelItemCount())
 2035         
 2036         for index in indexes:
 2037             topItem = self.logTree.topLevelItem(index)
 2038             if indexIsRole:
 2039                 if fieldIndex == self.__changesRole:
 2040                     changes = topItem.data(0, self.__changesRole)
 2041                     txt = "\n".join(
 2042                         [c["path"] for c in changes] +
 2043                         [c["copyfrom"] for c in changes]
 2044                     )
 2045                 else:
 2046                     # Filter based on complete subject text
 2047                     txt = topItem.data(0, self.__subjectRole)
 2048             else:
 2049                 txt = topItem.text(fieldIndex)
 2050             if searchRx.indexIn(txt) > -1:
 2051                 self.logTree.setCurrentItem(self.logTree.topLevelItem(index))
 2052                 break
 2053         else:
 2054             E5MessageBox.information(
 2055                 self,
 2056                 self.tr("Find Commit"),
 2057                 self.tr("""'{0}' was not found.""").format(self.rxEdit.text()))
 2058     
 2059     ##################################################################
 2060     ## Commit navigation methods below
 2061     ##################################################################
 2062     
 2063     def __commitIdClicked(self, url):
 2064         """
 2065         Private slot to handle the anchorClicked signal of the changeset
 2066         details pane.
 2067         
 2068         @param url URL that was clicked
 2069         @type QUrl
 2070         """
 2071         if url.scheme() == "rev":
 2072             # a commit ID was clicked, show the respective item
 2073             commitId = url.path()
 2074             items = self.logTree.findItems(commitId, Qt.MatchStartsWith,
 2075                                            self.CommitIdColumn)
 2076             if items:
 2077                 itm = items[0]
 2078                 if itm.isHidden():
 2079                     itm.setHidden(False)
 2080                 self.logTree.setCurrentItem(itm)
 2081             else:
 2082                 # load the next batch and try again
 2083                 if self.nextButton.isEnabled():
 2084                     self.__addFinishCallback(
 2085                         lambda: self.__commitIdClicked(url))
 2086                     self.on_nextButton_clicked()
 2087     
 2088     ###########################################################################
 2089     ## Diff handling methods below
 2090     ###########################################################################
 2091     
 2092     def __generateDiffs(self, parent=1):
 2093         """
 2094         Private slot to generate diff outputs for the selected item.
 2095         
 2096         @param parent number of parent to diff against
 2097         @type int
 2098         """
 2099         self.diffEdit.clear()
 2100         self.diffLabel.setText(self.tr("Differences"))
 2101         self.diffSelectLabel.clear()
 2102         try:
 2103             self.diffHighlighter.regenerateRules()
 2104         except AttributeError:
 2105             # backward compatibility
 2106             pass
 2107         
 2108         selectedItems = self.logTree.selectedItems()
 2109         if len(selectedItems) == 1:
 2110             currentItem = selectedItems[0]
 2111             commit2 = currentItem.text(self.CommitIdColumn)
 2112             parents = currentItem.data(0, self.__parentsRole)
 2113             if len(parents) >= parent:
 2114                 self.diffLabel.setText(
 2115                     self.tr("Differences to Parent {0}").format(parent))
 2116                 commit1 = parents[parent - 1]
 2117                 
 2118                 self.__diffGenerator.start(self.__filename, [commit1, commit2])
 2119             
 2120             if len(parents) > 1:
 2121                 parentLinks = []
 2122                 for index in range(1, len(parents) + 1):
 2123                     if parent == index:
 2124                         parentLinks.append("&nbsp;{0}&nbsp;".format(index))
 2125                     else:
 2126                         parentLinks.append(
 2127                             '<a href="diff:{0}">&nbsp;{0}&nbsp;</a>'
 2128                             .format(index))
 2129                     self.diffSelectLabel.setText(
 2130                         self.tr('Diff to Parent {0}')
 2131                         .format(" ".join(parentLinks)))
 2132         elif len(selectedItems) == 2:
 2133             commit2 = selectedItems[0].text(self.CommitIdColumn)
 2134             commit1 = selectedItems[1].text(self.CommitIdColumn)
 2135             index2 = self.logTree.indexOfTopLevelItem(selectedItems[0])
 2136             index1 = self.logTree.indexOfTopLevelItem(selectedItems[1])
 2137             
 2138             if index2 < index1:
 2139                 # swap to always compare old to new
 2140                 commit1, commit2 = commit2, commit1
 2141             
 2142             self.__diffGenerator.start(self.__filename, [commit1, commit2])
 2143     
 2144     def __generatorFinished(self):
 2145         """
 2146         Private slot connected to the finished signal of the diff generator.
 2147         """
 2148         diff, _, errors, fileSeparators = self.__diffGenerator.getResult()
 2149         
 2150         if diff:
 2151             self.diffEdit.setPlainText("".join(diff))
 2152         elif errors:
 2153             self.diffEdit.setPlainText("".join(errors))
 2154         else:
 2155             self.diffEdit.setPlainText(self.tr('There is no difference.'))
 2156         
 2157         self.saveLabel.setVisible(bool(diff))
 2158         
 2159         fileSeparators = self.__mergeFileSeparators(fileSeparators)
 2160         if self.__diffUpdatesFiles:
 2161             for oldFileName, newFileName, lineNumber, _ in fileSeparators:
 2162                 if oldFileName == newFileName:
 2163                     item = QTreeWidgetItem(self.filesTree, ["", oldFileName])
 2164                 elif oldFileName == "/dev/null":
 2165                     item = QTreeWidgetItem(self.filesTree, ["", newFileName])
 2166                 else:
 2167                     item = QTreeWidgetItem(
 2168                         self.filesTree, ["", newFileName, "", "", oldFileName])
 2169                 item.setData(0, self.__diffFileLineRole, lineNumber)
 2170             self.__resizeColumnsFiles()
 2171             self.__resortFiles()
 2172         else:
 2173             for oldFileName, newFileName, lineNumber, _ in fileSeparators:
 2174                 for fileName in (oldFileName, newFileName):
 2175                     if fileName != "/dev/null":
 2176                         items = self.filesTree.findItems(
 2177                             fileName, Qt.MatchExactly, 1)
 2178                         for item in items:
 2179                             item.setData(0, self.__diffFileLineRole,
 2180                                          lineNumber)
 2181         
 2182         tc = self.diffEdit.textCursor()
 2183         tc.movePosition(QTextCursor.Start)
 2184         self.diffEdit.setTextCursor(tc)
 2185         self.diffEdit.ensureCursorVisible()
 2186     
 2187     def __mergeFileSeparators(self, fileSeparators):
 2188         """
 2189         Private method to merge the file separator entries.
 2190         
 2191         @param fileSeparators list of file separator entries to be merged
 2192         @return merged list of file separator entries
 2193         """
 2194         separators = {}
 2195         for oldFile, newFile, pos1, pos2 in sorted(fileSeparators):
 2196             if (oldFile, newFile) not in separators:
 2197                 separators[(oldFile, newFile)] = [oldFile, newFile, pos1, pos2]
 2198             else:
 2199                 if pos1 != -2:
 2200                     separators[(oldFile, newFile)][2] = pos1
 2201                 if pos2 != -2:
 2202                     separators[(oldFile, newFile)][3] = pos2
 2203         return list(separators.values())
 2204     
 2205     @pyqtSlot(QTreeWidgetItem, QTreeWidgetItem)
 2206     def on_filesTree_currentItemChanged(self, current, previous):
 2207         """
 2208         Private slot called, when the current item of the files tree changes.
 2209         
 2210         @param current reference to the new current item (QTreeWidgetItem)
 2211         @param previous reference to the old current item (QTreeWidgetItem)
 2212         """
 2213         if current:
 2214             para = current.data(0, self.__diffFileLineRole)
 2215             if para is not None:
 2216                 if para == 0:
 2217                     tc = self.diffEdit.textCursor()
 2218                     tc.movePosition(QTextCursor.Start)
 2219                     self.diffEdit.setTextCursor(tc)
 2220                     self.diffEdit.ensureCursorVisible()
 2221                 elif para == -1:
 2222                     tc = self.diffEdit.textCursor()
 2223                     tc.movePosition(QTextCursor.End)
 2224                     self.diffEdit.setTextCursor(tc)
 2225                     self.diffEdit.ensureCursorVisible()
 2226                 else:
 2227                     # step 1: move cursor to end
 2228                     tc = self.diffEdit.textCursor()
 2229                     tc.movePosition(QTextCursor.End)
 2230                     self.diffEdit.setTextCursor(tc)
 2231                     self.diffEdit.ensureCursorVisible()
 2232                     
 2233                     # step 2: move cursor to desired line
 2234                     tc = self.diffEdit.textCursor()
 2235                     delta = tc.blockNumber() - para
 2236                     tc.movePosition(QTextCursor.PreviousBlock,
 2237                                     QTextCursor.MoveAnchor, delta)
 2238                     self.diffEdit.setTextCursor(tc)
 2239                     self.diffEdit.ensureCursorVisible()
 2240     
 2241     @pyqtSlot(str)
 2242     def on_diffSelectLabel_linkActivated(self, link):
 2243         """
 2244         Private slot to handle the selection of a diff target.
 2245         
 2246         @param link activated link
 2247         @type str
 2248         """
 2249         if ":" in link:
 2250             scheme, parent = link.split(":", 1)
 2251             if scheme == "diff":
 2252                 try:
 2253                     parent = int(parent)
 2254                     self.__generateDiffs(parent)
 2255                 except ValueError:
 2256                     # ignore silently
 2257                     pass
 2258     
 2259     @pyqtSlot(str)
 2260     def on_saveLabel_linkActivated(self, link):
 2261         """
 2262         Private slot to handle the selection of the save link.
 2263         
 2264         @param link activated link
 2265         @type str
 2266         """
 2267         if ":" not in link:
 2268             return
 2269         
 2270         scheme, rest = link.split(":", 1)
 2271         if scheme != "save" or rest != "me":
 2272             return
 2273         
 2274         if self.projectMode:
 2275             fname = self.vcs.splitPath(self.__filename)[0]
 2276             fname += "/{0}.diff".format(os.path.split(fname)[-1])
 2277         else:
 2278             dname, fname = self.vcs.splitPath(self.__filename)
 2279             if fname != '.':
 2280                 fname = "{0}.diff".format(self.__filename)
 2281             else:
 2282                 fname = dname
 2283         
 2284         fname, selectedFilter = E5FileDialog.getSaveFileNameAndFilter(
 2285             self,
 2286             self.tr("Save Diff"),
 2287             fname,
 2288             self.tr("Patch Files (*.diff)"),
 2289             None,
 2290             E5FileDialog.Options(E5FileDialog.DontConfirmOverwrite))
 2291         
 2292         if not fname:
 2293             return  # user aborted
 2294         
 2295         ext = QFileInfo(fname).suffix()
 2296         if not ext:
 2297             ex = selectedFilter.split("(*")[1].split(")")[0]
 2298             if ex:
 2299                 fname += ex
 2300         if QFileInfo(fname).exists():
 2301             res = E5MessageBox.yesNo(
 2302                 self,
 2303                 self.tr("Save Diff"),
 2304                 self.tr("<p>The patch file <b>{0}</b> already exists."
 2305                         " Overwrite it?</p>").format(fname),
 2306                 icon=E5MessageBox.Warning)
 2307             if not res:
 2308                 return
 2309         fname = Utilities.toNativeSeparators(fname)
 2310         
 2311         eol = e5App().getObject("Project").getEolString()
 2312         try:
 2313             f = open(fname, "w", encoding="utf-8", newline="")
 2314             f.write(eol.join(self.diffEdit.toPlainText().splitlines()))
 2315             f.write(eol)
 2316             f.close()
 2317         except IOError as why:
 2318             E5MessageBox.critical(
 2319                 self, self.tr('Save Diff'),
 2320                 self.tr(
 2321                     '<p>The patch file <b>{0}</b> could not be saved.'
 2322                     '<br>Reason: {1}</p>')
 2323                 .format(fname, str(why)))
 2324     
 2325     @pyqtSlot(str)
 2326     def on_sbsSelectLabel_linkActivated(self, link):
 2327         """
 2328         Private slot to handle selection of a side-by-side link.
 2329         
 2330         @param link text of the selected link
 2331         @type str
 2332         """
 2333         if ":" in link:
 2334             scheme, path = link.split(":", 1)
 2335             if scheme == "sbsdiff" and "_" in path:
 2336                 commit1, commit2 = path.split("_", 1)
 2337                 self.vcs.gitSbsDiff(self.__filename,
 2338                                     revisions=(commit1, commit2))