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