--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/eric7/Plugins/VcsPlugins/vcsMercurial/HgLogBrowserDialog.py Sat May 15 18:45:04 2021 +0200 @@ -0,0 +1,2707 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2010 - 2021 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing a dialog to browse the log history. +""" + +import os +import re +import collections +import contextlib + +from PyQt5.QtCore import pyqtSlot, Qt, QDate, QSize, QPoint, QFileInfo +from PyQt5.QtGui import ( + QColor, QPixmap, QPainter, QPen, QBrush, QIcon, QTextCursor, QPalette +) +from PyQt5.QtWidgets import ( + QWidget, QDialogButtonBox, QHeaderView, QTreeWidgetItem, QApplication, + QLineEdit, QMenu, QInputDialog +) + +from E5Gui.E5Application import e5App +from E5Gui import E5MessageBox, E5FileDialog +from E5Gui.E5OverrideCursor import E5OverrideCursor + +from .Ui_HgLogBrowserDialog import Ui_HgLogBrowserDialog + +from .HgDiffHighlighter import HgDiffHighlighter +from .HgDiffGenerator import HgDiffGenerator + +import UI.PixmapCache +import Preferences +import Utilities + +COLORNAMES = ["blue", "darkgreen", "red", "green", "darkblue", "purple", + "cyan", "olive", "magenta", "darkred", "darkmagenta", + "darkcyan", "gray", "yellow"] +COLORS = [str(QColor(x).name()) for x in COLORNAMES] + +LIGHTCOLORS = ["#aaaaff", "#7faa7f", "#ffaaaa", "#aaffaa", "#7f7faa", + "#ffaaff", "#aaffff", "#d5d579", "#ffaaff", "#d57979", + "#d579d5", "#79d5d5", "#d5d5d5", "#d5d500", + ] + + +class HgLogBrowserDialog(QWidget, Ui_HgLogBrowserDialog): + """ + Class implementing a dialog to browse the log history. + """ + IconColumn = 0 + BranchColumn = 1 + RevisionColumn = 2 + PhaseColumn = 3 + AuthorColumn = 4 + DateColumn = 5 + MessageColumn = 6 + TagsColumn = 7 + BookmarksColumn = 8 + + LargefilesCacheL = ".hglf/" + LargefilesCacheW = ".hglf\\" + PathSeparatorRe = re.compile(r"/|\\") + + ClosedIndicator = " \u2612" + + def __init__(self, vcs, mode="", parent=None): + """ + Constructor + + @param vcs reference to the vcs object + @type Hg + @param mode mode of the dialog + @type str (one of log, full_log, incoming, outgoing) + @param parent parent widget + @type QWidget + """ + super().__init__(parent) + self.setupUi(self) + + windowFlags = self.windowFlags() + windowFlags |= Qt.WindowType.WindowContextHelpButtonHint + self.setWindowFlags(windowFlags) + + self.mainSplitter.setSizes([300, 400]) + self.mainSplitter.setStretchFactor(0, 1) + self.mainSplitter.setStretchFactor(1, 2) + self.diffSplitter.setStretchFactor(0, 1) + self.diffSplitter.setStretchFactor(1, 2) + + if not mode: + if vcs.getPlugin().getPreferences("LogBrowserShowFullLog"): + mode = "full_log" + else: + mode = "log" + + if mode == "log": + self.setWindowTitle(self.tr("Mercurial Log")) + elif mode == "incoming": + self.setWindowTitle(self.tr("Mercurial Log (Incoming)")) + elif mode == "outgoing": + self.setWindowTitle(self.tr("Mercurial Log (Outgoing)")) + elif mode == "full_log": + self.setWindowTitle(self.tr("Mercurial Full Log")) + + self.buttonBox.button( + QDialogButtonBox.StandardButton.Close).setEnabled(False) + self.buttonBox.button( + QDialogButtonBox.StandardButton.Cancel).setDefault(True) + + self.filesTree.headerItem().setText(self.filesTree.columnCount(), "") + self.filesTree.header().setSortIndicator( + 0, Qt.SortOrder.AscendingOrder) + + self.upButton.setIcon(UI.PixmapCache.getIcon("1uparrow")) + self.downButton.setIcon(UI.PixmapCache.getIcon("1downarrow")) + + self.refreshButton = self.buttonBox.addButton( + self.tr("&Refresh"), QDialogButtonBox.ButtonRole.ActionRole) + self.refreshButton.setToolTip( + self.tr("Press to refresh the list of changesets")) + self.refreshButton.setEnabled(False) + + self.findPrevButton.setIcon(UI.PixmapCache.getIcon("1leftarrow")) + self.findNextButton.setIcon(UI.PixmapCache.getIcon("1rightarrow")) + self.__findBackwards = False + + self.modeComboBox.addItem(self.tr("Find"), "find") + self.modeComboBox.addItem(self.tr("Filter"), "filter") + + self.fieldCombo.addItem(self.tr("Revision"), "revision") + self.fieldCombo.addItem(self.tr("Author"), "author") + self.fieldCombo.addItem(self.tr("Message"), "message") + self.fieldCombo.addItem(self.tr("File"), "file") + self.fieldCombo.addItem(self.tr("Phase"), "phase") + + font = Preferences.getEditorOtherFonts("MonospacedFont") + self.diffEdit.document().setDefaultFont(font) + + self.diffHighlighter = HgDiffHighlighter(self.diffEdit.document()) + self.__diffGenerator = HgDiffGenerator(vcs, self) + self.__diffGenerator.finished.connect(self.__generatorFinished) + + self.vcs = vcs + if mode in ("log", "incoming", "outgoing", "full_log"): + if mode == "full_log": + self.commandMode = "incoming" + else: + self.commandMode = mode + self.initialCommandMode = mode + else: + self.commandMode = "log" + self.initialCommandMode = "log" + self.__hgClient = vcs.getClient() + + self.__detailsTemplate = self.tr( + "<table>" + "<tr><td><b>Revision</b></td><td>{0}</td></tr>" + "<tr><td><b>Date</b></td><td>{1}</td></tr>" + "<tr><td><b>Author</b></td><td>{2}</td></tr>" + "<tr><td><b>Branch</b></td><td>{3}</td></tr>" + "{4}" + "<tr><td><b>Message</b></td><td>{5}</td></tr>" + "</table>" + ) + self.__parentsTemplate = self.tr( + "<tr><td><b>Parents</b></td><td>{0}</td></tr>" + ) + self.__childrenTemplate = self.tr( + "<tr><td><b>Children</b></td><td>{0}</td></tr>" + ) + self.__tagsTemplate = self.tr( + "<tr><td><b>Tags</b></td><td>{0}</td></tr>" + ) + self.__latestTagTemplate = self.tr( + "<tr><td><b>Latest Tag</b></td><td>{0}</td></tr>" + ) + self.__bookmarksTemplate = self.tr( + "<tr><td><b>Bookmarks</b></td><td>{0}</td></tr>" + ) + + self.__bundle = "" + self.__filename = "" + self.__isFile = False + self.__selectedRevisions = [] + self.intercept = False + + self.__initData() + + self.__allBranchesFilter = self.tr("All") + + self.fromDate.setDisplayFormat("yyyy-MM-dd") + self.toDate.setDisplayFormat("yyyy-MM-dd") + self.__resetUI() + + # roles used in the log tree + self.__messageRole = Qt.ItemDataRole.UserRole + self.__changesRole = Qt.ItemDataRole.UserRole + 1 + self.__edgesRole = Qt.ItemDataRole.UserRole + 2 + self.__parentsRole = Qt.ItemDataRole.UserRole + 3 + self.__latestTagRole = Qt.ItemDataRole.UserRole + 4 + self.__incomingRole = Qt.ItemDataRole.UserRole + 5 + + # roles used in the file tree + self.__diffFileLineRole = Qt.ItemDataRole.UserRole + + self.flags = { + 'A': self.tr('Added'), + 'D': self.tr('Deleted'), + 'M': self.tr('Modified'), + } + + self.phases = { + 'draft': self.tr("Draft"), + 'public': self.tr("Public"), + 'secret': self.tr("Secret"), + } + + self.__dotRadius = 8 + self.__rowHeight = 20 + + self.logTree.setIconSize( + QSize(100 * self.__rowHeight, self.__rowHeight)) + self.BookmarksColumn = self.logTree.columnCount() + self.logTree.headerItem().setText( + self.BookmarksColumn, self.tr("Bookmarks")) + + self.__logTreeNormalFont = self.logTree.font() + self.__logTreeNormalFont.setBold(False) + self.__logTreeBoldFont = self.logTree.font() + self.__logTreeBoldFont.setBold(True) + self.__logTreeHasDarkBackground = e5App().usesDarkPalette() + + self.detailsEdit.anchorClicked.connect(self.__revisionClicked) + + self.__initActionsMenu() + + self.__finishCallbacks = [] + if self.initialCommandMode == "full_log": + self.__addFinishCallback(self.on_nextButton_clicked) + + def __addFinishCallback(self, callback): + """ + Private method to add a method to be called once the process finished. + + The callback methods are invoke in a FIFO style and are consumed. If + a callback method needs to be called again, it must be added again. + + @param callback callback method + @type function + """ + if callback not in self.__finishCallbacks: + self.__finishCallbacks.append(callback) + + def __initActionsMenu(self): + """ + Private method to initialize the actions menu. + """ + self.__actionsMenu = QMenu() + self.__actionsMenu.setTearOffEnabled(True) + self.__actionsMenu.setToolTipsVisible(True) + + self.__graftAct = self.__actionsMenu.addAction( + UI.PixmapCache.getIcon("vcsGraft"), + self.tr("Copy Changesets"), self.__graftActTriggered) + self.__graftAct.setToolTip(self.tr( + "Copy the selected changesets to the current branch")) + + self.__mergeAct = self.__actionsMenu.addAction( + UI.PixmapCache.getIcon("vcsMerge"), + self.tr("Merge with Changeset"), self.__mergeActTriggered) + self.__mergeAct.setToolTip(self.tr( + "Merge the working directory with the selected changeset")) + + self.__phaseAct = self.__actionsMenu.addAction( + self.tr("Change Phase"), self.__phaseActTriggered) + self.__phaseAct.setToolTip(self.tr( + "Change the phase of the selected revisions")) + self.__phaseAct.setWhatsThis(self.tr( + """<b>Change Phase</b>\n<p>This changes the phase of the""" + """ selected revisions. The selected revisions have to have""" + """ the same current phase.</p>""")) + + self.__tagAct = self.__actionsMenu.addAction( + UI.PixmapCache.getIcon("vcsTag"), self.tr("Tag"), + self.__tagActTriggered) + self.__tagAct.setToolTip(self.tr("Tag the selected revision")) + + self.__closeHeadsAct = self.__actionsMenu.addAction( + UI.PixmapCache.getIcon("closehead"), self.tr("Close Heads"), + self.__closeHeadsActTriggered) + self.__closeHeadsAct.setToolTip(self.tr("Close the selected heads")) + + self.__switchAct = self.__actionsMenu.addAction( + UI.PixmapCache.getIcon("vcsSwitch"), self.tr("Switch"), + self.__switchActTriggered) + self.__switchAct.setToolTip(self.tr( + "Switch the working directory to the selected revision")) + + self.__actionsMenu.addSeparator() + + self.__bookmarkAct = self.__actionsMenu.addAction( + UI.PixmapCache.getIcon("addBookmark"), + self.tr("Define Bookmark..."), self.__bookmarkActTriggered) + self.__bookmarkAct.setToolTip( + self.tr("Bookmark the selected revision")) + self.__bookmarkMoveAct = self.__actionsMenu.addAction( + UI.PixmapCache.getIcon("moveBookmark"), + self.tr("Move Bookmark..."), self.__bookmarkMoveActTriggered) + self.__bookmarkMoveAct.setToolTip( + self.tr("Move bookmark to the selected revision")) + + self.__actionsMenu.addSeparator() + + self.__pullAct = self.__actionsMenu.addAction( + UI.PixmapCache.getIcon("vcsUpdate"), self.tr("Pull Changes"), + self.__pullActTriggered) + self.__pullAct.setToolTip(self.tr( + "Pull changes from a remote repository")) + self.__lfPullAct = self.__actionsMenu.addAction( + self.tr("Pull Large Files"), self.__lfPullActTriggered) + self.__lfPullAct.setToolTip(self.tr( + "Pull large files for selected revisions")) + + self.__actionsMenu.addSeparator() + + self.__pushAct = self.__actionsMenu.addAction( + UI.PixmapCache.getIcon("vcsCommit"), + self.tr("Push Selected Changes"), self.__pushActTriggered) + self.__pushAct.setToolTip(self.tr( + "Push changes of the selected changeset and its ancestors" + " to a remote repository")) + self.__pushAllAct = self.__actionsMenu.addAction( + UI.PixmapCache.getIcon("vcsCommit"), + self.tr("Push All Changes"), self.__pushAllActTriggered) + self.__pushAllAct.setToolTip(self.tr( + "Push all changes to a remote repository")) + + self.__actionsMenu.addSeparator() + + self.__bundleAct = self.__actionsMenu.addAction( + UI.PixmapCache.getIcon("vcsCreateChangegroup"), + self.tr("Create Changegroup"), self.__bundleActTriggered) + self.__bundleAct.setToolTip(self.tr( + "Create a changegroup file containing the selected changesets")) + self.__bundleAct.setWhatsThis(self.tr( + """<b>Create Changegroup</b>\n<p>This creates a changegroup""" + """ file containing the selected revisions. If no revisions""" + """ are selected, all changesets will be bundled. If one""" + """ revision is selected, it will be interpreted as the base""" + """ revision. Otherwise the lowest revision will be used as""" + """ the base revision and all other revision will be bundled.""" + """ If the dialog is showing outgoing changesets, all""" + """ selected changesets will be bundled.</p>""")) + self.__unbundleAct = self.__actionsMenu.addAction( + UI.PixmapCache.getIcon("vcsApplyChangegroup"), + self.tr("Apply Changegroup"), self.__unbundleActTriggered) + self.__unbundleAct.setToolTip(self.tr( + "Apply the currently viewed changegroup file")) + + self.__actionsMenu.addSeparator() + + self.__gpgSignAct = self.__actionsMenu.addAction( + UI.PixmapCache.getIcon("changesetSign"), + self.tr("Sign Revisions"), self.__gpgSignActTriggered) + self.__gpgSignAct.setToolTip(self.tr( + "Add a signature for the selected revisions")) + self.__gpgVerifyAct = self.__actionsMenu.addAction( + UI.PixmapCache.getIcon("changesetSignVerify"), + self.tr("Verify Signatures"), self.__gpgVerifyActTriggered) + self.__gpgVerifyAct.setToolTip(self.tr( + "Verify all signatures there may be for the selected revision")) + + self.__actionsMenu.addSeparator() + + self.__stripAct = self.__actionsMenu.addAction( + UI.PixmapCache.getIcon("fileDelete"), + self.tr("Strip Changesets"), self.__stripActTriggered) + self.__stripAct.setToolTip(self.tr( + "Strip changesets from a repository")) + + self.__actionsMenu.addSeparator() + + self.__selectAllAct = self.__actionsMenu.addAction( + self.tr("Select All Entries"), self.__selectAllActTriggered) + self.__unselectAllAct = self.__actionsMenu.addAction( + self.tr("Deselect All Entries"), + lambda: self.__selectAllActTriggered(False)) + + self.actionsButton.setIcon( + UI.PixmapCache.getIcon("actionsToolButton")) + self.actionsButton.setMenu(self.__actionsMenu) + + def __initData(self): + """ + Private method to (re-)initialize some data. + """ + self.__maxDate = QDate() + self.__minDate = QDate() + self.__filterLogsEnabled = True + + self.buf = [] # buffer for stdout + self.diff = None + self.__started = False + self.__lastRev = 0 + self.projectMode = False + + # attributes to store log graph data + self.__revs = [] + self.__revColors = {} + self.__revColor = 0 + + self.__branchColors = {} + + self.__projectWorkingDirParents = [] + self.__projectBranch = "" + + self.__childrenInfo = collections.defaultdict(list) + + def closeEvent(self, e): + """ + Protected slot implementing a close event handler. + + @param e close event (QCloseEvent) + """ + if self.__hgClient.isExecuting(): + self.__hgClient.cancel() + + self.vcs.getPlugin().setPreferences( + "LogBrowserGeometry", self.saveGeometry()) + self.vcs.getPlugin().setPreferences( + "LogBrowserSplitterStates", [ + self.mainSplitter.saveState(), + self.detailsSplitter.saveState(), + self.diffSplitter.saveState(), + ] + ) + + e.accept() + + def show(self): + """ + Public slot to show the dialog. + """ + self.__reloadGeometry() + self.__restoreSplitterStates() + self.__resetUI() + + super().show() + + def __reloadGeometry(self): + """ + Private method to restore the geometry. + """ + geom = self.vcs.getPlugin().getPreferences("LogBrowserGeometry") + if geom.isEmpty(): + s = QSize(1000, 800) + self.resize(s) + else: + self.restoreGeometry(geom) + + def __restoreSplitterStates(self): + """ + Private method to restore the state of the various splitters. + """ + states = self.vcs.getPlugin().getPreferences( + "LogBrowserSplitterStates") + if len(states) == 3: + # we have three splitters + self.mainSplitter.restoreState(states[0]) + self.detailsSplitter.restoreState(states[1]) + self.diffSplitter.restoreState(states[2]) + + def __resetUI(self): + """ + Private method to reset the user interface. + """ + self.branchCombo.clear() + self.fromDate.setDate(QDate.currentDate()) + self.toDate.setDate(QDate.currentDate()) + self.fieldCombo.setCurrentIndex(self.fieldCombo.findData("message")) + self.limitSpinBox.setValue(self.vcs.getPlugin().getPreferences( + "LogLimit")) + self.stopCheckBox.setChecked(self.vcs.getPlugin().getPreferences( + "StopLogOnCopy")) + + if self.initialCommandMode in ("incoming", "outgoing"): + self.nextButton.setEnabled(False) + self.limitSpinBox.setEnabled(False) + else: + self.nextButton.setEnabled(True) + self.limitSpinBox.setEnabled(True) + + self.logTree.clear() + + if self.initialCommandMode == "full_log": + self.commandMode = "incoming" + else: + self.commandMode = self.initialCommandMode + + def __resizeColumnsLog(self): + """ + Private method to resize the log tree columns. + """ + self.logTree.header().resizeSections( + QHeaderView.ResizeMode.ResizeToContents) + self.logTree.header().setStretchLastSection(True) + + def __resizeColumnsFiles(self): + """ + Private method to resize the changed files tree columns. + """ + self.filesTree.header().resizeSections( + QHeaderView.ResizeMode.ResizeToContents) + self.filesTree.header().setStretchLastSection(True) + + def __resortFiles(self): + """ + Private method to resort the changed files tree. + """ + sortColumn = self.filesTree.sortColumn() + self.filesTree.sortItems( + 1, self.filesTree.header().sortIndicatorOrder()) + self.filesTree.sortItems( + sortColumn, self.filesTree.header().sortIndicatorOrder()) + + def __getColor(self, n): + """ + Private method to get the (rotating) name of the color given an index. + + @param n color index + @type int + @return color name + @rtype str + """ + if self.__logTreeHasDarkBackground: + return LIGHTCOLORS[n % len(LIGHTCOLORS)] + else: + return COLORS[n % len(COLORS)] + + def __branchColor(self, branchName): + """ + Private method to calculate a color for a given branch name. + + @param branchName name of the branch (string) + @return name of the color to use (string) + """ + if branchName not in self.__branchColors: + self.__branchColors[branchName] = self.__getColor( + len(self.__branchColors)) + return self.__branchColors[branchName] + + def __generateEdges(self, rev, parents): + """ + Private method to generate edge info for the give data. + + @param rev revision to calculate edge info for (integer) + @param parents list of parent revisions (list of integers) + @return tuple containing the column and color index for + the given node and a list of tuples indicating the edges + between the given node and its parents + (integer, integer, [(integer, integer, integer), ...]) + """ + if rev not in self.__revs: + # new head + self.__revs.append(rev) + self.__revColors[rev] = self.__revColor + self.__revColor += 1 + + col = self.__revs.index(rev) + color = self.__revColors.pop(rev) + nextRevs = self.__revs[:] + + # add parents to next + addparents = [p for p in parents if p not in nextRevs] + nextRevs[col:col + 1] = addparents + + # set colors for the parents + for i, p in enumerate(addparents): + if not i: + self.__revColors[p] = color + else: + self.__revColors[p] = self.__revColor + self.__revColor += 1 + + # add edges to the graph + edges = [] + if parents[0] != -1: + for ecol, erev in enumerate(self.__revs): + if erev in nextRevs: + edges.append( + (ecol, nextRevs.index(erev), self.__revColors[erev])) + elif erev == rev: + for p in parents: + edges.append( + (ecol, nextRevs.index(p), self.__revColors[p])) + + self.__revs = nextRevs + return col, color, edges + + def __generateIcon(self, column, color, bottomedges, topedges, dotColor, + currentRev, closed, isPushableDraft): + """ + Private method to generate an icon containing the revision tree for the + given data. + + @param column column index of the revision + @type int + @param color color of the node + @type int + @param bottomedges list of edges for the bottom of the node + @type list of tuples of (int, int, int) + @param topedges list of edges for the top of the node + @type list of tuples of (int, int, int) + @param dotColor color to be used for the dot + @type QColor + @param currentRev flag indicating to draw the icon for the + current revision + @type bool + @param closed flag indicating to draw an icon for a closed + branch + @type bool + @param isPushableDraft flag indicating an entry of phase 'draft', + that can by pushed + @type bool + @return icon for the node + @rtype QIcon + """ + def col2x(col, radius): + """ + Local function to calculate a x-position for a column. + + @param col column number (integer) + @param radius radius of the indicator circle (integer) + """ + return int(1.2 * radius) * col + radius // 2 + 3 + + textColor = self.logTree.palette().color(QPalette.ColorRole.Text) + + radius = self.__dotRadius + w = len(bottomedges) * radius + 20 + h = self.__rowHeight + + dot_x = col2x(column, radius) - radius // 2 + dot_y = h // 2 + + pix = QPixmap(w, h) + pix.fill(QColor(0, 0, 0, 0)) # draw transparent background + painter = QPainter(pix) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + # draw the revision history lines + for y1, y2, lines in ((0, h, bottomedges), + (-h, 0, topedges)): + if lines: + for start, end, ecolor in lines: + lpen = QPen(QColor(self.__getColor(ecolor))) + lpen.setWidth(2) + painter.setPen(lpen) + x1 = col2x(start, radius) + x2 = col2x(end, radius) + painter.drawLine(x1, dot_y + y1, x2, dot_y + y2) + + penradius = 1 + pencolor = textColor + + dot_y = (h // 2) - radius // 2 + + # draw an indicator for the revision + if currentRev: + # enlarge for the current revision + delta = 1 + radius += 2 * delta + dot_y -= delta + dot_x -= delta + penradius = 3 + painter.setBrush(dotColor) + pen = QPen(pencolor) + pen.setWidth(penradius) + painter.setPen(pen) + if closed: + painter.drawRect(dot_x - 2, dot_y + 1, + radius + 4, radius - 2) + elif self.commandMode in ("incoming", "outgoing"): + offset = radius // 2 + if self.commandMode == "incoming": + # incoming: draw a down arrow + painter.drawConvexPolygon( + QPoint(dot_x, dot_y), + QPoint(dot_x + 2 * offset, dot_y), + QPoint(dot_x + offset, dot_y + 2 * offset) + ) + else: + # outgoing: draw an up arrow + painter.drawConvexPolygon( + QPoint(dot_x + offset, dot_y), + QPoint(dot_x, dot_y + 2 * offset), + QPoint(dot_x + 2 * offset, dot_y + 2 * offset) + ) + else: + if isPushableDraft: + # 'draft' phase: draw an up arrow like outgoing, + # if it can be pushed + offset = radius // 2 + painter.drawConvexPolygon( + QPoint(dot_x + offset, dot_y), + QPoint(dot_x, dot_y + 2 * offset), + QPoint(dot_x + 2 * offset, dot_y + 2 * offset) + ) + else: + painter.drawEllipse(dot_x, dot_y, radius, radius) + painter.end() + return QIcon(pix) + + def __getParents(self, rev): + """ + Private method to get the parents of the currently viewed + file/directory. + + @param rev revision number to get parents for (string) + @return list of parent revisions (list of integers) + """ + errMsg = "" + parents = [-1] + + if int(rev) > 0: + args = self.vcs.initCommand("parents") + if self.commandMode == "incoming": + if self.__bundle: + args.append("--repository") + args.append(self.__bundle) + elif ( + self.vcs.bundleFile and + os.path.exists(self.vcs.bundleFile) + ): + args.append("--repository") + args.append(self.vcs.bundleFile) + args.append("--template") + args.append("{rev}\n") + args.append("-r") + args.append(rev) + if not self.projectMode: + args.append(self.__filename) + + output, errMsg = self.__hgClient.runcommand(args) + + if output: + parents = [int(p) for p in output.strip().splitlines()] + + return parents + + def __identifyProject(self): + """ + Private method to determine the revision of the project directory. + """ + errMsg = "" + + args = self.vcs.initCommand("identify") + args.append("-nb") + + output, errMsg = self.__hgClient.runcommand(args) + + if errMsg: + E5MessageBox.critical( + self, + self.tr("Mercurial Error"), + errMsg) + + if output: + outputList = output.strip().split(None, 1) + if len(outputList) == 2: + outputRevs = outputList[0].strip() + if outputRevs.endswith("+"): + outputRevs = outputRevs[:-1] + self.__projectWorkingDirParents = outputRevs.split('+') + else: + self.__projectWorkingDirParents = [outputRevs] + self.__projectBranch = outputList[1].strip() + + def __getClosedBranches(self): + """ + Private method to get the list of closed branches. + """ + self.__closedBranchesRevs = [] + errMsg = "" + + args = self.vcs.initCommand("branches") + args.append("--closed") + + output, errMsg = self.__hgClient.runcommand(args) + + if errMsg: + E5MessageBox.critical( + self, + self.tr("Mercurial Error"), + errMsg) + + if output: + for line in output.splitlines(): + if line.strip().endswith("(closed)"): + parts = line.split() + self.__closedBranchesRevs.append( + parts[-2].split(":", 1)[0]) + + def __getHeads(self): + """ + Private method to get the list of all heads. + """ + self.__headRevisions = [] + errMsg = "" + + args = self.vcs.initCommand("heads") + args.append("--closed") + args.append("--template") + args.append("{rev}\n") + + output, errMsg = self.__hgClient.runcommand(args) + + if errMsg: + E5MessageBox.critical( + self, + self.tr("Mercurial Error"), + errMsg) + + if output: + for line in output.splitlines(): + line = line.strip() + if line: + self.__headRevisions.append(line) + + def __getRevisionOfTag(self, tag): + """ + Private method to get the revision of a tag. + + @param tag tag name + @type str + @return tuple containing the revision and changeset ID + @rtype tuple of (str, str) + """ + errMsg = "" + + args = self.vcs.initCommand("tags") + + output, errMsg = self.__hgClient.runcommand(args) + + if errMsg: + E5MessageBox.critical( + self, + self.tr("Mercurial Error"), + errMsg) + + res = ("", "") + if output: + for line in output.splitlines(): + if line.strip(): + with contextlib.suppress(ValueError): + name, rev = line.strip().rsplit(None, 1) + if name == tag: + res = tuple(rev.split(":", 1)) + break + + return res + + def __generateLogItem(self, author, date, message, revision, changedPaths, + parents, branches, tags, phase, bookmarks, + latestTag, canPush=False): + """ + Private method to generate a log tree entry. + + @param author author info + @type str + @param date date info + @type str + @param message text of the log message + @type list of str + @param revision revision info + @type str + @param changedPaths list of dictionary objects containing + info about the changed files/directories + @type dict + @param parents list of parent revisions + @type list of int + @param branches list of branches + @type list of str + @param tags list of tags + @type str + @param phase phase of the entry + @type str + @param bookmarks list of bookmarks + @type str + @param latestTag the latest tag(s) reachable from the changeset + @type list of str + @param canPush flag indicating that changesets can be pushed + @type bool + @return reference to the generated item + @rtype QTreeWidgetItem + """ + logMessageColumnWidth = self.vcs.getPlugin().getPreferences( + "LogMessageColumnWidth") + msgtxt = "" + for line in message: + if ". " in line: + msgtxt += " " + line.strip().split(". ", 1)[0] + "." + break + else: + msgtxt += " " + line.strip() + if len(msgtxt) > logMessageColumnWidth: + msgtxt = "{0}...".format(msgtxt[:logMessageColumnWidth]) + + rev, node = revision.split(":") + closedStr = (self.ClosedIndicator + if rev in self.__closedBranchesRevs else "") + phaseStr = self.phases.get(phase, phase) + columnLabels = [ + "", + branches[0] + closedStr, + "{0:>7}:{1}".format(rev, node), + phaseStr, + author, + date, + msgtxt, + ", ".join(tags), + ] + if bookmarks is not None: + columnLabels.append(", ".join(bookmarks)) + itm = QTreeWidgetItem(self.logTree, columnLabels) + + itm.setForeground(self.BranchColumn, + QBrush(QColor(self.__branchColor(branches[0])))) + + if not self.projectMode: + parents = self.__getParents(rev) + if not parents: + parents = [int(rev) - 1] + column, color, edges = self.__generateEdges(int(rev), parents) + + itm.setData(0, self.__messageRole, message) + itm.setData(0, self.__changesRole, changedPaths) + itm.setData(0, self.__edgesRole, edges) + itm.setData(0, self.__latestTagRole, latestTag) + if parents == [-1]: + itm.setData(0, self.__parentsRole, []) + else: + itm.setData(0, self.__parentsRole, parents) + for parent in parents: + self.__childrenInfo[parent].append(int(rev)) + itm.setData(0, self.__incomingRole, self.commandMode == "incoming") + + topedges = ( + self.logTree.topLevelItem( + self.logTree.indexOfTopLevelItem(itm) - 1 + ).data(0, self.__edgesRole) + if self.logTree.topLevelItemCount() > 1 else + None + ) + + icon = self.__generateIcon(column, color, edges, topedges, + QColor(self.__branchColor(branches[0])), + rev in self.__projectWorkingDirParents, + rev in self.__closedBranchesRevs, + phase == "draft" and canPush) + itm.setIcon(0, icon) + + try: + self.__lastRev = int(revision.split(":")[0]) + except ValueError: + self.__lastRev = 0 + + return itm + + def __getLogEntries(self, startRev=None, noEntries=0): + """ + Private method to retrieve log entries from the repository. + + @param startRev revision number to start from (integer, string) + @param noEntries number of entries to get (0 = default) (int) + """ + self.buttonBox.button( + QDialogButtonBox.StandardButton.Close).setEnabled(False) + self.buttonBox.button( + QDialogButtonBox.StandardButton.Cancel).setEnabled(True) + self.buttonBox.button( + QDialogButtonBox.StandardButton.Cancel).setDefault(True) + QApplication.processEvents() + + with E5OverrideCursor(): + self.buf = [] + self.cancelled = False + self.errors.clear() + self.intercept = False + + if noEntries == 0: + noEntries = self.limitSpinBox.value() + + preargs = [] + args = self.vcs.initCommand(self.commandMode) + args.append('--verbose') + if self.commandMode not in ("incoming", "outgoing"): + args.append('--limit') + args.append(str(noEntries)) + if self.commandMode in ("incoming", "outgoing"): + args.append("--newest-first") + if self.vcs.hasSubrepositories(): + args.append("--subrepos") + if startRev is not None: + args.append('--rev') + args.append('{0}:0'.format(startRev)) + if ( + not self.projectMode and + not self.stopCheckBox.isChecked() + ): + args.append('--follow') + if self.commandMode == "log": + args.append('--copies') + args.append('--template') + args.append(os.path.join(os.path.dirname(__file__), + "templates", + "logBrowserBookmarkPhase.tmpl")) + if self.commandMode == "incoming": + if self.__bundle: + args.append(self.__bundle) + elif not self.vcs.hasSubrepositories(): + project = e5App().getObject("Project") + self.vcs.bundleFile = os.path.join( + project.getProjectManagementDir(), "hg-bundle.hg") + if os.path.exists(self.vcs.bundleFile): + os.remove(self.vcs.bundleFile) + preargs = args[:] + preargs.append("--quiet") + preargs.append('--bundle') + preargs.append(self.vcs.bundleFile) + args.append(self.vcs.bundleFile) + if not self.projectMode: + args.append(self.__filename) + + if preargs: + out, err = self.__hgClient.runcommand(preargs) + else: + err = "" + if err: + if ( + self.commandMode == "incoming" and + self.initialCommandMode == "full_log" + ): + # ignore the error + self.commandMode = "log" + else: + self.__showError(err) + elif ( + self.commandMode != "incoming" or + (self.vcs.bundleFile and + os.path.exists(self.vcs.bundleFile)) or + self.__bundle + ): + out, err = self.__hgClient.runcommand(args) + self.buf = out.splitlines(True) + if err: + self.__showError(err) + self.__processBuffer() + elif ( + self.commandMode == "incoming" and + self.initialCommandMode == "full_log" + ): + # no incoming changesets, just switch to log mode + self.commandMode = "log" + self.__finish() + + def start(self, name=None, bundle=None, isFile=False, noEntries=0): + """ + Public slot to start the hg log command. + + @param name file/directory name to show the log for + @type str + @param bundle name of a bundle file + @type str + @param isFile flag indicating log for a file is to be shown + @type bool + @param noEntries number of entries to get (0 = default) + @type int + """ + self.__bundle = bundle + self.__isFile = isFile + + if self.initialCommandMode == "full_log": + if isFile: + self.commandMode = "log" + self.__finishCallbacks = [] + else: + self.commandMode = "incoming" + self.__addFinishCallback(self.on_nextButton_clicked) + + self.sbsSelectLabel.clear() + + self.errorGroup.hide() + self.errors.clear() + QApplication.processEvents() + + self.__initData() + + self.__filename = name + + self.projectMode = name is None + self.stopCheckBox.setDisabled(self.projectMode) + self.activateWindow() + self.raise_() + + self.logTree.clear() + self.__started = True + self.__identifyProject() + self.__getClosedBranches() + self.__getHeads() + self.__getLogEntries(noEntries=noEntries) + + def __finish(self): + """ + Private slot called when the process finished or the user pressed + the button. + """ + self.buttonBox.button( + QDialogButtonBox.StandardButton.Close).setEnabled(True) + self.buttonBox.button( + QDialogButtonBox.StandardButton.Cancel).setEnabled(False) + self.buttonBox.button( + QDialogButtonBox.StandardButton.Close).setDefault(True) + + self.refreshButton.setEnabled(True) + + while self.__finishCallbacks: + self.__finishCallbacks.pop(0)() + + def __modifyForLargeFiles(self, filename): + """ + Private method to convert the displayed file name for a large file. + + @param filename file name to be processed (string) + @return processed file name (string) + """ + if filename.startswith((self.LargefilesCacheL, self.LargefilesCacheW)): + return self.tr("{0} (large file)").format( + self.PathSeparatorRe.split(filename, 1)[1]) + else: + return filename + + def __processBuffer(self): + """ + Private method to process the buffered output of the hg log command. + """ + noEntries = 0 + log = {"message": [], "bookmarks": None, "phase": ""} + changedPaths = [] + initialText = True + fileCopies = {} + canPush = self.vcs.canPush() + for s in self.buf: + if s != "@@@\n": + try: + key, value = s.split("|", 1) + except ValueError: + key = "" + value = s + if key == "change": + initialText = False + log["revision"] = value.strip() + elif key == "user": + log["author"] = value.strip() + elif key == "parents": + log["parents"] = [ + int(x.split(":", 1)[0]) + for x in value.strip().split() + ] + elif key == "date": + log["date"] = " ".join(value.strip().split()[:2]) + elif key == "description": + log["message"].append(value.strip()) + elif key == "file_adds": + if value.strip(): + for f in value.strip().split(", "): + if f in fileCopies: + changedPaths.append({ + "action": "A", + "path": self.__modifyForLargeFiles(f), + "copyfrom": self.__modifyForLargeFiles( + fileCopies[f]), + }) + else: + changedPaths.append({ + "action": "A", + "path": self.__modifyForLargeFiles(f), + "copyfrom": "", + }) + elif key == "files_mods": + if value.strip(): + for f in value.strip().split(", "): + changedPaths.append({ + "action": "M", + "path": self.__modifyForLargeFiles(f), + "copyfrom": "", + }) + elif key == "file_dels": + if value.strip(): + for f in value.strip().split(", "): + changedPaths.append({ + "action": "D", + "path": self.__modifyForLargeFiles(f), + "copyfrom": "", + }) + elif key == "file_copies": + if value.strip(): + for entry in value.strip().split(", "): + newName, oldName = entry[:-1].split(" (") + fileCopies[newName] = oldName + elif key == "branches": + if value.strip(): + log["branches"] = value.strip().split(", ") + else: + log["branches"] = ["default"] + elif key == "tags": + log["tags"] = value.strip().split(", ") + elif key == "bookmarks": + log["bookmarks"] = value.strip().split(", ") + elif key == "phase": + log["phase"] = value.strip() + elif key == "latesttag": + tag = value.strip() + if tag == "null": + log["latesttag"] = [] + elif ":" in tag: + log["latesttag"] = [ + t.strip() for t in tag.split(":") if t.strip()] + else: + log["latesttag"] = [tag] + else: + if initialText: + continue + if value.strip(): + log["message"].append(value.strip()) + else: + if len(log) > 1: + self.__generateLogItem( + log["author"], log["date"], + log["message"], log["revision"], changedPaths, + log["parents"], log["branches"], log["tags"], + log["phase"], log["bookmarks"], log["latesttag"], + canPush=canPush) + dt = QDate.fromString(log["date"], Qt.DateFormat.ISODate) + if ( + not self.__maxDate.isValid() and + not self.__minDate.isValid() + ): + self.__maxDate = dt + self.__minDate = dt + else: + if self.__maxDate < dt: + self.__maxDate = dt + if self.__minDate > dt: + self.__minDate = dt + noEntries += 1 + log = {"message": [], "bookmarks": None, "phase": ""} + changedPaths = [] + fileCopies = {} + + self.__resizeColumnsLog() + + if self.__started and not self.__finishCallbacks: + # we are really done + if self.__selectedRevisions: + foundItems = self.logTree.findItems( + self.__selectedRevisions[0], Qt.MatchFlag.MatchExactly, + self.RevisionColumn) + if foundItems: + self.logTree.setCurrentItem(foundItems[0]) + else: + self.logTree.setCurrentItem( + self.logTree.topLevelItem(0)) + elif self.__projectWorkingDirParents: + for rev in self.__projectWorkingDirParents: + # rev string format must match with the format of the + # __generateLogItem() method + items = self.logTree.findItems( + "{0:>7}:".format(rev), + Qt.MatchFlag.MatchStartsWith, + self.RevisionColumn) + if items: + self.logTree.setCurrentItem(items[0]) + break + else: + self.logTree.setCurrentItem( + self.logTree.topLevelItem(0)) + else: + self.logTree.setCurrentItem(self.logTree.topLevelItem(0)) + self.__started = False + + if self.commandMode in ("incoming", "outgoing"): + self.commandMode = "log" # switch to log mode + if self.__lastRev > 0: + self.nextButton.setEnabled(True) + self.limitSpinBox.setEnabled(True) + else: + if noEntries < self.limitSpinBox.value() and not self.cancelled: + self.nextButton.setEnabled(False) + self.limitSpinBox.setEnabled(False) + + # update the log filters + self.__filterLogsEnabled = False + self.fromDate.setMinimumDate(self.__minDate) + self.fromDate.setMaximumDate(self.__maxDate) + self.fromDate.setDate(self.__minDate) + self.toDate.setMinimumDate(self.__minDate) + self.toDate.setMaximumDate(self.__maxDate) + self.toDate.setDate(self.__maxDate) + + branchFilter = self.branchCombo.currentText() + if not branchFilter: + branchFilter = self.__allBranchesFilter + self.branchCombo.clear() + self.branchCombo.addItems( + [self.__allBranchesFilter] + sorted(self.__branchColors.keys())) + self.branchCombo.setCurrentIndex( + self.branchCombo.findText(branchFilter)) + + self.__filterLogsEnabled = True + if self.__actionMode() == "filter": + self.__filterLogs() + self.__updateToolMenuActions() + + # restore selected item + if self.__selectedRevisions and not self.__finishCallbacks: + # we are really done + for revision in self.__selectedRevisions: + items = self.logTree.findItems( + revision, Qt.MatchFlag.MatchExactly, self.RevisionColumn) + if items: + items[0].setSelected(True) + self.__selectedRevisions = [] + + def __showError(self, out): + """ + Private slot to show some error. + + @param out error to be shown (string) + """ + self.errorGroup.show() + self.errors.insertPlainText(out) + self.errors.ensureCursorVisible() + + def on_buttonBox_clicked(self, button): + """ + Private slot called by a button of the button box clicked. + + @param button button that was clicked (QAbstractButton) + """ + if button == self.buttonBox.button( + QDialogButtonBox.StandardButton.Close + ): + self.close() + elif button == self.buttonBox.button( + QDialogButtonBox.StandardButton.Cancel + ): + self.cancelled = True + self.__hgClient.cancel() + elif button == self.refreshButton: + self.on_refreshButton_clicked() + + def __updateSbsSelectLabel(self): + """ + Private slot to update the enabled status of the diff buttons. + """ + self.sbsSelectLabel.clear() + if self.__isFile: + selectedItems = self.logTree.selectedItems() + if len(selectedItems) == 1: + currentItem = selectedItems[0] + rev2 = ( + currentItem.text(self.RevisionColumn).split(":", 1)[0] + .strip() + ) + parents = currentItem.data(0, self.__parentsRole) + if parents: + parentLinks = [] + for index in range(len(parents)): + parentLinks.append( + '<a href="sbsdiff:{0}_{1}"> {2} </a>' + .format(parents[index], rev2, index + 1)) + self.sbsSelectLabel.setText( + self.tr('Side-by-Side Diff to Parent {0}').format( + " ".join(parentLinks))) + elif len(selectedItems) == 2: + rev1 = int(selectedItems[0].text(self.RevisionColumn) + .split(":", 1)[0]) + rev2 = int(selectedItems[1].text(self.RevisionColumn) + .split(":", 1)[0]) + if rev1 > rev2: + # Swap the entries, so that rev1 < rev2 + rev1, rev2 = rev2, rev1 + self.sbsSelectLabel.setText(self.tr( + '<a href="sbsdiff:{0}_{1}">Side-by-Side Compare</a>') + .format(rev1, rev2)) + + def __updateToolMenuActions(self): + """ + Private slot to update the status of the tool menu actions and + the tool menu button. + """ + if self.initialCommandMode in ("log", "full_log") and self.projectMode: + # do the phase action + # step 1: count entries with changeable phases + secret = 0 + draft = 0 + public = 0 + for itm in [item for item in self.logTree.selectedItems() + if not item.data(0, self.__incomingRole)]: + # count phase for local items only + phase = itm.text(self.PhaseColumn) + if phase == self.phases["draft"]: + draft += 1 + elif phase == self.phases["secret"]: + secret += 1 + else: + public += 1 + + # step 2: set the status of the phase action + if ( + public == 0 and + ((secret > 0 and draft == 0) or + (secret == 0 and draft > 0)) + ): + self.__phaseAct.setEnabled(True) + else: + self.__phaseAct.setEnabled(False) + + # do the graft action + # step 1: count selected entries not belonging to the + # current branch + otherBranches = 0 + for itm in [item for item in self.logTree.selectedItems() + if not item.data(0, self.__incomingRole)]: + # for local items only + branch = itm.text(self.BranchColumn) + if branch != self.__projectBranch: + otherBranches += 1 + + # step 2: set the status of the graft action + self.__graftAct.setEnabled(otherBranches > 0) + + selectedItemsCount = len([ + itm for itm in self.logTree.selectedItems() + if not itm.data(0, self.__incomingRole) + ]) + selectedIncomingItemsCount = len([ + itm for itm in self.logTree.selectedItems() + if itm.data(0, self.__incomingRole) + ]) + + self.__mergeAct.setEnabled(selectedItemsCount == 1) + self.__tagAct.setEnabled(selectedItemsCount == 1) + self.__switchAct.setEnabled(selectedItemsCount == 1) + self.__bookmarkAct.setEnabled(selectedItemsCount == 1) + self.__bookmarkMoveAct.setEnabled(selectedItemsCount == 1) + + if selectedIncomingItemsCount > 0: + self.__pullAct.setText(self.tr("Pull Selected Changes")) + else: + self.__pullAct.setText(self.tr("Pull Changes")) + if self.vcs.canPull(): + self.__pullAct.setEnabled(True) + self.__lfPullAct.setEnabled( + self.vcs.isExtensionActive("largefiles") and + selectedItemsCount > 0) + else: + self.__pullAct.setEnabled(False) + self.__lfPullAct.setEnabled(False) + + if self.vcs.canPush(): + self.__pushAct.setEnabled( + selectedItemsCount == 1 and + not self.logTree.selectedItems()[0].data( + 0, self.__incomingRole) and + self.logTree.selectedItems()[0].text(self.PhaseColumn) == + self.phases["draft"]) + self.__pushAllAct.setEnabled(True) + else: + self.__pushAct.setEnabled(False) + self.__pushAllAct.setEnabled(False) + + self.__stripAct.setEnabled( + self.vcs.isExtensionActive("strip") and + selectedItemsCount == 1) + + # count incoming items for 'full_log' + if self.initialCommandMode == "full_log": + # incoming items are at the top + incomingCount = 0 + for row in range(self.logTree.topLevelItemCount()): + if self.logTree.topLevelItem(row).data( + 0, self.__incomingRole): + incomingCount += 1 + else: + break + localCount = self.logTree.topLevelItemCount() - incomingCount + else: + localCount = self.logTree.topLevelItemCount() + self.__bundleAct.setEnabled(localCount > 0) + self.__unbundleAct.setEnabled(False) + + self.__gpgSignAct.setEnabled( + self.vcs.isExtensionActive("gpg") and + selectedItemsCount > 0) + self.__gpgVerifyAct.setEnabled( + self.vcs.isExtensionActive("gpg") and + selectedItemsCount == 1) + + if self.vcs.isExtensionActive("closehead"): + revs = [itm.text(self.RevisionColumn).strip().split(":", 1)[0] + for itm in self.logTree.selectedItems() + if not itm.data(0, self.__incomingRole)] + revs = [rev for rev in revs if rev in self.__headRevisions] + self.__closeHeadsAct.setEnabled(len(revs) > 0) + else: + self.__closeHeadsAct.setEnabled(False) + self.actionsButton.setEnabled(True) + + elif self.initialCommandMode == "incoming" and self.projectMode: + for act in [self.__phaseAct, self.__graftAct, self.__mergeAct, + self.__tagAct, self.__closeHeadsAct, self.__switchAct, + self.__bookmarkAct, self.__bookmarkMoveAct, + self.__pushAct, self.__pushAllAct, self.__stripAct, + self.__bundleAct, self.__gpgSignAct, + self.__gpgVerifyAct]: + act.setEnabled(False) + + self.__pullAct.setText(self.tr("Pull Selected Changes")) + if self.vcs.canPull() and not bool(self.__bundle): + selectedIncomingItemsCount = len([ + itm for itm in self.logTree.selectedItems() + if itm.data(0, self.__incomingRole) + ]) + self.__pullAct.setEnabled(selectedIncomingItemsCount > 0) + self.__lfPullAct.setEnabled( + self.vcs.isExtensionActive("largefiles") and + selectedIncomingItemsCount > 0) + else: + self.__pullAct.setEnabled(False) + self.__lfPullAct.setEnabled(False) + + self.__unbundleAct.setEnabled(bool(self.__bundle)) + + self.actionsButton.setEnabled(True) + + elif self.initialCommandMode == "outgoing" and self.projectMode: + for act in [self.__phaseAct, self.__graftAct, self.__mergeAct, + self.__tagAct, self.__closeHeadsAct, self.__switchAct, + self.__bookmarkAct, self.__bookmarkMoveAct, + self.__pullAct, self.__lfPullAct, + self.__stripAct, self.__gpgSignAct, + self.__gpgVerifyAct, self.__unbundleAct]: + act.setEnabled(False) + + selectedItemsCount = len(self.logTree.selectedItems()) + if self.vcs.canPush(): + self.__pushAct.setEnabled( + selectedItemsCount == 1 and + self.logTree.selectedItems()[0].text(self.PhaseColumn) == + self.phases["draft"]) + self.__pushAllAct.setEnabled(True) + else: + self.__pushAct.setEnabled(False) + self.__pushAllAct.setEnabled(False) + + self.__bundleAct.setEnabled(selectedItemsCount > 0) + + else: + self.actionsButton.setEnabled(False) + + def __updateDetailsAndFiles(self): + """ + Private slot to update the details and file changes panes. + """ + self.detailsEdit.clear() + self.filesTree.clear() + self.__diffUpdatesFiles = False + + selectedItems = self.logTree.selectedItems() + if len(selectedItems) == 1: + self.detailsEdit.setHtml( + self.__generateDetailsTableText(selectedItems[0])) + self.__updateFilesTree(self.filesTree, selectedItems[0]) + self.__resizeColumnsFiles() + self.__resortFiles() + elif len(selectedItems) == 2: + self.__diffUpdatesFiles = True + index1 = self.logTree.indexOfTopLevelItem(selectedItems[0]) + index2 = self.logTree.indexOfTopLevelItem(selectedItems[1]) + if index1 > index2: + # Swap the entries + selectedItems[0], selectedItems[1] = ( + selectedItems[1], selectedItems[0] + ) + html = "{0}<hr/>{1}".format( + self.__generateDetailsTableText(selectedItems[0]), + self.__generateDetailsTableText(selectedItems[1]), + ) + self.detailsEdit.setHtml(html) + # self.filesTree is updated by the diff + + def __generateDetailsTableText(self, itm): + """ + Private method to generate an HTML table with the details of the given + changeset. + + @param itm reference to the item the table should be based on + @type QTreeWidgetItem + @return HTML table containing details + @rtype str + """ + if itm is not None: + if itm.text(self.TagsColumn): + tagsStr = self.__tagsTemplate.format(itm.text(self.TagsColumn)) + else: + tagsStr = "" + + if itm.text(self.BookmarksColumn): + bookmarksStr = self.__bookmarksTemplate.format( + itm.text(self.BookmarksColumn)) + else: + bookmarksStr = "" + + if self.projectMode and itm.data(0, self.__latestTagRole): + latestTagLinks = [] + for tag in itm.data(0, self.__latestTagRole): + latestTagLinks.append('<a href="rev:{0}">{1}</a>'.format( + self.__getRevisionOfTag(tag)[0], tag)) + latestTagStr = self.__latestTagTemplate.format( + ", ".join(latestTagLinks)) + else: + latestTagStr = "" + + rev = int(itm.text(self.RevisionColumn).split(":", 1)[0]) + + if itm.data(0, self.__parentsRole): + parentLinks = [] + for parent in [str(x) for x in + itm.data(0, self.__parentsRole)]: + parentLinks.append( + '<a href="rev:{0}">{0}</a>'.format(parent)) + parentsStr = self.__parentsTemplate.format( + ", ".join(parentLinks)) + else: + parentsStr = "" + + if self.__childrenInfo[rev]: + childLinks = [] + for child in [str(x) for x in self.__childrenInfo[rev]]: + childLinks.append( + '<a href="rev:{0}">{0}</a>'.format(child)) + childrenStr = self.__childrenTemplate.format( + ", ".join(childLinks)) + else: + childrenStr = "" + + messageStr = "<br />\n".join([ + Utilities.html_encode(line.strip()) + for line in itm.data(0, self.__messageRole) + ]) + + html = self.__detailsTemplate.format( + itm.text(self.RevisionColumn), + itm.text(self.DateColumn), + itm.text(self.AuthorColumn), + itm.text(self.BranchColumn).replace( + self.ClosedIndicator, ""), + parentsStr + childrenStr + tagsStr + latestTagStr + + bookmarksStr, + messageStr, + ) + else: + html = "" + + return html + + def __updateFilesTree(self, parent, itm): + """ + Private method to update the files tree with changes of the given item. + + @param parent parent for the items to be added + @type QTreeWidget or QTreeWidgetItem + @param itm reference to the item the update should be based on + @type QTreeWidgetItem + """ + if itm is not None: + changes = itm.data(0, self.__changesRole) + if len(changes) > 0: + for change in changes: + QTreeWidgetItem(parent, [ + self.flags[change["action"]], + change["path"].strip(), + change["copyfrom"].strip(), + ]) + + @pyqtSlot(QTreeWidgetItem, QTreeWidgetItem) + def on_logTree_currentItemChanged(self, current, previous): + """ + Private slot called, when the current item of the log tree changes. + + @param current reference to the new current item (QTreeWidgetItem) + @param previous reference to the old current item (QTreeWidgetItem) + """ + self.__updateToolMenuActions() + + # Highlight the current entry using a bold font + for col in range(self.logTree.columnCount()): + current and current.setFont(col, self.__logTreeBoldFont) + previous and previous.setFont(col, self.__logTreeNormalFont) + + # set the state of the up and down buttons + self.upButton.setEnabled( + current is not None and + self.logTree.indexOfTopLevelItem(current) > 0) + self.downButton.setEnabled( + current is not None and + int(current.text(self.RevisionColumn).split(":")[0]) > 0 and + (self.logTree.indexOfTopLevelItem(current) < + self.logTree.topLevelItemCount() - 1 or + self.nextButton.isEnabled())) + + @pyqtSlot() + def on_logTree_itemSelectionChanged(self): + """ + Private slot called, when the selection has changed. + """ + self.__updateDetailsAndFiles() + self.__updateSbsSelectLabel() + self.__updateToolMenuActions() + self.__generateDiffs() + + @pyqtSlot() + def on_upButton_clicked(self): + """ + Private slot to move the current item up one entry. + """ + itm = self.logTree.itemAbove(self.logTree.currentItem()) + if itm: + self.logTree.setCurrentItem(itm) + + @pyqtSlot() + def on_downButton_clicked(self): + """ + Private slot to move the current item down one entry. + """ + itm = self.logTree.itemBelow(self.logTree.currentItem()) + if itm: + self.logTree.setCurrentItem(itm) + else: + # load the next bunch and try again + if self.nextButton.isEnabled(): + self.__addFinishCallback(self.on_downButton_clicked) + self.on_nextButton_clicked() + + @pyqtSlot() + def on_nextButton_clicked(self): + """ + Private slot to handle the Next button. + """ + if self.nextButton.isEnabled(): + if self.__lastRev > 0: + self.__getLogEntries(startRev=self.__lastRev - 1) + else: + self.__getLogEntries() + + @pyqtSlot(QDate) + def on_fromDate_dateChanged(self, date): + """ + Private slot called, when the from date changes. + + @param date new date (QDate) + """ + if self.__actionMode() == "filter": + self.__filterLogs() + + @pyqtSlot(QDate) + def on_toDate_dateChanged(self, date): + """ + Private slot called, when the from date changes. + + @param date new date (QDate) + """ + if self.__actionMode() == "filter": + self.__filterLogs() + + @pyqtSlot(int) + def on_branchCombo_activated(self, index): + """ + Private slot called, when a new branch is selected. + + @param index index of the selected entry + @type int + """ + if self.__actionMode() == "filter": + self.__filterLogs() + + @pyqtSlot(int) + def on_fieldCombo_activated(self, index): + """ + Private slot called, when a new filter field is selected. + + @param index index of the selected entry + @type int + """ + if self.__actionMode() == "filter": + self.__filterLogs() + + @pyqtSlot(str) + def on_rxEdit_textChanged(self, txt): + """ + Private slot called, when a filter expression is entered. + + @param txt filter expression (string) + """ + if self.__actionMode() == "filter": + self.__filterLogs() + elif self.__actionMode() == "find": + self.__findItem(self.__findBackwards, interactive=True) + + @pyqtSlot() + def on_rxEdit_returnPressed(self): + """ + Private slot handling a press of the Return key in the rxEdit input. + """ + if self.__actionMode() == "find": + self.__findItem(self.__findBackwards, interactive=True) + + def __filterLogs(self): + """ + Private method to filter the log entries. + """ + if self.__filterLogsEnabled: + from_ = self.fromDate.date().toString("yyyy-MM-dd") + to_ = self.toDate.date().addDays(1).toString("yyyy-MM-dd") + branch = self.branchCombo.currentText() + closedBranch = branch + '--' + fieldIndex, searchRx, indexIsRole = self.__prepareFieldSearch() + + visibleItemCount = self.logTree.topLevelItemCount() + currentItem = self.logTree.currentItem() + for topIndex in range(self.logTree.topLevelItemCount()): + topItem = self.logTree.topLevelItem(topIndex) + if indexIsRole: + if fieldIndex == self.__changesRole: + changes = topItem.data(0, self.__changesRole) + txt = "\n".join( + [c["path"] for c in changes] + + [c["copyfrom"] for c in changes] + ) + else: + # Find based on complete message text + txt = "\n".join(topItem.data(0, self.__messageRole)) + else: + txt = topItem.text(fieldIndex) + if ( + topItem.text(self.DateColumn) <= to_ and + topItem.text(self.DateColumn) >= from_ and + (branch == self.__allBranchesFilter or + topItem.text(self.BranchColumn) in + [branch, closedBranch]) and + searchRx.search(txt) is not None + ): + topItem.setHidden(False) + if topItem is currentItem: + self.on_logTree_currentItemChanged(topItem, None) + else: + topItem.setHidden(True) + if topItem is currentItem: + self.filesTree.clear() + visibleItemCount -= 1 + self.logTree.header().setSectionHidden( + self.IconColumn, + visibleItemCount != self.logTree.topLevelItemCount()) + + def __prepareFieldSearch(self): + """ + Private slot to prepare the filed search data. + + @return tuple of field index, search expression and flag indicating + that the field index is a data role (integer, string, boolean) + """ + indexIsRole = False + txt = self.fieldCombo.itemData(self.fieldCombo.currentIndex()) + if txt == "author": + fieldIndex = self.AuthorColumn + searchRx = re.compile(self.rxEdit.text(), re.IGNORECASE) + elif txt == "revision": + fieldIndex = self.RevisionColumn + txt = self.rxEdit.text() + if txt.startswith("^"): + searchRx = re.compile(r"^\s*{0}".format(txt[1:]), + re.IGNORECASE) + else: + searchRx = re.compile(txt, re.IGNORECASE) + elif txt == "file": + fieldIndex = self.__changesRole + searchRx = re.compile(self.rxEdit.text(), re.IGNORECASE) + indexIsRole = True + elif txt == "phase": + fieldIndex = self.PhaseColumn + searchRx = re.compile(self.rxEdit.text(), re.IGNORECASE) + else: + fieldIndex = self.__messageRole + searchRx = re.compile(self.rxEdit.text(), re.IGNORECASE) + indexIsRole = True + + return fieldIndex, searchRx, indexIsRole + + @pyqtSlot(bool) + def on_stopCheckBox_clicked(self, checked): + """ + Private slot called, when the stop on copy/move checkbox is clicked. + + @param checked flag indicating the state of the check box (boolean) + """ + self.vcs.getPlugin().setPreferences("StopLogOnCopy", + self.stopCheckBox.isChecked()) + self.nextButton.setEnabled(True) + self.limitSpinBox.setEnabled(True) + + @pyqtSlot() + def on_refreshButton_clicked(self, addNext=False): + """ + Private slot to refresh the log. + + @param addNext flag indicating to get a second batch of log entries as + well + @type bool + """ + self.buttonBox.button( + QDialogButtonBox.StandardButton.Close).setEnabled(False) + self.buttonBox.button( + QDialogButtonBox.StandardButton.Cancel).setEnabled(True) + self.buttonBox.button( + QDialogButtonBox.StandardButton.Cancel).setDefault(True) + + self.refreshButton.setEnabled(False) + + # save the selected items commit IDs + self.__selectedRevisions = [] + for item in self.logTree.selectedItems(): + self.__selectedRevisions.append(item.text(self.RevisionColumn)) + + if self.initialCommandMode in ("incoming", "outgoing"): + self.nextButton.setEnabled(False) + self.limitSpinBox.setEnabled(False) + if addNext: + self.__addFinishCallback(self.on_nextButton_clicked) + else: + self.nextButton.setEnabled(True) + self.limitSpinBox.setEnabled(True) + + if self.initialCommandMode == "full_log": + self.commandMode = "incoming" + self.__addFinishCallback(self.on_nextButton_clicked) + else: + self.commandMode = self.initialCommandMode + self.start(self.__filename, bundle=self.__bundle, isFile=self.__isFile, + noEntries=self.logTree.topLevelItemCount()) + + @pyqtSlot() + def __phaseActTriggered(self): + """ + Private slot to handle the Change Phase action. + """ + itm = self.logTree.selectedItems()[0] + if not itm.data(0, self.__incomingRole): + currentPhase = itm.text(self.PhaseColumn) + revs = [] + for itm in self.logTree.selectedItems(): + if itm.text(self.PhaseColumn) == currentPhase: + revs.append( + itm.text(self.RevisionColumn).split(":")[0].strip()) + + if not revs: + self.__phaseAct.setEnabled(False) + return + + if currentPhase == self.phases["draft"]: + newPhase = self.phases["secret"] + data = (revs, "s", True) + else: + newPhase = self.phases["draft"] + data = (revs, "d", False) + res = self.vcs.hgPhase(data) + if res: + for itm in self.logTree.selectedItems(): + itm.setText(self.PhaseColumn, newPhase) + + @pyqtSlot() + def __graftActTriggered(self): + """ + Private slot to handle the Copy Changesets action. + """ + revs = [] + + for itm in [item for item in self.logTree.selectedItems() + if not item.data(0, self.__incomingRole)]: + branch = itm.text(self.BranchColumn) + if branch != self.__projectBranch: + revs.append( + itm.text(self.RevisionColumn).strip().split(":", 1)[0]) + + if revs: + shouldReopen = self.vcs.hgGraft(revs) + if shouldReopen: + res = E5MessageBox.yesNo( + None, + self.tr("Copy Changesets"), + self.tr( + """The project should be reread. Do this now?"""), + yesDefault=True) + if res: + e5App().getObject("Project").reopenProject() + return + + self.on_refreshButton_clicked() + + @pyqtSlot() + def __tagActTriggered(self): + """ + Private slot to tag the selected revision. + """ + if len([itm for itm in self.logTree.selectedItems() + if not itm.data(0, self.__incomingRole)]) == 1: + itm = self.logTree.selectedItems()[0] + rev = itm.text(self.RevisionColumn).strip().split(":", 1)[0] + tag = itm.text(self.TagsColumn).strip().split(", ", 1)[0] + res = self.vcs.vcsTag(revision=rev, tagName=tag) + if res: + self.on_refreshButton_clicked() + + @pyqtSlot() + def __closeHeadsActTriggered(self): + """ + Private slot to close the selected head revisions. + """ + if self.vcs.isExtensionActive("closehead"): + revs = [itm.text(self.RevisionColumn).strip().split(":", 1)[0] + for itm in self.logTree.selectedItems() + if not itm.data(0, self.__incomingRole)] + revs = [rev for rev in revs if rev in self.__headRevisions] + if revs: + closeheadExtension = self.vcs.getExtensionObject("closehead") + if closeheadExtension is not None: + closeheadExtension.hgCloseheads(revisions=revs) + + self.on_refreshButton_clicked() + + @pyqtSlot() + def __switchActTriggered(self): + """ + Private slot to switch the working directory to the + selected revision. + """ + if len([itm for itm in self.logTree.selectedItems() + if not itm.data(0, self.__incomingRole)]) == 1: + itm = self.logTree.selectedItems()[0] + rev = itm.text(self.RevisionColumn).strip().split(":", 1)[0] + bookmarks = [bm.strip() for bm in + itm.text(self.BookmarksColumn).strip().split(",") + if bm.strip()] + if bookmarks: + bookmark, ok = QInputDialog.getItem( + self, + self.tr("Switch"), + self.tr("Select bookmark to switch to (leave empty to" + " use revision):"), + [""] + bookmarks, + 0, False) + if not ok: + return + if bookmark: + rev = bookmark + if rev: + shouldReopen = self.vcs.vcsUpdate(revision=rev) + if shouldReopen: + res = E5MessageBox.yesNo( + None, + self.tr("Switch"), + self.tr( + """The project should be reread. Do this now?"""), + yesDefault=True) + if res: + e5App().getObject("Project").reopenProject() + return + + self.on_refreshButton_clicked() + + @pyqtSlot() + def __bookmarkActTriggered(self): + """ + Private slot to bookmark the selected revision. + """ + if len([itm for itm in self.logTree.selectedItems() + if not itm.data(0, self.__incomingRole)]) == 1: + itm = self.logTree.selectedItems()[0] + rev, changeset = ( + itm.text(self.RevisionColumn).strip().split(":", 1) + ) + bookmark, ok = QInputDialog.getText( + self, + self.tr("Define Bookmark"), + self.tr('Enter bookmark name for changeset "{0}":').format( + changeset), + QLineEdit.EchoMode.Normal) + if ok and bool(bookmark): + self.vcs.hgBookmarkDefine( + revision="rev({0})".format(rev), + bookmark=bookmark) + self.on_refreshButton_clicked() + + @pyqtSlot() + def __bookmarkMoveActTriggered(self): + """ + Private slot to move a bookmark to the selected revision. + """ + if len([itm for itm in self.logTree.selectedItems() + if not itm.data(0, self.__incomingRole)]) == 1: + itm = self.logTree.selectedItems()[0] + rev, changeset = ( + itm.text(self.RevisionColumn).strip().split(":", 1) + ) + bookmarksList = self.vcs.hgGetBookmarksList() + bookmark, ok = QInputDialog.getItem( + self, + self.tr("Move Bookmark"), + self.tr('Select the bookmark to be moved to changeset' + ' "{0}":').format(changeset), + [""] + bookmarksList, + 0, False) + if ok and bool(bookmark): + self.vcs.hgBookmarkMove( + revision="rev({0})".format(rev), + bookmark=bookmark) + self.on_refreshButton_clicked() + + @pyqtSlot() + def __lfPullActTriggered(self): + """ + Private slot to pull large files of selected revisions. + """ + revs = [] + for itm in [item for item in self.logTree.selectedItems() + if not item.data(0, self.__incomingRole)]: + rev = itm.text(self.RevisionColumn).strip().split(":", 1)[0] + if rev: + revs.append(rev) + + if revs: + self.vcs.getExtensionObject("largefiles").hgLfPull(revisions=revs) + + @pyqtSlot() + def __pullActTriggered(self): + """ + Private slot to pull changes from a remote repository. + """ + shouldReopen = False + refresh = False + addNext = False + + if self.initialCommandMode in ("log", "full_log", "incoming"): + revs = [] + for itm in [item for item in self.logTree.selectedItems() + if item.data(0, self.__incomingRole)]: + rev = itm.text(self.RevisionColumn).split(":")[1].strip() + if rev: + revs.append(rev) + shouldReopen = self.vcs.hgPull(revisions=revs) + refresh = True + if self.initialCommandMode == "incoming": + addNext = True + + if shouldReopen: + res = E5MessageBox.yesNo( + None, + self.tr("Pull Changes"), + self.tr( + """The project should be reread. Do this now?"""), + yesDefault=True) + if res: + e5App().getObject("Project").reopenProject() + return + + if refresh: + self.on_refreshButton_clicked(addNext=addNext) + + @pyqtSlot() + def __pushActTriggered(self): + """ + Private slot to push changes to a remote repository up to a selected + changeset. + """ + itm = self.logTree.selectedItems()[0] + if not itm.data(0, self.__incomingRole): + rev = itm.text(self.RevisionColumn).strip().split(":", 1)[0] + if rev: + self.vcs.hgPush(rev=rev) + self.on_refreshButton_clicked( + addNext=self.initialCommandMode == "outgoing") + + @pyqtSlot() + def __pushAllActTriggered(self): + """ + Private slot to push all changes to a remote repository. + """ + self.vcs.hgPush() + self.on_refreshButton_clicked() + + @pyqtSlot() + def __stripActTriggered(self): + """ + Private slot to strip changesets from the repository. + """ + itm = self.logTree.selectedItems()[0] + if not itm.data(0, self.__incomingRole): + rev = itm.text(self.RevisionColumn).strip().split(":", 1)[1] + shouldReopen = self.vcs.getExtensionObject("strip").hgStrip( + rev=rev) + if shouldReopen: + res = E5MessageBox.yesNo( + None, + self.tr("Strip Changesets"), + self.tr( + """The project should be reread. Do this now?"""), + yesDefault=True) + if res: + e5App().getObject("Project").reopenProject() + return + + self.on_refreshButton_clicked() + + @pyqtSlot() + def __mergeActTriggered(self): + """ + Private slot to merge the working directory with the selected + changeset. + """ + itm = self.logTree.selectedItems()[0] + if not itm.data(0, self.__incomingRole): + rev = "rev({0})".format( + itm.text(self.RevisionColumn).strip().split(":", 1)[0]) + self.vcs.vcsMerge("", rev=rev) + + @pyqtSlot() + def __bundleActTriggered(self): + """ + Private slot to create a changegroup file. + """ + if self.initialCommandMode in ("log", "full_log"): + selectedItems = [itm for itm in self.logTree.selectedItems() + if not itm.data(0, self.__incomingRole)] + if len(selectedItems) == 0: + # all revisions of the local repository will be bundled + bundleData = { + "revs": [], + "base": "", + "all": True, + } + elif len(selectedItems) == 1: + # the selected changeset is the base + rev = selectedItems[0].text(self.RevisionColumn).split( + ":", 1)[0].strip() + bundleData = { + "revs": [], + "base": rev, + "all": False, + } + else: + # lowest revision is the base, others will be bundled + revs = [] + for itm in selectedItems: + rev = itm.text(self.RevisionColumn).split(":", 1)[0] + with contextlib.suppress(ValueError): + revs.append(int(rev)) + baseRev = min(revs) + while baseRev in revs: + revs.remove(baseRev) + + bundleData = { + "revs": [str(rev) for rev in revs], + "base": str(baseRev), + "all": False, + } + elif self.initialCommandMode == "outgoing": + selectedItems = self.logTree.selectedItems() + if len(selectedItems) > 0: + revs = [] + for itm in selectedItems: + rev = itm.text(self.RevisionColumn).split(":", 1)[0] + revs.append(rev.strip()) + + bundleData = { + "revs": revs, + "base": "", + "all": False, + } + + self.vcs.hgBundle(bundleData=bundleData) + + @pyqtSlot() + def __unbundleActTriggered(self): + """ + Private slot to apply the currently previewed bundle file. + """ + if self.initialCommandMode == "incoming" and bool(self.__bundle): + shouldReopen = self.vcs.hgUnbundle(files=[self.__bundle]) + if shouldReopen: + res = E5MessageBox.yesNo( + None, + self.tr("Apply Changegroup"), + self.tr("""The project should be reread. Do this now?"""), + yesDefault=True) + if res: + e5App().getObject("Project").reopenProject() + return + + self.vcs.vcsLogBrowser() + self.close() + + @pyqtSlot() + def __gpgSignActTriggered(self): + """ + Private slot to sign the selected revisions. + """ + revs = [] + for itm in [item for item in self.logTree.selectedItems() + if not item.data(0, self.__incomingRole)]: + rev = itm.text(self.RevisionColumn).split(":", 1)[0].strip() + if rev: + revs.append(rev) + + if revs: + self.vcs.getExtensionObject("gpg").hgGpgSign(revisions=revs) + + @pyqtSlot() + def __gpgVerifyActTriggered(self): + """ + Private slot to verify the signatures of a selected revisions. + """ + itm = self.logTree.selectedItems()[0] + if not itm.data(0, self.__incomingRole): + rev = itm.text(self.RevisionColumn).split(":", 1)[0].strip() + if rev: + self.vcs.getExtensionObject("gpg").hgGpgVerifySignatures( + rev=rev) + + def __selectAllActTriggered(self, select=True): + """ + Private method to select or unselect all log entries. + + @param select flag indicating to select all entries + @type bool + """ + blocked = self.logTree.blockSignals(True) + for row in range(self.logTree.topLevelItemCount()): + self.logTree.topLevelItem(row).setSelected(select) + self.logTree.blockSignals(blocked) + self.on_logTree_itemSelectionChanged() + + def __actionMode(self): + """ + Private method to get the selected action mode. + + @return selected action mode (string, one of filter or find) + """ + return self.modeComboBox.itemData( + self.modeComboBox.currentIndex()) + + @pyqtSlot(int) + def on_modeComboBox_currentIndexChanged(self, index): + """ + Private slot to react on mode changes. + + @param index index of the selected entry (integer) + """ + mode = self.modeComboBox.itemData(index) + findMode = mode == "find" + filterMode = mode == "filter" + + self.fromDate.setEnabled(filterMode) + self.toDate.setEnabled(filterMode) + self.branchCombo.setEnabled(filterMode) + self.findPrevButton.setVisible(findMode) + self.findNextButton.setVisible(findMode) + + if findMode: + for topIndex in range(self.logTree.topLevelItemCount()): + self.logTree.topLevelItem(topIndex).setHidden(False) + self.logTree.header().setSectionHidden(self.IconColumn, False) + elif filterMode: + self.__filterLogs() + + @pyqtSlot() + def on_findPrevButton_clicked(self): + """ + Private slot to find the previous item matching the entered criteria. + """ + self.__findItem(True) + + @pyqtSlot() + def on_findNextButton_clicked(self): + """ + Private slot to find the next item matching the entered criteria. + """ + self.__findItem(False) + + def __findItem(self, backwards=False, interactive=False): + """ + Private slot to find an item matching the entered criteria. + + @param backwards flag indicating to search backwards (boolean) + @param interactive flag indicating an interactive search (boolean) + """ + self.__findBackwards = backwards + + fieldIndex, searchRx, indexIsRole = self.__prepareFieldSearch() + currentIndex = self.logTree.indexOfTopLevelItem( + self.logTree.currentItem()) + if backwards: + if interactive: + indexes = range(currentIndex, -1, -1) + else: + indexes = range(currentIndex - 1, -1, -1) + else: + if interactive: + indexes = range(currentIndex, self.logTree.topLevelItemCount()) + else: + indexes = range(currentIndex + 1, + self.logTree.topLevelItemCount()) + + for index in indexes: + topItem = self.logTree.topLevelItem(index) + if indexIsRole: + if fieldIndex == self.__changesRole: + changes = topItem.data(0, self.__changesRole) + txt = "\n".join( + [c["path"] for c in changes] + + [c["copyfrom"] for c in changes] + ) + else: + # Find based on complete message text + txt = "\n".join(topItem.data(0, self.__messageRole)) + else: + txt = topItem.text(fieldIndex) + if searchRx.search(txt) is not None: + self.logTree.setCurrentItem(self.logTree.topLevelItem(index)) + break + else: + E5MessageBox.information( + self, + self.tr("Find Commit"), + self.tr("""'{0}' was not found.""").format(self.rxEdit.text())) + + def __revisionClicked(self, url): + """ + Private slot to handle the anchorClicked signal of the changeset + details pane. + + @param url URL that was clicked + @type QUrl + """ + if url.scheme() == "rev": + # a parent or child revision was clicked, show the respective item + rev = url.path() + searchStr = "{0:>7}:".format(rev) + # format must be in sync with item generation format + items = self.logTree.findItems( + searchStr, Qt.MatchFlag.MatchStartsWith, self.RevisionColumn) + if items: + itm = items[0] + if itm.isHidden(): + itm.setHidden(False) + self.logTree.setCurrentItem(itm) + else: + # load the next batch and try again + if self.nextButton.isEnabled(): + self.__addFinishCallback( + lambda: self.__revisionClicked(url)) + self.on_nextButton_clicked() + + ########################################################################### + ## Diff handling methods below + ########################################################################### + + def __generateDiffs(self, parent=1): + """ + Private slot to generate diff outputs for the selected item. + + @param parent number of parent to diff against + @type int + """ + self.diffEdit.setPlainText(self.tr("Generating differences ...")) + self.diffLabel.setText(self.tr("Differences")) + self.diffSelectLabel.clear() + self.diffHighlighter.regenerateRules() + + selectedItems = self.logTree.selectedItems() + if len(selectedItems) == 1: + currentItem = selectedItems[0] + rev2 = currentItem.text(self.RevisionColumn).split(":", 1)[0] + parents = currentItem.data(0, self.__parentsRole) + if len(parents) >= parent: + self.diffLabel.setText( + self.tr("Differences to Parent {0}").format(parent)) + rev1 = parents[parent - 1] + + self.__diffGenerator.start(self.__filename, [rev1, rev2], + self.__bundle) + + if len(parents) > 1: + if parent == 1: + par1 = " 1 " + else: + par1 = '<a href="diff:1"> 1 </a>' + if parent == 2: + par2 = " 2 " + else: + par2 = '<a href="diff:2"> 2 </a>' + self.diffSelectLabel.setText( + self.tr('Diff to Parent {0}{1}').format(par1, par2)) + elif len(selectedItems) == 2: + rev2 = int(selectedItems[0].text( + self.RevisionColumn).split(":")[0]) + rev1 = int(selectedItems[1].text( + self.RevisionColumn).split(":")[0]) + + self.__diffGenerator.start(self.__filename, + [min(rev1, rev2), max(rev1, rev2)], + self.__bundle) + else: + self.diffEdit.clear() + + def __generatorFinished(self): + """ + Private slot connected to the finished signal of the diff generator. + """ + diff, errors, fileSeparators = self.__diffGenerator.getResult() + + if diff: + self.diffEdit.setPlainText("".join(diff)) + elif errors: + self.diffEdit.setPlainText("".join(errors)) + else: + self.diffEdit.setPlainText(self.tr('There is no difference.')) + + self.saveLabel.setVisible(bool(diff)) + + if self.__diffUpdatesFiles: + for oldFileName, newFileName, lineNumber in fileSeparators: + if oldFileName == newFileName: + fileName = oldFileName + elif oldFileName == "__NULL__": + fileName = newFileName + else: + fileName = oldFileName + item = QTreeWidgetItem(self.filesTree, ["", fileName, ""]) + item.setData(0, self.__diffFileLineRole, lineNumber) + self.__resizeColumnsFiles() + self.__resortFiles() + else: + for oldFileName, newFileName, lineNumber in fileSeparators: + for fileName in (oldFileName, newFileName): + if fileName != "__NULL__": + items = self.filesTree.findItems( + fileName, Qt.MatchFlag.MatchExactly, 1) + for item in items: + item.setData(0, self.__diffFileLineRole, + lineNumber) + + tc = self.diffEdit.textCursor() + tc.movePosition(QTextCursor.MoveOperation.Start) + self.diffEdit.setTextCursor(tc) + self.diffEdit.ensureCursorVisible() + + @pyqtSlot(QTreeWidgetItem, QTreeWidgetItem) + def on_filesTree_currentItemChanged(self, current, previous): + """ + Private slot called, when the current item of the files tree changes. + + @param current reference to the new current item (QTreeWidgetItem) + @param previous reference to the old current item (QTreeWidgetItem) + """ + if current: + para = current.data(0, self.__diffFileLineRole) + if para is not None: + if para == 0: + tc = self.diffEdit.textCursor() + tc.movePosition(QTextCursor.MoveOperation.Start) + self.diffEdit.setTextCursor(tc) + self.diffEdit.ensureCursorVisible() + elif para == -1: + tc = self.diffEdit.textCursor() + tc.movePosition(QTextCursor.MoveOperation.End) + self.diffEdit.setTextCursor(tc) + self.diffEdit.ensureCursorVisible() + else: + # step 1: move cursor to end + tc = self.diffEdit.textCursor() + tc.movePosition(QTextCursor.MoveOperation.End) + self.diffEdit.setTextCursor(tc) + self.diffEdit.ensureCursorVisible() + + # step 2: move cursor to desired line + tc = self.diffEdit.textCursor() + delta = tc.blockNumber() - para + tc.movePosition(QTextCursor.MoveOperation.PreviousBlock, + QTextCursor.MoveMode.MoveAnchor, delta) + self.diffEdit.setTextCursor(tc) + self.diffEdit.ensureCursorVisible() + + @pyqtSlot(str) + def on_diffSelectLabel_linkActivated(self, link): + """ + Private slot to handle the selection of a diff target. + + @param link activated link + @type str + """ + if ":" in link: + scheme, parent = link.split(":", 1) + if scheme == "diff": + with contextlib.suppress(ValueError): + parent = int(parent) + self.__generateDiffs(parent) + + @pyqtSlot(str) + def on_saveLabel_linkActivated(self, link): + """ + Private slot to handle the selection of the save link. + + @param link activated link + @type str + """ + if ":" not in link: + return + + scheme, rest = link.split(":", 1) + if scheme != "save" or rest != "me": + return + + if self.projectMode: + fname = self.vcs.splitPath(self.__filename)[0] + fname += "/{0}.diff".format(os.path.split(fname)[-1]) + else: + dname, fname = self.vcs.splitPath(self.__filename) + if fname != '.': + fname = "{0}.diff".format(self.__filename) + else: + fname = dname + + fname, selectedFilter = E5FileDialog.getSaveFileNameAndFilter( + self, + self.tr("Save Diff"), + fname, + self.tr("Patch Files (*.diff)"), + None, + E5FileDialog.Options(E5FileDialog.DontConfirmOverwrite)) + + if not fname: + return # user aborted + + ext = QFileInfo(fname).suffix() + if not ext: + ex = selectedFilter.split("(*")[1].split(")")[0] + if ex: + fname += ex + if QFileInfo(fname).exists(): + res = E5MessageBox.yesNo( + self, + self.tr("Save Diff"), + self.tr("<p>The patch file <b>{0}</b> already exists." + " Overwrite it?</p>").format(fname), + icon=E5MessageBox.Warning) + if not res: + return + fname = Utilities.toNativeSeparators(fname) + + eol = e5App().getObject("Project").getEolString() + try: + with open(fname, "w", encoding="utf-8", newline="") as f: + f.write(eol.join(self.diffEdit.toPlainText().splitlines())) + except OSError as why: + E5MessageBox.critical( + self, self.tr('Save Diff'), + self.tr( + '<p>The patch file <b>{0}</b> could not be saved.' + '<br>Reason: {1}</p>') + .format(fname, str(why))) + + @pyqtSlot(str) + def on_sbsSelectLabel_linkActivated(self, link): + """ + Private slot to handle selection of a side-by-side link. + + @param link text of the selected link + @type str + """ + if ":" in link and self.__filename is not None: + scheme, path = link.split(":", 1) + if scheme == "sbsdiff" and "_" in path: + rev1, rev2 = path.split("_", 1) + self.vcs.hgSbsDiff(self.__filename, revisions=(rev1, rev2))