diff -r 3fc8dfeb6ebe -r b99e7fd55fd3 src/eric7/Plugins/VcsPlugins/vcsPySvn/SvnDiffDialog.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/Plugins/VcsPlugins/vcsPySvn/SvnDiffDialog.py Thu Jul 07 11:23:56 2022 +0200 @@ -0,0 +1,484 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2003 - 2022 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing a dialog to show the output of the svn diff command +process. +""" + +import os +import pathlib + +import pysvn + +from PyQt6.QtCore import QDateTime, Qt, pyqtSlot +from PyQt6.QtGui import QTextCursor +from PyQt6.QtWidgets import QWidget, QDialogButtonBox + +from EricWidgets.EricApplication import ericApp +from EricWidgets import EricMessageBox, EricFileDialog +from EricGui.EricOverrideCursor import EricOverrideCursor + +from EricUtilities.EricMutexLocker import EricMutexLocker + +from .SvnDialogMixin import SvnDialogMixin +from .Ui_SvnDiffDialog import Ui_SvnDiffDialog +from .SvnDiffHighlighter import SvnDiffHighlighter + +import Utilities +import Preferences + + +class SvnDiffDialog(QWidget, SvnDialogMixin, Ui_SvnDiffDialog): + """ + Class implementing a dialog to show the output of the svn diff command. + """ + 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) + SvnDialogMixin.__init__(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).setEnabled(False) + self.buttonBox.button( + QDialogButtonBox.StandardButton.Cancel).setDefault(True) + + self.searchWidget.attachTextEdit(self.contents) + + self.vcs = vcs + + font = Preferences.getEditorOtherFonts("MonospacedFont") + self.contents.document().setDefaultFont(font) + + self.highlighter = SvnDiffHighlighter(self.contents.document()) + + self.client = self.vcs.getClient() + self.client.callback_cancel = self._clientCancelCallback + self.client.callback_get_login = self._clientLoginCallback + self.client.callback_ssl_server_trust_prompt = ( + self._clientSslServerTrustPromptCallback + ) + + def __getVersionArg(self, version): + """ + Private method to get a pysvn revision object for the given version + number. + + @param version revision (integer or string) + @return revision object (pysvn.Revision) + """ + if isinstance(version, int): + return pysvn.Revision(pysvn.opt_revision_kind.number, version) + elif version.startswith("{"): + dateStr = version[1:-1] + secs = QDateTime.fromString( + dateStr, Qt.DateFormat.ISODate).toTime_t() + return pysvn.Revision(pysvn.opt_revision_kind.date, secs) + else: + return { + "HEAD": pysvn.Revision(pysvn.opt_revision_kind.head), + "COMMITTED": pysvn.Revision(pysvn.opt_revision_kind.committed), + "BASE": pysvn.Revision(pysvn.opt_revision_kind.base), + "WORKING": pysvn.Revision(pysvn.opt_revision_kind.working), + "PREV": pysvn.Revision(pysvn.opt_revision_kind.previous), + }.get(version, pysvn.Revision(pysvn.opt_revision_kind.unspecified)) + + def __getDiffSummaryKind(self, summaryKind): + """ + Private method to get a string descripion of the diff summary. + + @param summaryKind (pysvn.diff_summarize.summarize_kind) + @return one letter string indicating the change type (string) + """ + if summaryKind == pysvn.diff_summarize_kind.delete: + return "D" + elif summaryKind == pysvn.diff_summarize_kind.modified: + return "M" + elif summaryKind == pysvn.diff_summarize_kind.added: + return "A" + elif summaryKind == pysvn.diff_summarize_kind.normal: + return "N" + else: + return " " + + def start(self, fn, versions=None, urls=None, summary=False, pegRev=None, + refreshable=False): + """ + Public slot to start the svn diff command. + + @param fn filename to be diffed (string) + @param versions list of versions to be diffed (list of up to 2 integer + or None) + @param urls list of repository URLs (list of 2 strings) + @param summary flag indicating a summarizing diff + (only valid for URL diffs) (boolean) + @param pegRev revision number the filename is valid (integer) + @param refreshable flag indicating a refreshable diff (boolean) + """ + self.refreshButton.setVisible(refreshable) + + self.buttonBox.button( + QDialogButtonBox.StandardButton.Save).setEnabled(False) + 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._reset() + self.errorGroup.hide() + + self.filename = fn + + self.contents.clear() + self.highlighter.regenerateRules() + self.paras = 0 + + self.filesCombo.clear() + + self.__oldFile = "" + self.__oldFileLine = -1 + self.__fileSeparators = [] + + if Utilities.hasEnvironmentEntry('TEMP'): + tmpdir = Utilities.getEnvironmentEntry('TEMP') + elif Utilities.hasEnvironmentEntry('TMPDIR'): + tmpdir = Utilities.getEnvironmentEntry('TMPDIR') + elif Utilities.hasEnvironmentEntry('TMP'): + tmpdir = Utilities.getEnvironmentEntry('TMP') + elif os.path.exists('/var/tmp'): # secok + tmpdir = '/var/tmp' # secok + elif os.path.exists('/usr/tmp'): + tmpdir = '/usr/tmp' + elif os.path.exists('/tmp'): # secok + tmpdir = '/tmp' # secok + else: + EricMessageBox.critical( + self, + self.tr("Subversion Diff"), + self.tr("""There is no temporary directory available.""")) + return + + tmpdir = os.path.join(tmpdir, 'svn_tmp') + if not os.path.exists(tmpdir): + os.mkdir(tmpdir) + + opts = self.vcs.options['global'] + self.vcs.options['diff'] + recurse = "--non-recursive" not in opts + + if versions is not None: + self.raise_() + self.activateWindow() + rev1 = self.__getVersionArg(versions[0]) + if len(versions) == 1: + rev2 = self.__getVersionArg("WORKING") + else: + rev2 = self.__getVersionArg(versions[1]) + else: + rev1 = self.__getVersionArg("BASE") + rev2 = self.__getVersionArg("WORKING") + + if urls is not None: + rev1 = self.__getVersionArg("HEAD") + rev2 = self.__getVersionArg("HEAD") + + if isinstance(fn, list): + dname, fnames = self.vcs.splitPathList(fn) + else: + dname, fname = self.vcs.splitPath(fn) + fnames = [fname] + + with EricOverrideCursor(): + cwd = os.getcwd() + os.chdir(dname) + try: + dname = ericApp().getObject('Project').getRelativePath(dname) + if dname: + dname += "/" + with EricMutexLocker(self.vcs.vcsExecutionMutex): + for name in fnames: + self.__showError( + self.tr("Processing file '{0}'...\n").format(name)) + if urls is not None: + url1 = "{0}/{1}{2}".format(urls[0], dname, name) + url2 = "{0}/{1}{2}".format(urls[1], dname, name) + if summary: + diff_summary = self.client.diff_summarize( + url1, revision1=rev1, + url_or_path2=url2, revision2=rev2, + recurse=recurse) + diff_list = [] + for diff_sum in diff_summary: + path = diff_sum['path'] + diff_list.append("{0} {1}".format( + self.__getDiffSummaryKind( + diff_sum['summarize_kind']), + path)) + diffText = os.linesep.join(diff_list) + else: + diffText = self.client.diff( + tmpdir, + url1, revision1=rev1, + url_or_path2=url2, revision2=rev2, + recurse=recurse) + else: + if pegRev is not None: + diffText = self.client.diff_peg( + tmpdir, name, + peg_revision=self.__getVersionArg(pegRev), + revision_start=rev1, revision_end=rev2, + recurse=recurse) + else: + diffText = self.client.diff( + tmpdir, name, + revision1=rev1, revision2=rev2, + recurse=recurse) + for counter, line in enumerate(diffText.splitlines()): + if ( + line.startswith("--- ") or + line.startswith("+++ ") + ): + self.__processFileLine(line) + + self.__appendText( + "{0}{1}".format(line, os.linesep)) + if ( + counter % 30 == 0 and + self._clientCancelCallback() + ): + # check for cancel every 30 lines + break + if self._clientCancelCallback(): + break + except pysvn.ClientError as e: + self.__showError(e.args[0]) + os.chdir(cwd) + self.__finish() + + if self.paras == 0: + self.contents.setPlainText(self.tr('There is no difference.')) + + self.buttonBox.button( + QDialogButtonBox.StandardButton.Save).setEnabled(self.paras > 0) + + def __appendText(self, line): + """ + Private method to append text to the end of the contents pane. + + @param line line of text to insert (string) + """ + tc = self.contents.textCursor() + tc.movePosition(QTextCursor.MoveOperation.End) + self.contents.setTextCursor(tc) + self.contents.insertPlainText(line) + self.paras += 1 + + def __extractFileName(self, line): + """ + Private method to extract the file name out of a file separator line. + + @param line line to be processed (string) + @return extracted file name (string) + """ + f = line.split(None, 1)[1] + f = f.rsplit(None, 2)[0] + return f + + def __processFileLine(self, line): + """ + Private slot to process a line giving the old/new file. + + @param line line to be processed (string) + """ + if line.startswith('---'): + self.__oldFileLine = self.paras + self.__oldFile = self.__extractFileName(line) + else: + self.__fileSeparators.append( + (self.__oldFile, self.__extractFileName(line), + self.__oldFileLine)) + + def __finish(self): + """ + Private slot called when the user pressed the button. + """ + self.refreshButton.setEnabled(True) + self.buttonBox.button( + QDialogButtonBox.StandardButton.Cancel).setEnabled(False) + self.buttonBox.button( + QDialogButtonBox.StandardButton.Close).setEnabled(True) + self.buttonBox.button( + QDialogButtonBox.StandardButton.Close).setEnabled(True) + self.buttonBox.button( + QDialogButtonBox.StandardButton.Close).setDefault(True) + + tc = self.contents.textCursor() + tc.movePosition(QTextCursor.MoveOperation.Start) + self.contents.setTextCursor(tc) + self.contents.ensureCursorVisible() + + self.filesCombo.addItem(self.tr("<Start>"), 0) + self.filesCombo.addItem(self.tr("<End>"), -1) + for oldFile, newFile, pos in sorted(self.__fileSeparators): + if oldFile != newFile: + self.filesCombo.addItem( + "{0}\n{1}".format(oldFile, newFile), pos) + else: + self.filesCombo.addItem(oldFile, pos) + + self._cancel() + + 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.__finish() + elif 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) + """ + para = self.filesCombo.itemData(index) + + if para == 0: + tc = self.contents.textCursor() + tc.movePosition(QTextCursor.MoveOperation.Start) + self.contents.setTextCursor(tc) + self.contents.ensureCursorVisible() + elif para == -1: + tc = self.contents.textCursor() + tc.movePosition(QTextCursor.MoveOperation.End) + self.contents.setTextCursor(tc) + self.contents.ensureCursorVisible() + else: + # step 1: move cursor to end + tc = self.contents.textCursor() + tc.movePosition(QTextCursor.MoveOperation.End) + self.contents.setTextCursor(tc) + self.contents.ensureCursorVisible() + + # step 2: move cursor to desired line + tc = self.contents.textCursor() + delta = tc.blockNumber() - para + tc.movePosition( + QTextCursor.MoveOperation.PreviousBlock, + QTextCursor.MoveMode.MoveAnchor, + delta) + self.contents.setTextCursor(tc) + self.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 = 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.contents.toPlainText().splitlines())) + 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() + 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, refreshable=True) + + def __showError(self, msg): + """ + Private slot to show an error message. + + @param msg error message to show (string) + """ + self.errorGroup.show() + self.errors.insertPlainText(msg) + self.errors.ensureCursorVisible()