diff -r 4e8b98454baa -r 800c432b34c8 eric7/Plugins/VcsPlugins/vcsGit/GitDiffDialog.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/eric7/Plugins/VcsPlugins/vcsGit/GitDiffDialog.py Sat May 15 18:45:04 2021 +0200 @@ -0,0 +1,373 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2014 - 2021 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing a dialog to show the output of the git diff command +process. +""" + +import contextlib + +from PyQt5.QtCore import pyqtSlot, QFileInfo, Qt +from PyQt5.QtGui import QTextCursor +from PyQt5.QtWidgets import QWidget, QDialogButtonBox + +from E5Gui import E5MessageBox, E5FileDialog +from E5Gui.E5Application import e5App + +from .Ui_GitDiffDialog import Ui_GitDiffDialog + +from .GitDiffHighlighter import GitDiffHighlighter +from .GitDiffGenerator import GitDiffGenerator + +import Utilities +import Preferences + + +class GitDiffDialog(QWidget, Ui_GitDiffDialog): + """ + Class implementing a dialog to show the output of the git diff command + process. + """ + 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) + + self.refreshButton = self.buttonBox.addButton( + self.tr("Refresh"), QDialogButtonBox.ButtonRole.ActionRole) + self.refreshButton.setToolTip( + self.tr("Press to refresh the display")) + self.refreshButton.setEnabled(False) + self.buttonBox.button( + QDialogButtonBox.StandardButton.Save).setEnabled(False) + self.buttonBox.button( + QDialogButtonBox.StandardButton.Close).setDefault(True) + + try: + # insert the search widget if it is available + from E5Gui.E5TextEditSearchWidget import E5TextEditSearchWidget + self.searchWidget = E5TextEditSearchWidget(self.contentsGroup) + self.searchWidget.setFocusPolicy(Qt.FocusPolicy.WheelFocus) + self.searchWidget.setObjectName("searchWidget") + self.contentsGroup.layout().insertWidget(1, self.searchWidget) + self.searchWidget.attachTextEdit(self.contents) + + self.searchWidget2 = E5TextEditSearchWidget(self.contentsGroup) + self.searchWidget2.setFocusPolicy(Qt.FocusPolicy.WheelFocus) + self.searchWidget2.setObjectName("searchWidget2") + self.contentsGroup.layout().addWidget(self.searchWidget2) + self.searchWidget2.attachTextEdit(self.contents2) + + self.setTabOrder(self.filesCombo, self.searchWidget) + self.setTabOrder(self.searchWidget, self.contents) + self.setTabOrder(self.contents, self.contents2) + self.setTabOrder(self.contents2, self.searchWidget2) + self.setTabOrder(self.searchWidget2, self.errors) + except ImportError: + # eric version without search widget + self.searchWidget = None + self.searchWidget2 = None + + self.vcs = vcs + + font = Preferences.getEditorOtherFonts("MonospacedFont") + self.contents.document().setDefaultFont(font) + self.contents2.document().setDefaultFont(font) + + self.highlighter = GitDiffHighlighter(self.contents.document()) + self.highlighter2 = GitDiffHighlighter(self.contents2.document()) + + self.__diffGenerator = GitDiffGenerator(vcs, self) + self.__diffGenerator.finished.connect(self.__generatorFinished) + + self.__modeMessages = { + "work2stage": self.tr("Working Tree to Staging Area"), + "stage2repo": self.tr("Staging Area to HEAD Commit"), + "work2repo": self.tr("Working Tree to HEAD Commit"), + "work2stage2repo": self.tr("Working to Staging (top)" + " and Staging to HEAD (bottom)"), + "stash": self.tr("Stash Contents"), + "stashName": self.tr("Stash Contents of {0}"), + } + + def closeEvent(self, e): + """ + Protected slot implementing a close event handler. + + @param e close event (QCloseEvent) + """ + self.__diffGenerator.stopProcesses() + e.accept() + + def start(self, fn, versions=None, diffMode="work2repo", stashName="", + refreshable=False): + """ + Public slot to start the git diff command. + + @param fn filename to be diffed (string) + @param versions list of versions to be diffed (list of up to 2 strings + or None) + @param diffMode indication for the type of diff to be performed ( + 'work2repo' compares the working tree with the HEAD commit, + 'work2stage' compares the working tree with the staging area, + 'stage2repo' compares the staging area with the HEAD commit, + 'work2stage2repo' compares the working tree with the staging area + and the staging area with the HEAD commit, + 'stash' shows the diff for a stash) + @param stashName name of the stash to show a diff for (string) + @param refreshable flag indicating a refreshable diff (boolean) + @exception ValueError raised to indicate a bad value for the 'diffMode' + parameter. + """ + if diffMode not in ["work2repo", "work2stage", "stage2repo", + "work2stage2repo", "stash"]: + raise ValueError("Bad value for 'diffMode' parameter.") + + self.refreshButton.setVisible(refreshable) + + self.__filename = fn + self.__diffMode = diffMode + + self.errorGroup.hide() + + self.contents.clear() + self.contents2.clear() + self.contents2.setVisible(diffMode == "work2stage2repo") + if self.searchWidget2: + self.searchWidget2.setVisible(diffMode == "work2stage2repo") + + self.filesCombo.clear() + + with contextlib.suppress(AttributeError): + self.highlighter.regenerateRules() + self.highlighter2.regenerateRules() + + if diffMode in ["work2repo", "work2stage", "stage2repo", + "work2stage2repo"]: + self.contentsGroup.setTitle( + self.tr("Difference ({0})") + .format(self.__modeMessages[diffMode])) + + if versions is not None: + self.raise_() + self.activateWindow() + elif diffMode == "stash": + if stashName: + msg = self.__modeMessages["stashName"].format(stashName) + else: + msg = self.__modeMessages["stash"] + self.contentsGroup.setTitle( + self.tr("Difference ({0})").format(msg)) + + procStarted = self.__diffGenerator.start( + fn, versions=versions, diffMode=diffMode, stashName=stashName) + if not procStarted: + E5MessageBox.critical( + self, + self.tr('Process Generation Error'), + self.tr( + 'The process {0} could not be started. ' + 'Ensure, that it is in the search path.' + ).format('git')) + return + + def __generatorFinished(self): + """ + Private slot connected to the finished signal. + """ + self.refreshButton.setEnabled(True) + + diff1, diff2, errors, fileSeparators = self.__diffGenerator.getResult() + + if diff1: + self.contents.setPlainText("".join(diff1)) + else: + self.contents.setPlainText( + self.tr('There is no difference.')) + + if diff2: + self.contents2.setPlainText("".join(diff2)) + else: + self.contents2.setPlainText( + self.tr('There is no difference.')) + + if errors: + self.errorGroup.show() + self.errors.setPlainText(errors) + self.errors.ensureCursorVisible() + + self.buttonBox.button( + QDialogButtonBox.StandardButton.Save).setEnabled(bool(diff2)) + self.buttonBox.button( + QDialogButtonBox.StandardButton.Close).setEnabled(True) + self.buttonBox.button( + QDialogButtonBox.StandardButton.Close).setDefault(True) + self.buttonBox.button( + QDialogButtonBox.StandardButton.Close).setFocus( + Qt.FocusReason.OtherFocusReason) + + for contents in [self.contents, self.contents2]: + tc = contents.textCursor() + tc.movePosition(QTextCursor.MoveOperation.Start) + contents.setTextCursor(tc) + contents.ensureCursorVisible() + + fileSeparators = self.__mergeFileSeparators(fileSeparators) + self.filesCombo.addItem(self.tr("<Start>"), (0, 0)) + self.filesCombo.addItem(self.tr("<End>"), (-1, -1)) + for oldFile, newFile, pos1, pos2 in sorted(fileSeparators): + if oldFile != newFile: + self.filesCombo.addItem( + "{0} -- {1}".format(oldFile, newFile), (pos1, pos2)) + else: + self.filesCombo.addItem(oldFile, (pos1, pos2)) + + 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()) + + 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.Save + ): + self.on_saveButton_clicked() + elif button == self.refreshButton: + self.on_refreshButton_clicked() + + @pyqtSlot(int) + def on_filesCombo_activated(self, index): + """ + Private slot to handle the selection of a file. + + @param index activated row (integer) + """ + para1, para2 = self.filesCombo.itemData(index) + + for para, contents in [(para1, self.contents), + (para2, self.contents2)]: + if para == 0: + tc = contents.textCursor() + tc.movePosition(QTextCursor.MoveOperation.Start) + contents.setTextCursor(tc) + contents.ensureCursorVisible() + elif para == -1: + tc = contents.textCursor() + tc.movePosition(QTextCursor.MoveOperation.End) + contents.setTextCursor(tc) + contents.ensureCursorVisible() + else: + # step 1: move cursor to end + tc = contents.textCursor() + tc.movePosition(QTextCursor.MoveOperation.End) + contents.setTextCursor(tc) + contents.ensureCursorVisible() + + # step 2: move cursor to desired line + tc = contents.textCursor() + delta = tc.blockNumber() - para + tc.movePosition(QTextCursor.MoveOperation.PreviousBlock, + QTextCursor.MoveMode.MoveAnchor, + delta) + contents.setTextCursor(tc) + contents.ensureCursorVisible() + + @pyqtSlot() + def on_saveButton_clicked(self): + """ + Private slot to handle the Save button press. + + It saves the diff shown in the dialog to a file in the local + filesystem. + """ + if isinstance(self.__filename, list): + if len(self.__filename) > 1: + fname = self.vcs.splitPathList(self.__filename)[0] + else: + dname, fname = self.vcs.splitPath(self.__filename[0]) + if fname != '.': + fname = "{0}.diff".format(self.__filename[0]) + else: + fname = dname + else: + fname = self.vcs.splitPath(self.__filename)[0] + + 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.contents2.toPlainText().splitlines())) + f.write(eol) + 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() + def on_refreshButton_clicked(self): + """ + Private slot to refresh the display. + """ + self.buttonBox.button( + QDialogButtonBox.StandardButton.Close).setEnabled(False) + + self.buttonBox.button( + QDialogButtonBox.StandardButton.Save).setEnabled(False) + self.refreshButton.setEnabled(False) + + self.start(self.__filename, diffMode=self.__diffMode, refreshable=True)