|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2004 - 2021 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing a dialog to compare two files. |
|
8 """ |
|
9 |
|
10 import os |
|
11 import time |
|
12 import contextlib |
|
13 from difflib import unified_diff, context_diff |
|
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 |
|
30 class DiffDialog(QWidget, Ui_DiffDialog): |
|
31 """ |
|
32 Class implementing a dialog to compare two files. |
|
33 """ |
|
34 def __init__(self, parent=None): |
|
35 """ |
|
36 Constructor |
|
37 |
|
38 @param parent reference to the parent widget (QWidget) |
|
39 """ |
|
40 super().__init__(parent) |
|
41 self.setupUi(self) |
|
42 |
|
43 self.file1Picker.setMode(E5PathPickerModes.OpenFileMode) |
|
44 self.file2Picker.setMode(E5PathPickerModes.OpenFileMode) |
|
45 |
|
46 self.diffButton = self.buttonBox.addButton( |
|
47 self.tr("Compare"), QDialogButtonBox.ButtonRole.ActionRole) |
|
48 self.diffButton.setToolTip( |
|
49 self.tr("Press to perform the comparison of the two files")) |
|
50 self.saveButton = self.buttonBox.addButton( |
|
51 self.tr("Save"), QDialogButtonBox.ButtonRole.ActionRole) |
|
52 self.saveButton.setToolTip( |
|
53 self.tr("Save the output to a patch file")) |
|
54 self.diffButton.setEnabled(False) |
|
55 self.saveButton.setEnabled(False) |
|
56 self.diffButton.setDefault(True) |
|
57 |
|
58 self.searchWidget.attachTextEdit(self.contents) |
|
59 |
|
60 self.filename1 = '' |
|
61 self.filename2 = '' |
|
62 |
|
63 self.updateInterval = 20 # update every 20 lines |
|
64 |
|
65 font = Preferences.getEditorOtherFonts("MonospacedFont") |
|
66 self.contents.document().setDefaultFont(font) |
|
67 |
|
68 self.highlighter = DiffHighlighter(self.contents.document()) |
|
69 |
|
70 # connect some of our widgets explicitly |
|
71 self.file1Picker.textChanged.connect(self.__fileChanged) |
|
72 self.file2Picker.textChanged.connect(self.__fileChanged) |
|
73 |
|
74 def show(self, filename=None): |
|
75 """ |
|
76 Public slot to show the dialog. |
|
77 |
|
78 @param filename name of a file to use as the first file (string) |
|
79 """ |
|
80 if filename: |
|
81 self.file1Picker.setText(filename) |
|
82 super().show() |
|
83 |
|
84 def on_buttonBox_clicked(self, button): |
|
85 """ |
|
86 Private slot called by a button of the button box clicked. |
|
87 |
|
88 @param button button that was clicked (QAbstractButton) |
|
89 """ |
|
90 if button == self.diffButton: |
|
91 self.on_diffButton_clicked() |
|
92 elif button == self.saveButton: |
|
93 self.on_saveButton_clicked() |
|
94 |
|
95 @pyqtSlot() |
|
96 def on_saveButton_clicked(self): |
|
97 """ |
|
98 Private slot to handle the Save button press. |
|
99 |
|
100 It saves the diff shown in the dialog to a file in the local |
|
101 filesystem. |
|
102 """ |
|
103 dname, fname = Utilities.splitPath(self.filename2) |
|
104 fname = "{0}.diff".format(self.filename2) if fname != '.' else dname |
|
105 |
|
106 fname, selectedFilter = E5FileDialog.getSaveFileNameAndFilter( |
|
107 self, |
|
108 self.tr("Save Diff"), |
|
109 fname, |
|
110 self.tr("Patch Files (*.diff)"), |
|
111 None, |
|
112 E5FileDialog.Options(E5FileDialog.DontConfirmOverwrite)) |
|
113 |
|
114 if not fname: |
|
115 return |
|
116 |
|
117 ext = QFileInfo(fname).suffix() |
|
118 if not ext: |
|
119 ex = selectedFilter.split("(*")[1].split(")")[0] |
|
120 if ex: |
|
121 fname += ex |
|
122 if QFileInfo(fname).exists(): |
|
123 res = E5MessageBox.yesNo( |
|
124 self, |
|
125 self.tr("Save Diff"), |
|
126 self.tr("<p>The patch file <b>{0}</b> already exists." |
|
127 " Overwrite it?</p>").format(fname), |
|
128 icon=E5MessageBox.Warning) |
|
129 if not res: |
|
130 return |
|
131 fname = Utilities.toNativeSeparators(fname) |
|
132 |
|
133 txt = self.contents.toPlainText() |
|
134 try: |
|
135 with open(fname, "w", encoding="utf-8") as f, \ |
|
136 contextlib.suppress(UnicodeError): |
|
137 f.write(txt) |
|
138 except OSError as why: |
|
139 E5MessageBox.critical( |
|
140 self, self.tr('Save Diff'), |
|
141 self.tr( |
|
142 '<p>The patch file <b>{0}</b> could not be saved.<br />' |
|
143 'Reason: {1}</p>').format(fname, str(why))) |
|
144 |
|
145 @pyqtSlot() |
|
146 def on_diffButton_clicked(self): |
|
147 """ |
|
148 Private slot to handle the Compare button press. |
|
149 """ |
|
150 self.filename1 = Utilities.toNativeSeparators(self.file1Picker.text()) |
|
151 try: |
|
152 filemtime1 = time.ctime(os.stat(self.filename1).st_mtime) |
|
153 except OSError: |
|
154 filemtime1 = "" |
|
155 try: |
|
156 with open(self.filename1, "r", encoding="utf-8") as f1: |
|
157 lines1 = f1.readlines() |
|
158 except OSError: |
|
159 E5MessageBox.critical( |
|
160 self, |
|
161 self.tr("Compare Files"), |
|
162 self.tr( |
|
163 """<p>The file <b>{0}</b> could not be read.</p>""") |
|
164 .format(self.filename1)) |
|
165 return |
|
166 |
|
167 self.filename2 = Utilities.toNativeSeparators(self.file2Picker.text()) |
|
168 try: |
|
169 filemtime2 = time.ctime(os.stat(self.filename2).st_mtime) |
|
170 except OSError: |
|
171 filemtime2 = "" |
|
172 try: |
|
173 with open(self.filename2, "r", encoding="utf-8") as f2: |
|
174 lines2 = f2.readlines() |
|
175 except OSError: |
|
176 E5MessageBox.critical( |
|
177 self, |
|
178 self.tr("Compare Files"), |
|
179 self.tr( |
|
180 """<p>The file <b>{0}</b> could not be read.</p>""") |
|
181 .format(self.filename2)) |
|
182 return |
|
183 |
|
184 self.contents.clear() |
|
185 self.highlighter.regenerateRules() |
|
186 self.saveButton.setEnabled(False) |
|
187 |
|
188 if self.unifiedRadioButton.isChecked(): |
|
189 self.__generateUnifiedDiff( |
|
190 lines1, lines2, self.filename1, self.filename2, |
|
191 filemtime1, filemtime2) |
|
192 else: |
|
193 self.__generateContextDiff( |
|
194 lines1, lines2, self.filename1, self.filename2, |
|
195 filemtime1, filemtime2) |
|
196 |
|
197 tc = self.contents.textCursor() |
|
198 tc.movePosition(QTextCursor.MoveOperation.Start) |
|
199 self.contents.setTextCursor(tc) |
|
200 self.contents.ensureCursorVisible() |
|
201 |
|
202 self.saveButton.setEnabled(True) |
|
203 |
|
204 def __appendText(self, txt): |
|
205 """ |
|
206 Private method to append text to the end of the contents pane. |
|
207 |
|
208 @param txt text to insert (string) |
|
209 """ |
|
210 tc = self.contents.textCursor() |
|
211 tc.movePosition(QTextCursor.MoveOperation.End) |
|
212 self.contents.setTextCursor(tc) |
|
213 self.contents.insertPlainText(txt) |
|
214 |
|
215 def __generateUnifiedDiff(self, a, b, fromfile, tofile, |
|
216 fromfiledate, tofiledate): |
|
217 """ |
|
218 Private slot to generate a unified diff output. |
|
219 |
|
220 @param a first sequence of lines (list of strings) |
|
221 @param b second sequence of lines (list of strings) |
|
222 @param fromfile filename of the first file (string) |
|
223 @param tofile filename of the second file (string) |
|
224 @param fromfiledate modification time of the first file (string) |
|
225 @param tofiledate modification time of the second file (string) |
|
226 """ |
|
227 for paras, line in enumerate( |
|
228 unified_diff(a, b, fromfile, tofile, fromfiledate, tofiledate) |
|
229 ): |
|
230 self.__appendText(line) |
|
231 if not (paras % self.updateInterval): |
|
232 QApplication.processEvents() |
|
233 |
|
234 if self.contents.toPlainText().strip() == "": |
|
235 self.__appendText(self.tr('There is no difference.')) |
|
236 |
|
237 def __generateContextDiff(self, a, b, fromfile, tofile, |
|
238 fromfiledate, tofiledate): |
|
239 """ |
|
240 Private slot to generate a context diff output. |
|
241 |
|
242 @param a first sequence of lines (list of strings) |
|
243 @param b second sequence of lines (list of strings) |
|
244 @param fromfile filename of the first file (string) |
|
245 @param tofile filename of the second file (string) |
|
246 @param fromfiledate modification time of the first file (string) |
|
247 @param tofiledate modification time of the second file (string) |
|
248 """ |
|
249 for paras, line in enumerate( |
|
250 context_diff(a, b, fromfile, tofile, fromfiledate, tofiledate) |
|
251 ): |
|
252 self.__appendText(line) |
|
253 if not (paras % self.updateInterval): |
|
254 QApplication.processEvents() |
|
255 |
|
256 if self.contents.toPlainText().strip() == "": |
|
257 self.__appendText(self.tr('There is no difference.')) |
|
258 |
|
259 def __fileChanged(self): |
|
260 """ |
|
261 Private slot to enable/disable the Compare button. |
|
262 """ |
|
263 if ( |
|
264 not self.file1Picker.text() or |
|
265 not self.file2Picker.text() |
|
266 ): |
|
267 self.diffButton.setEnabled(False) |
|
268 else: |
|
269 self.diffButton.setEnabled(True) |
|
270 |
|
271 |
|
272 class DiffWindow(E5MainWindow): |
|
273 """ |
|
274 Main window class for the standalone dialog. |
|
275 """ |
|
276 def __init__(self, parent=None): |
|
277 """ |
|
278 Constructor |
|
279 |
|
280 @param parent reference to the parent widget (QWidget) |
|
281 """ |
|
282 super().__init__(parent) |
|
283 |
|
284 self.setStyle(Preferences.getUI("Style"), |
|
285 Preferences.getUI("StyleSheet")) |
|
286 |
|
287 self.cw = DiffDialog(self) |
|
288 self.cw.installEventFilter(self) |
|
289 size = self.cw.size() |
|
290 self.setCentralWidget(self.cw) |
|
291 self.resize(size) |
|
292 |
|
293 def eventFilter(self, obj, event): |
|
294 """ |
|
295 Public method to filter events. |
|
296 |
|
297 @param obj reference to the object the event is meant for (QObject) |
|
298 @param event reference to the event object (QEvent) |
|
299 @return flag indicating, whether the event was handled (boolean) |
|
300 """ |
|
301 if event.type() == QEvent.Type.Close: |
|
302 QApplication.exit() |
|
303 return True |
|
304 |
|
305 return False |