eric6/UI/DiffDialog.py

changeset 6942
2602857055c5
parent 6645
ad476851d7e0
child 7229
53054eb5b15a
equal deleted inserted replaced
6941:f99d60d6b59b 6942:2602857055c5
1 # -*- coding: utf-8 -*-
2
3 # Copyright (c) 2004 - 2019 Detlev Offenbach <detlev@die-offenbachs.de>
4 #
5
6 """
7 Module implementing a dialog to compare two files.
8 """
9
10 from __future__ import unicode_literals
11
12 import os
13 import time
14
15 from PyQt5.QtCore import QFileInfo, QEvent, pyqtSlot
16 from PyQt5.QtGui import QTextCursor
17 from PyQt5.QtWidgets import QWidget, QApplication, QDialogButtonBox
18
19 from E5Gui import E5MessageBox, E5FileDialog
20 from E5Gui.E5MainWindow import E5MainWindow
21 from E5Gui.E5PathPicker import E5PathPickerModes
22
23 from .Ui_DiffDialog import Ui_DiffDialog
24 from .DiffHighlighter import DiffHighlighter
25
26 import Utilities
27 import Preferences
28
29 from difflib import SequenceMatcher
30
31 # This function is copied from python 2.3 and slightly modified.
32 # The header lines contain a tab after the filename.
33
34
35 def unified_diff(a, b, fromfile='', tofile='', fromfiledate='',
36 tofiledate='', n=3, lineterm='\n'):
37 """
38 Compare two sequences of lines; generate the delta as a unified diff.
39
40 Unified diffs are a compact way of showing line changes and a few
41 lines of context. The number of context lines is set by 'n' which
42 defaults to three.
43
44 By default, the diff control lines (those with ---, +++, or @@) are
45 created with a trailing newline. This is helpful so that inputs
46 created from file.readlines() result in diffs that are suitable for
47 file.writelines() since both the inputs and outputs have trailing
48 newlines.
49
50 For inputs that do not have trailing newlines, set the lineterm
51 argument to "" so that the output will be uniformly newline free.
52
53 The unidiff format normally has a header for filenames and modification
54 times. Any or all of these may be specified using strings for
55 'fromfile', 'tofile', 'fromfiledate', and 'tofiledate'. The modification
56 times are normally expressed in the format returned by time.ctime().
57
58 Example:
59
60 <pre>
61 &gt;&gt;&gt; for line in unified_diff('one two three four'.split(),
62 ... 'zero one tree four'.split(), 'Original', 'Current',
63 ... 'Sat Jan 26 23:30:50 1991', 'Fri Jun 06 10:20:52 2003',
64 ... lineterm=''):
65 ... print line
66 --- Original Sat Jan 26 23:30:50 1991
67 +++ Current Fri Jun 06 10:20:52 2003
68 @@ -1,4 +1,4 @@
69 +zero
70 one
71 -two
72 -three
73 +tree
74 four
75 </pre>
76
77 @param a first sequence of lines (list of strings)
78 @param b second sequence of lines (list of strings)
79 @param fromfile filename of the first file (string)
80 @param tofile filename of the second file (string)
81 @param fromfiledate modification time of the first file (string)
82 @param tofiledate modification time of the second file (string)
83 @param n number of lines of context (integer)
84 @param lineterm line termination string (string)
85 @return a generator yielding lines of differences
86 """
87 started = False
88 for group in SequenceMatcher(None, a, b).get_grouped_opcodes(n):
89 if not started:
90 yield '--- {0}\t{1}{2}'.format(fromfile, fromfiledate, lineterm)
91 yield '+++ {0}\t{1}{2}'.format(tofile, tofiledate, lineterm)
92 started = True
93 i1 = group[0][1]
94 i2 = group[-1][2]
95 j1 = group[0][3]
96 j2 = group[-1][4]
97 yield "@@ -{0:d},{1:d} +{2:d},{3:d} @@{4}".format(
98 i1 + 1, i2 - i1, j1 + 1, j2 - j1, lineterm)
99 for tag, i1, i2, j1, j2 in group:
100 if tag == 'equal':
101 for line in a[i1:i2]:
102 yield ' ' + line
103 continue
104 if tag == 'replace' or tag == 'delete':
105 for line in a[i1:i2]:
106 yield '-' + line
107 if tag == 'replace' or tag == 'insert':
108 for line in b[j1:j2]:
109 yield '+' + line
110
111 # This function is copied from python 2.3 and slightly modified.
112 # The header lines contain a tab after the filename.
113
114
115 def context_diff(a, b, fromfile='', tofile='',
116 fromfiledate='', tofiledate='', n=3, lineterm='\n'):
117 r"""
118 Compare two sequences of lines; generate the delta as a context diff.
119
120 Context diffs are a compact way of showing line changes and a few
121 lines of context. The number of context lines is set by 'n' which
122 defaults to three.
123
124 By default, the diff control lines (those with *** or ---) are
125 created with a trailing newline. This is helpful so that inputs
126 created from file.readlines() result in diffs that are suitable for
127 file.writelines() since both the inputs and outputs have trailing
128 newlines.
129
130 For inputs that do not have trailing newlines, set the lineterm
131 argument to "" so that the output will be uniformly newline free.
132
133 The context diff format normally has a header for filenames and
134 modification times. Any or all of these may be specified using
135 strings for 'fromfile', 'tofile', 'fromfiledate', and 'tofiledate'.
136 The modification times are normally expressed in the format returned
137 by time.ctime(). If not specified, the strings default to blanks.
138
139 Example:
140
141 <pre>
142 &gt;&gt;&gt; print ''.join(
143 ... context_diff('one\ntwo\nthree\nfour\n'.splitlines(1),
144 ... 'zero\none\ntree\nfour\n'.splitlines(1), 'Original', 'Current',
145 ... 'Sat Jan 26 23:30:50 1991', 'Fri Jun 06 10:22:46 2003')),
146 *** Original Sat Jan 26 23:30:50 1991
147 --- Current Fri Jun 06 10:22:46 2003
148 ***************
149 *** 1,4 ****
150 one
151 ! two
152 ! three
153 four
154 --- 1,4 ----
155 + zero
156 one
157 ! tree
158 four
159 </pre>
160
161 @param a first sequence of lines (list of strings)
162 @param b second sequence of lines (list of strings)
163 @param fromfile filename of the first file (string)
164 @param tofile filename of the second file (string)
165 @param fromfiledate modification time of the first file (string)
166 @param tofiledate modification time of the second file (string)
167 @param n number of lines of context (integer)
168 @param lineterm line termination string (string)
169 @return a generator yielding lines of differences
170 """
171 started = False
172 prefixmap = {'insert': '+ ', 'delete': '- ', 'replace': '! ',
173 'equal': ' '}
174 for group in SequenceMatcher(None, a, b).get_grouped_opcodes(n):
175 if not started:
176 yield '*** {0}\t{1}{2}'.format(fromfile, fromfiledate, lineterm)
177 yield '--- {0}\t{1}{2}'.format(tofile, tofiledate, lineterm)
178 started = True
179
180 yield '***************{0}'.format(lineterm)
181 if group[-1][2] - group[0][1] >= 2:
182 yield '*** {0:d},{1:d} ****{2}'.format(
183 group[0][1] + 1, group[-1][2], lineterm)
184 else:
185 yield '*** {0:d} ****{1}'.format(group[-1][2], lineterm)
186 visiblechanges = [e for e in group if e[0] in ('replace', 'delete')]
187 if visiblechanges:
188 for tag, i1, i2, _, _ in group:
189 if tag != 'insert':
190 for line in a[i1:i2]:
191 yield prefixmap[tag] + line
192
193 if group[-1][4] - group[0][3] >= 2:
194 yield '--- {0:d},{1:d} ----{2}'.format(
195 group[0][3] + 1, group[-1][4], lineterm)
196 else:
197 yield '--- {0:d} ----{1}'.format(group[-1][4], lineterm)
198 visiblechanges = [e for e in group if e[0] in ('replace', 'insert')]
199 if visiblechanges:
200 for tag, _, _, j1, j2 in group:
201 if tag != 'delete':
202 for line in b[j1:j2]:
203 yield prefixmap[tag] + line
204
205
206 class DiffDialog(QWidget, Ui_DiffDialog):
207 """
208 Class implementing a dialog to compare two files.
209 """
210 def __init__(self, parent=None):
211 """
212 Constructor
213
214 @param parent reference to the parent widget (QWidget)
215 """
216 super(DiffDialog, self).__init__(parent)
217 self.setupUi(self)
218
219 self.file1Picker.setMode(E5PathPickerModes.OpenFileMode)
220 self.file2Picker.setMode(E5PathPickerModes.OpenFileMode)
221
222 self.diffButton = self.buttonBox.addButton(
223 self.tr("Compare"), QDialogButtonBox.ActionRole)
224 self.diffButton.setToolTip(
225 self.tr("Press to perform the comparison of the two files"))
226 self.saveButton = self.buttonBox.addButton(
227 self.tr("Save"), QDialogButtonBox.ActionRole)
228 self.saveButton.setToolTip(
229 self.tr("Save the output to a patch file"))
230 self.diffButton.setEnabled(False)
231 self.saveButton.setEnabled(False)
232 self.diffButton.setDefault(True)
233
234 self.searchWidget.attachTextEdit(self.contents)
235
236 self.filename1 = ''
237 self.filename2 = ''
238
239 self.updateInterval = 20 # update every 20 lines
240
241 font = Preferences.getEditorOtherFonts("MonospacedFont")
242 self.contents.setFontFamily(font.family())
243 self.contents.setFontPointSize(font.pointSize())
244
245 self.highlighter = DiffHighlighter(self.contents.document())
246
247 # connect some of our widgets explicitly
248 self.file1Picker.textChanged.connect(self.__fileChanged)
249 self.file2Picker.textChanged.connect(self.__fileChanged)
250
251 def show(self, filename=None):
252 """
253 Public slot to show the dialog.
254
255 @param filename name of a file to use as the first file (string)
256 """
257 if filename:
258 self.file1Picker.setText(filename)
259 super(DiffDialog, self).show()
260
261 def on_buttonBox_clicked(self, button):
262 """
263 Private slot called by a button of the button box clicked.
264
265 @param button button that was clicked (QAbstractButton)
266 """
267 if button == self.diffButton:
268 self.on_diffButton_clicked()
269 elif button == self.saveButton:
270 self.on_saveButton_clicked()
271
272 @pyqtSlot()
273 def on_saveButton_clicked(self):
274 """
275 Private slot to handle the Save button press.
276
277 It saves the diff shown in the dialog to a file in the local
278 filesystem.
279 """
280 dname, fname = Utilities.splitPath(self.filename2)
281 if fname != '.':
282 fname = "{0}.diff".format(self.filename2)
283 else:
284 fname = dname
285
286 fname, selectedFilter = E5FileDialog.getSaveFileNameAndFilter(
287 self,
288 self.tr("Save Diff"),
289 fname,
290 self.tr("Patch Files (*.diff)"),
291 None,
292 E5FileDialog.Options(E5FileDialog.DontConfirmOverwrite))
293
294 if not fname:
295 return
296
297 ext = QFileInfo(fname).suffix()
298 if not ext:
299 ex = selectedFilter.split("(*")[1].split(")")[0]
300 if ex:
301 fname += ex
302 if QFileInfo(fname).exists():
303 res = E5MessageBox.yesNo(
304 self,
305 self.tr("Save Diff"),
306 self.tr("<p>The patch file <b>{0}</b> already exists."
307 " Overwrite it?</p>").format(fname),
308 icon=E5MessageBox.Warning)
309 if not res:
310 return
311 fname = Utilities.toNativeSeparators(fname)
312
313 try:
314 f = open(fname, "w", encoding="utf-8")
315 txt = self.contents.toPlainText()
316 try:
317 f.write(txt)
318 except UnicodeError:
319 pass
320 f.close()
321 except IOError as why:
322 E5MessageBox.critical(
323 self, self.tr('Save Diff'),
324 self.tr(
325 '<p>The patch file <b>{0}</b> could not be saved.<br />'
326 'Reason: {1}</p>').format(fname, str(why)))
327
328 @pyqtSlot()
329 def on_diffButton_clicked(self):
330 """
331 Private slot to handle the Compare button press.
332 """
333 self.filename1 = Utilities.toNativeSeparators(self.file1Picker.text())
334 try:
335 filemtime1 = time.ctime(os.stat(self.filename1).st_mtime)
336 except IOError:
337 filemtime1 = ""
338 try:
339 f1 = open(self.filename1, "r", encoding="utf-8")
340 lines1 = f1.readlines()
341 f1.close()
342 except IOError:
343 E5MessageBox.critical(
344 self,
345 self.tr("Compare Files"),
346 self.tr(
347 """<p>The file <b>{0}</b> could not be read.</p>""")
348 .format(self.filename1))
349 return
350
351 self.filename2 = Utilities.toNativeSeparators(self.file2Picker.text())
352 try:
353 filemtime2 = time.ctime(os.stat(self.filename2).st_mtime)
354 except IOError:
355 filemtime2 = ""
356 try:
357 f2 = open(self.filename2, "r", encoding="utf-8")
358 lines2 = f2.readlines()
359 f2.close()
360 except IOError:
361 E5MessageBox.critical(
362 self,
363 self.tr("Compare Files"),
364 self.tr(
365 """<p>The file <b>{0}</b> could not be read.</p>""")
366 .format(self.filename2))
367 return
368
369 self.contents.clear()
370 self.highlighter.regenerateRules()
371 self.saveButton.setEnabled(False)
372
373 if self.unifiedRadioButton.isChecked():
374 self.__generateUnifiedDiff(
375 lines1, lines2, self.filename1, self.filename2,
376 filemtime1, filemtime2)
377 else:
378 self.__generateContextDiff(
379 lines1, lines2, self.filename1, self.filename2,
380 filemtime1, filemtime2)
381
382 tc = self.contents.textCursor()
383 tc.movePosition(QTextCursor.Start)
384 self.contents.setTextCursor(tc)
385 self.contents.ensureCursorVisible()
386
387 self.saveButton.setEnabled(True)
388
389 def __appendText(self, txt):
390 """
391 Private method to append text to the end of the contents pane.
392
393 @param txt text to insert (string)
394 """
395 tc = self.contents.textCursor()
396 tc.movePosition(QTextCursor.End)
397 self.contents.setTextCursor(tc)
398 self.contents.insertPlainText(txt)
399
400 def __generateUnifiedDiff(self, a, b, fromfile, tofile,
401 fromfiledate, tofiledate):
402 """
403 Private slot to generate a unified diff output.
404
405 @param a first sequence of lines (list of strings)
406 @param b second sequence of lines (list of strings)
407 @param fromfile filename of the first file (string)
408 @param tofile filename of the second file (string)
409 @param fromfiledate modification time of the first file (string)
410 @param tofiledate modification time of the second file (string)
411 """
412 paras = 0
413 for line in unified_diff(a, b, fromfile, tofile,
414 fromfiledate, tofiledate):
415 self.__appendText(line)
416 paras += 1
417 if not (paras % self.updateInterval):
418 QApplication.processEvents()
419
420 if paras == 0:
421 self.__appendText(self.tr('There is no difference.'))
422
423 def __generateContextDiff(self, a, b, fromfile, tofile,
424 fromfiledate, tofiledate):
425 """
426 Private slot to generate a context diff output.
427
428 @param a first sequence of lines (list of strings)
429 @param b second sequence of lines (list of strings)
430 @param fromfile filename of the first file (string)
431 @param tofile filename of the second file (string)
432 @param fromfiledate modification time of the first file (string)
433 @param tofiledate modification time of the second file (string)
434 """
435 paras = 0
436 for line in context_diff(a, b, fromfile, tofile,
437 fromfiledate, tofiledate):
438 self.__appendText(line)
439 paras += 1
440 if not (paras % self.updateInterval):
441 QApplication.processEvents()
442
443 if paras == 0:
444 self.__appendText(self.tr('There is no difference.'))
445
446 def __fileChanged(self):
447 """
448 Private slot to enable/disable the Compare button.
449 """
450 if not self.file1Picker.text() or \
451 not self.file2Picker.text():
452 self.diffButton.setEnabled(False)
453 else:
454 self.diffButton.setEnabled(True)
455
456
457 class DiffWindow(E5MainWindow):
458 """
459 Main window class for the standalone dialog.
460 """
461 def __init__(self, parent=None):
462 """
463 Constructor
464
465 @param parent reference to the parent widget (QWidget)
466 """
467 super(DiffWindow, self).__init__(parent)
468
469 self.setStyle(Preferences.getUI("Style"),
470 Preferences.getUI("StyleSheet"))
471
472 self.cw = DiffDialog(self)
473 self.cw.installEventFilter(self)
474 size = self.cw.size()
475 self.setCentralWidget(self.cw)
476 self.resize(size)
477
478 def eventFilter(self, obj, event):
479 """
480 Public method to filter events.
481
482 @param obj reference to the object the event is meant for (QObject)
483 @param event reference to the event object (QEvent)
484 @return flag indicating, whether the event was handled (boolean)
485 """
486 if event.type() == QEvent.Close:
487 QApplication.exit()
488 return True
489
490 return False

eric ide

mercurial