|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2014 - 2021 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing a dialog to browse the log history. |
|
8 """ |
|
9 |
|
10 import os |
|
11 import collections |
|
12 import re |
|
13 import contextlib |
|
14 |
|
15 from PyQt5.QtCore import ( |
|
16 pyqtSlot, Qt, QDate, QProcess, QTimer, QSize, QPoint, QFileInfo |
|
17 ) |
|
18 from PyQt5.QtGui import ( |
|
19 QColor, QPixmap, QPainter, QPen, QIcon, QTextCursor, QPalette |
|
20 ) |
|
21 from PyQt5.QtWidgets import ( |
|
22 QWidget, QDialogButtonBox, QHeaderView, QTreeWidgetItem, QApplication, |
|
23 QLineEdit, QMenu, QInputDialog |
|
24 ) |
|
25 |
|
26 from E5Gui.E5Application import e5App |
|
27 from E5Gui import E5MessageBox, E5FileDialog |
|
28 from E5Gui.E5OverrideCursor import E5OverrideCursorProcess |
|
29 |
|
30 from Globals import strToQByteArray |
|
31 |
|
32 from .Ui_GitLogBrowserDialog import Ui_GitLogBrowserDialog |
|
33 |
|
34 from .GitDiffHighlighter import GitDiffHighlighter |
|
35 from .GitDiffGenerator import GitDiffGenerator |
|
36 |
|
37 import UI.PixmapCache |
|
38 import Preferences |
|
39 import Utilities |
|
40 |
|
41 COLORNAMES = ["red", "green", "purple", "cyan", "olive", "magenta", |
|
42 "gray", "yellow", "darkred", "darkgreen", "darkblue", |
|
43 "darkcyan", "darkmagenta", "blue"] |
|
44 COLORS = [str(QColor(x).name()) for x in COLORNAMES] |
|
45 |
|
46 LIGHTCOLORS = ["#aaaaff", "#7faa7f", "#ffaaaa", "#aaffaa", "#7f7faa", |
|
47 "#ffaaff", "#aaffff", "#d5d579", "#ffaaff", "#d57979", |
|
48 "#d579d5", "#79d5d5", "#d5d5d5", "#d5d500", |
|
49 ] |
|
50 |
|
51 |
|
52 class GitLogBrowserDialog(QWidget, Ui_GitLogBrowserDialog): |
|
53 """ |
|
54 Class implementing a dialog to browse the log history. |
|
55 """ |
|
56 IconColumn = 0 |
|
57 CommitIdColumn = 1 |
|
58 AuthorColumn = 2 |
|
59 DateColumn = 3 |
|
60 CommitterColumn = 4 |
|
61 CommitDateColumn = 5 |
|
62 SubjectColumn = 6 |
|
63 BranchColumn = 7 |
|
64 TagsColumn = 8 |
|
65 |
|
66 def __init__(self, vcs, parent=None): |
|
67 """ |
|
68 Constructor |
|
69 |
|
70 @param vcs reference to the vcs object |
|
71 @param parent parent widget (QWidget) |
|
72 """ |
|
73 super().__init__(parent) |
|
74 self.setupUi(self) |
|
75 |
|
76 windowFlags = self.windowFlags() |
|
77 windowFlags |= Qt.WindowType.WindowContextHelpButtonHint |
|
78 self.setWindowFlags(windowFlags) |
|
79 |
|
80 self.mainSplitter.setSizes([300, 400]) |
|
81 self.mainSplitter.setStretchFactor(0, 1) |
|
82 self.mainSplitter.setStretchFactor(1, 2) |
|
83 self.diffSplitter.setStretchFactor(0, 1) |
|
84 self.diffSplitter.setStretchFactor(1, 2) |
|
85 |
|
86 self.buttonBox.button( |
|
87 QDialogButtonBox.StandardButton.Close).setEnabled(False) |
|
88 self.buttonBox.button( |
|
89 QDialogButtonBox.StandardButton.Cancel).setDefault(True) |
|
90 |
|
91 self.filesTree.headerItem().setText(self.filesTree.columnCount(), "") |
|
92 self.filesTree.header().setSortIndicator( |
|
93 1, Qt.SortOrder.AscendingOrder) |
|
94 |
|
95 self.upButton.setIcon(UI.PixmapCache.getIcon("1uparrow")) |
|
96 self.downButton.setIcon(UI.PixmapCache.getIcon("1downarrow")) |
|
97 |
|
98 self.refreshButton = self.buttonBox.addButton( |
|
99 self.tr("&Refresh"), QDialogButtonBox.ButtonRole.ActionRole) |
|
100 self.refreshButton.setToolTip( |
|
101 self.tr("Press to refresh the list of commits")) |
|
102 self.refreshButton.setEnabled(False) |
|
103 |
|
104 self.findPrevButton.setIcon(UI.PixmapCache.getIcon("1leftarrow")) |
|
105 self.findNextButton.setIcon(UI.PixmapCache.getIcon("1rightarrow")) |
|
106 self.__findBackwards = False |
|
107 |
|
108 self.modeComboBox.addItem(self.tr("Find"), "find") |
|
109 self.modeComboBox.addItem(self.tr("Filter"), "filter") |
|
110 |
|
111 self.fieldCombo.addItem(self.tr("Commit ID"), "commitId") |
|
112 self.fieldCombo.addItem(self.tr("Author"), "author") |
|
113 self.fieldCombo.addItem(self.tr("Committer"), "committer") |
|
114 self.fieldCombo.addItem(self.tr("Subject"), "subject") |
|
115 self.fieldCombo.addItem(self.tr("File"), "file") |
|
116 |
|
117 self.__logTreeNormalFont = self.logTree.font() |
|
118 self.__logTreeNormalFont.setBold(False) |
|
119 self.__logTreeBoldFont = self.logTree.font() |
|
120 self.__logTreeBoldFont.setBold(True) |
|
121 self.__logTreeHasDarkBackground = e5App().usesDarkPalette() |
|
122 |
|
123 font = Preferences.getEditorOtherFonts("MonospacedFont") |
|
124 self.diffEdit.document().setDefaultFont(font) |
|
125 |
|
126 self.diffHighlighter = GitDiffHighlighter(self.diffEdit.document()) |
|
127 self.__diffGenerator = GitDiffGenerator(vcs, self) |
|
128 self.__diffGenerator.finished.connect(self.__generatorFinished) |
|
129 |
|
130 self.vcs = vcs |
|
131 |
|
132 self.__detailsTemplate = self.tr( |
|
133 "<table>" |
|
134 "<tr><td><b>Commit ID</b></td><td>{0}</td></tr>" |
|
135 "<tr><td><b>Date</b></td><td>{1}</td></tr>" |
|
136 "<tr><td><b>Author</b></td><td>{2} <{3}></td></tr>" |
|
137 "<tr><td><b>Commit Date</b></td><td>{4}</td></tr>" |
|
138 "<tr><td><b>Committer</b></td><td>{5} <{6}></td></tr>" |
|
139 "{7}" |
|
140 "<tr><td><b>Subject</b></td><td>{8}</td></tr>" |
|
141 "{9}" |
|
142 "</table>" |
|
143 ) |
|
144 self.__parentsTemplate = self.tr( |
|
145 "<tr><td><b>Parents</b></td><td>{0}</td></tr>" |
|
146 ) |
|
147 self.__childrenTemplate = self.tr( |
|
148 "<tr><td><b>Children</b></td><td>{0}</td></tr>" |
|
149 ) |
|
150 self.__branchesTemplate = self.tr( |
|
151 "<tr><td><b>Branches</b></td><td>{0}</td></tr>" |
|
152 ) |
|
153 self.__tagsTemplate = self.tr( |
|
154 "<tr><td><b>Tags</b></td><td>{0}</td></tr>" |
|
155 ) |
|
156 self.__mesageTemplate = self.tr( |
|
157 "<tr><td><b>Message</b></td><td>{0}</td></tr>" |
|
158 ) |
|
159 |
|
160 self.__formatTemplate = ( |
|
161 'format:recordstart%n' |
|
162 'commit|%h%n' |
|
163 'parents|%p%n' |
|
164 'author|%an%n' |
|
165 'authormail|%ae%n' |
|
166 'authordate|%ai%n' |
|
167 'committer|%cn%n' |
|
168 'committermail|%ce%n' |
|
169 'committerdate|%ci%n' |
|
170 'refnames|%d%n' |
|
171 'subject|%s%n' |
|
172 'bodystart%n' |
|
173 '%b%n' |
|
174 'bodyend%n' |
|
175 ) |
|
176 |
|
177 self.__filename = "" |
|
178 self.__isFile = False |
|
179 self.__selectedCommitIDs = [] |
|
180 self.intercept = False |
|
181 |
|
182 self.__initData() |
|
183 |
|
184 self.fromDate.setDisplayFormat("yyyy-MM-dd") |
|
185 self.toDate.setDisplayFormat("yyyy-MM-dd") |
|
186 self.__resetUI() |
|
187 |
|
188 # roles used in the log tree |
|
189 self.__subjectRole = Qt.ItemDataRole.UserRole |
|
190 self.__messageRole = Qt.ItemDataRole.UserRole + 1 |
|
191 self.__changesRole = Qt.ItemDataRole.UserRole + 2 |
|
192 self.__edgesRole = Qt.ItemDataRole.UserRole + 3 |
|
193 self.__parentsRole = Qt.ItemDataRole.UserRole + 4 |
|
194 self.__branchesRole = Qt.ItemDataRole.UserRole + 5 |
|
195 self.__authorMailRole = Qt.ItemDataRole.UserRole + 6 |
|
196 self.__committerMailRole = Qt.ItemDataRole.UserRole + 7 |
|
197 |
|
198 # roles used in the file tree |
|
199 self.__diffFileLineRole = Qt.ItemDataRole.UserRole |
|
200 |
|
201 self.__process = E5OverrideCursorProcess() |
|
202 self.__process.finished.connect(self.__procFinished) |
|
203 self.__process.readyReadStandardOutput.connect(self.__readStdout) |
|
204 self.__process.readyReadStandardError.connect(self.__readStderr) |
|
205 |
|
206 self.flags = { |
|
207 'A': self.tr('Added'), |
|
208 'D': self.tr('Deleted'), |
|
209 'M': self.tr('Modified'), |
|
210 'C': self.tr('Copied'), |
|
211 'R': self.tr('Renamed'), |
|
212 'T': self.tr('Type changed'), |
|
213 'U': self.tr('Unmerged'), |
|
214 'X': self.tr('Unknown'), |
|
215 } |
|
216 |
|
217 self.__dotRadius = 8 |
|
218 self.__rowHeight = 20 |
|
219 |
|
220 self.logTree.setIconSize( |
|
221 QSize(100 * self.__rowHeight, self.__rowHeight)) |
|
222 |
|
223 self.detailsEdit.anchorClicked.connect(self.__commitIdClicked) |
|
224 |
|
225 self.__initLogTreeContextMenu() |
|
226 self.__initActionsMenu() |
|
227 |
|
228 self.__finishCallbacks = [] |
|
229 |
|
230 def __addFinishCallback(self, callback): |
|
231 """ |
|
232 Private method to add a method to be called once the process finished. |
|
233 |
|
234 The callback methods are invoke in a FIFO style and are consumed. If |
|
235 a callback method needs to be called again, it must be added again. |
|
236 |
|
237 @param callback callback method |
|
238 @type function |
|
239 """ |
|
240 if callback not in self.__finishCallbacks: |
|
241 self.__finishCallbacks.append(callback) |
|
242 |
|
243 def __initLogTreeContextMenu(self): |
|
244 """ |
|
245 Private method to initialize the log tree context menu. |
|
246 """ |
|
247 self.__logTreeMenu = QMenu() |
|
248 |
|
249 # commit ID column |
|
250 act = self.__logTreeMenu.addAction( |
|
251 self.tr("Show Commit ID Column")) |
|
252 act.setToolTip(self.tr( |
|
253 "Press to show the commit ID column")) |
|
254 act.setCheckable(True) |
|
255 act.setChecked(self.vcs.getPlugin().getPreferences( |
|
256 "ShowCommitIdColumn")) |
|
257 act.triggered.connect(self.__showCommitIdColumn) |
|
258 |
|
259 # author and date columns |
|
260 act = self.__logTreeMenu.addAction( |
|
261 self.tr("Show Author Columns")) |
|
262 act.setToolTip(self.tr( |
|
263 "Press to show the author columns")) |
|
264 act.setCheckable(True) |
|
265 act.setChecked(self.vcs.getPlugin().getPreferences( |
|
266 "ShowAuthorColumns")) |
|
267 act.triggered.connect(self.__showAuthorColumns) |
|
268 |
|
269 # committer and commit date columns |
|
270 act = self.__logTreeMenu.addAction( |
|
271 self.tr("Show Committer Columns")) |
|
272 act.setToolTip(self.tr( |
|
273 "Press to show the committer columns")) |
|
274 act.setCheckable(True) |
|
275 act.setChecked(self.vcs.getPlugin().getPreferences( |
|
276 "ShowCommitterColumns")) |
|
277 act.triggered.connect(self.__showCommitterColumns) |
|
278 |
|
279 # branches column |
|
280 act = self.__logTreeMenu.addAction( |
|
281 self.tr("Show Branches Column")) |
|
282 act.setToolTip(self.tr( |
|
283 "Press to show the branches column")) |
|
284 act.setCheckable(True) |
|
285 act.setChecked(self.vcs.getPlugin().getPreferences( |
|
286 "ShowBranchesColumn")) |
|
287 act.triggered.connect(self.__showBranchesColumn) |
|
288 |
|
289 # tags column |
|
290 act = self.__logTreeMenu.addAction( |
|
291 self.tr("Show Tags Column")) |
|
292 act.setToolTip(self.tr( |
|
293 "Press to show the Tags column")) |
|
294 act.setCheckable(True) |
|
295 act.setChecked(self.vcs.getPlugin().getPreferences( |
|
296 "ShowTagsColumn")) |
|
297 act.triggered.connect(self.__showTagsColumn) |
|
298 |
|
299 # set column visibility as configured |
|
300 self.__showCommitIdColumn(self.vcs.getPlugin().getPreferences( |
|
301 "ShowCommitIdColumn")) |
|
302 self.__showAuthorColumns(self.vcs.getPlugin().getPreferences( |
|
303 "ShowAuthorColumns")) |
|
304 self.__showCommitterColumns(self.vcs.getPlugin().getPreferences( |
|
305 "ShowCommitterColumns")) |
|
306 self.__showBranchesColumn(self.vcs.getPlugin().getPreferences( |
|
307 "ShowBranchesColumn")) |
|
308 self.__showTagsColumn(self.vcs.getPlugin().getPreferences( |
|
309 "ShowTagsColumn")) |
|
310 |
|
311 def __initActionsMenu(self): |
|
312 """ |
|
313 Private method to initialize the actions menu. |
|
314 """ |
|
315 self.__actionsMenu = QMenu() |
|
316 self.__actionsMenu.setTearOffEnabled(True) |
|
317 self.__actionsMenu.setToolTipsVisible(True) |
|
318 |
|
319 self.__cherryAct = self.__actionsMenu.addAction( |
|
320 self.tr("Copy Commits"), self.__cherryActTriggered) |
|
321 self.__cherryAct.setToolTip(self.tr( |
|
322 "Cherry-pick the selected commits to the current branch")) |
|
323 |
|
324 self.__actionsMenu.addSeparator() |
|
325 |
|
326 self.__tagAct = self.__actionsMenu.addAction( |
|
327 self.tr("Tag"), self.__tagActTriggered) |
|
328 self.__tagAct.setToolTip(self.tr("Tag the selected commit")) |
|
329 |
|
330 self.__branchAct = self.__actionsMenu.addAction( |
|
331 self.tr("Branch"), self.__branchActTriggered) |
|
332 self.__branchAct.setToolTip(self.tr( |
|
333 "Create a new branch at the selected commit.")) |
|
334 self.__branchSwitchAct = self.__actionsMenu.addAction( |
|
335 self.tr("Branch && Switch"), self.__branchSwitchActTriggered) |
|
336 self.__branchSwitchAct.setToolTip(self.tr( |
|
337 "Create a new branch at the selected commit and switch" |
|
338 " the work tree to it.")) |
|
339 |
|
340 self.__switchAct = self.__actionsMenu.addAction( |
|
341 self.tr("Switch"), self.__switchActTriggered) |
|
342 self.__switchAct.setToolTip(self.tr( |
|
343 "Switch the working directory to the selected commit")) |
|
344 self.__actionsMenu.addSeparator() |
|
345 |
|
346 self.__shortlogAct = self.__actionsMenu.addAction( |
|
347 self.tr("Show Short Log"), self.__shortlogActTriggered) |
|
348 self.__shortlogAct.setToolTip(self.tr( |
|
349 "Show a dialog with a log output for release notes")) |
|
350 |
|
351 self.__describeAct = self.__actionsMenu.addAction( |
|
352 self.tr("Describe"), self.__describeActTriggered) |
|
353 self.__describeAct.setToolTip(self.tr( |
|
354 "Show the most recent tag reachable from a commit")) |
|
355 |
|
356 self.actionsButton.setIcon( |
|
357 UI.PixmapCache.getIcon("actionsToolButton")) |
|
358 self.actionsButton.setMenu(self.__actionsMenu) |
|
359 |
|
360 def __initData(self): |
|
361 """ |
|
362 Private method to (re-)initialize some data. |
|
363 """ |
|
364 self.__maxDate = QDate() |
|
365 self.__minDate = QDate() |
|
366 self.__filterLogsEnabled = True |
|
367 |
|
368 self.buf = [] # buffer for stdout |
|
369 self.diff = None |
|
370 self.__started = False |
|
371 self.__skipEntries = 0 |
|
372 self.projectMode = False |
|
373 |
|
374 # attributes to store log graph data |
|
375 self.__commitIds = [] |
|
376 self.__commitColors = {} |
|
377 self.__commitColor = 0 |
|
378 |
|
379 self.__projectRevision = "" |
|
380 |
|
381 self.__childrenInfo = collections.defaultdict(list) |
|
382 |
|
383 def closeEvent(self, e): |
|
384 """ |
|
385 Protected slot implementing a close event handler. |
|
386 |
|
387 @param e close event (QCloseEvent) |
|
388 """ |
|
389 if ( |
|
390 self.__process is not None and |
|
391 self.__process.state() != QProcess.ProcessState.NotRunning |
|
392 ): |
|
393 self.__process.terminate() |
|
394 QTimer.singleShot(2000, self.__process.kill) |
|
395 self.__process.waitForFinished(3000) |
|
396 |
|
397 self.vcs.getPlugin().setPreferences( |
|
398 "LogBrowserGeometry", self.saveGeometry()) |
|
399 self.vcs.getPlugin().setPreferences( |
|
400 "LogBrowserSplitterStates", [ |
|
401 self.mainSplitter.saveState(), |
|
402 self.detailsSplitter.saveState(), |
|
403 self.diffSplitter.saveState(), |
|
404 ] |
|
405 ) |
|
406 |
|
407 e.accept() |
|
408 |
|
409 def show(self): |
|
410 """ |
|
411 Public slot to show the dialog. |
|
412 """ |
|
413 self.__reloadGeometry() |
|
414 self.__restoreSplitterStates() |
|
415 self.__resetUI() |
|
416 |
|
417 super().show() |
|
418 |
|
419 def __reloadGeometry(self): |
|
420 """ |
|
421 Private method to restore the geometry. |
|
422 """ |
|
423 geom = self.vcs.getPlugin().getPreferences("LogBrowserGeometry") |
|
424 if geom.isEmpty(): |
|
425 s = QSize(1000, 800) |
|
426 self.resize(s) |
|
427 else: |
|
428 self.restoreGeometry(geom) |
|
429 |
|
430 def __restoreSplitterStates(self): |
|
431 """ |
|
432 Private method to restore the state of the various splitters. |
|
433 """ |
|
434 states = self.vcs.getPlugin().getPreferences( |
|
435 "LogBrowserSplitterStates") |
|
436 if len(states) == 3: |
|
437 # we have three splitters |
|
438 self.mainSplitter.restoreState(states[0]) |
|
439 self.detailsSplitter.restoreState(states[1]) |
|
440 self.diffSplitter.restoreState(states[2]) |
|
441 |
|
442 def __resetUI(self): |
|
443 """ |
|
444 Private method to reset the user interface. |
|
445 """ |
|
446 self.fromDate.setDate(QDate.currentDate()) |
|
447 self.toDate.setDate(QDate.currentDate()) |
|
448 self.fieldCombo.setCurrentIndex(self.fieldCombo.findData("subject")) |
|
449 self.limitSpinBox.setValue(self.vcs.getPlugin().getPreferences( |
|
450 "LogLimit")) |
|
451 self.stopCheckBox.setChecked(self.vcs.getPlugin().getPreferences( |
|
452 "StopLogOnCopy")) |
|
453 |
|
454 self.logTree.clear() |
|
455 |
|
456 def __resizeColumnsLog(self): |
|
457 """ |
|
458 Private method to resize the log tree columns. |
|
459 """ |
|
460 self.logTree.header().resizeSections( |
|
461 QHeaderView.ResizeMode.ResizeToContents) |
|
462 self.logTree.header().setStretchLastSection(True) |
|
463 |
|
464 def __resizeColumnsFiles(self): |
|
465 """ |
|
466 Private method to resize the changed files tree columns. |
|
467 """ |
|
468 self.filesTree.header().resizeSections( |
|
469 QHeaderView.ResizeMode.ResizeToContents) |
|
470 self.filesTree.header().setStretchLastSection(True) |
|
471 |
|
472 def __resortFiles(self): |
|
473 """ |
|
474 Private method to resort the changed files tree. |
|
475 """ |
|
476 self.filesTree.setSortingEnabled(True) |
|
477 self.filesTree.sortItems(1, Qt.SortOrder.AscendingOrder) |
|
478 self.filesTree.setSortingEnabled(False) |
|
479 |
|
480 def __getColor(self, n): |
|
481 """ |
|
482 Private method to get the (rotating) name of the color given an index. |
|
483 |
|
484 @param n color index |
|
485 @type int |
|
486 @return color name |
|
487 @rtype str |
|
488 """ |
|
489 if self.__logTreeHasDarkBackground: |
|
490 return LIGHTCOLORS[n % len(LIGHTCOLORS)] |
|
491 else: |
|
492 return COLORS[n % len(COLORS)] |
|
493 |
|
494 def __generateEdges(self, commitId, parents): |
|
495 """ |
|
496 Private method to generate edge info for the give data. |
|
497 |
|
498 @param commitId commit id to calculate edge info for (string) |
|
499 @param parents list of parent commits (list of strings) |
|
500 @return tuple containing the column and color index for |
|
501 the given node and a list of tuples indicating the edges |
|
502 between the given node and its parents |
|
503 (integer, integer, [(integer, integer, integer), ...]) |
|
504 """ |
|
505 if commitId not in self.__commitIds: |
|
506 # new head |
|
507 self.__commitIds.append(commitId) |
|
508 self.__commitColors[commitId] = self.__commitColor |
|
509 self.__commitColor += 1 |
|
510 |
|
511 col = self.__commitIds.index(commitId) |
|
512 color = self.__commitColors.pop(commitId) |
|
513 nextCommitIds = self.__commitIds[:] |
|
514 |
|
515 # add parents to next |
|
516 addparents = [p for p in parents if p not in nextCommitIds] |
|
517 nextCommitIds[col:col + 1] = addparents |
|
518 |
|
519 # set colors for the parents |
|
520 for i, p in enumerate(addparents): |
|
521 if not i: |
|
522 self.__commitColors[p] = color |
|
523 else: |
|
524 self.__commitColors[p] = self.__commitColor |
|
525 self.__commitColor += 1 |
|
526 |
|
527 # add edges to the graph |
|
528 edges = [] |
|
529 if parents: |
|
530 for ecol, ecommitId in enumerate(self.__commitIds): |
|
531 if ecommitId in nextCommitIds: |
|
532 edges.append( |
|
533 (ecol, nextCommitIds.index(ecommitId), |
|
534 self.__commitColors[ecommitId])) |
|
535 elif ecommitId == commitId: |
|
536 for p in parents: |
|
537 edges.append( |
|
538 (ecol, nextCommitIds.index(p), |
|
539 self.__commitColors[p])) |
|
540 |
|
541 self.__commitIds = nextCommitIds |
|
542 return col, color, edges |
|
543 |
|
544 def __generateIcon(self, column, color, bottomedges, topedges, dotColor, |
|
545 currentCommit): |
|
546 """ |
|
547 Private method to generate an icon containing the revision tree for the |
|
548 given data. |
|
549 |
|
550 @param column column index of the revision (integer) |
|
551 @param color color of the node (integer) |
|
552 @param bottomedges list of edges for the bottom of the node |
|
553 (list of tuples of three integers) |
|
554 @param topedges list of edges for the top of the node |
|
555 (list of tuples of three integers) |
|
556 @param dotColor color to be used for the dot (QColor) |
|
557 @param currentCommit flag indicating to draw the icon for the |
|
558 current commit (boolean) |
|
559 @return icon for the node (QIcon) |
|
560 """ |
|
561 def col2x(col, radius): |
|
562 """ |
|
563 Local function to calculate a x-position for a column. |
|
564 |
|
565 @param col column number (integer) |
|
566 @param radius radius of the indicator circle (integer) |
|
567 """ |
|
568 return int(1.2 * radius) * col + radius // 2 + 3 |
|
569 |
|
570 radius = self.__dotRadius |
|
571 w = len(bottomedges) * radius + 20 |
|
572 h = self.__rowHeight |
|
573 |
|
574 dot_x = col2x(column, radius) - radius // 2 |
|
575 dot_y = h // 2 |
|
576 |
|
577 pix = QPixmap(w, h) |
|
578 pix.fill(QColor(0, 0, 0, 0)) # draw transparent background |
|
579 painter = QPainter(pix) |
|
580 painter.setRenderHint(QPainter.RenderHint.Antialiasing) |
|
581 |
|
582 # draw the revision history lines |
|
583 for y1, y2, lines in ((0, h, bottomedges), |
|
584 (-h, 0, topedges)): |
|
585 if lines: |
|
586 for start, end, ecolor in lines: |
|
587 lpen = QPen(QColor(self.__getColor(ecolor))) |
|
588 lpen.setWidth(2) |
|
589 painter.setPen(lpen) |
|
590 x1 = col2x(start, radius) |
|
591 x2 = col2x(end, radius) |
|
592 painter.drawLine(x1, dot_y + y1, x2, dot_y + y2) |
|
593 |
|
594 penradius = 1 |
|
595 pencolor = self.logTree.palette().color(QPalette.ColorRole.Text) |
|
596 |
|
597 dot_y = (h // 2) - radius // 2 |
|
598 |
|
599 # draw a dot for the revision |
|
600 if currentCommit: |
|
601 # enlarge dot for the current revision |
|
602 delta = 2 |
|
603 radius += 2 * delta |
|
604 dot_y -= delta |
|
605 dot_x -= delta |
|
606 painter.setBrush(dotColor) |
|
607 pen = QPen(pencolor) |
|
608 pen.setWidth(penradius) |
|
609 painter.setPen(pen) |
|
610 painter.drawEllipse(dot_x, dot_y, radius, radius) |
|
611 painter.end() |
|
612 return QIcon(pix) |
|
613 |
|
614 def __identifyProject(self): |
|
615 """ |
|
616 Private method to determine the revision of the project directory. |
|
617 """ |
|
618 errMsg = "" |
|
619 |
|
620 args = self.vcs.initCommand("show") |
|
621 args.append("--abbrev={0}".format( |
|
622 self.vcs.getPlugin().getPreferences("CommitIdLength"))) |
|
623 args.append("--format=%h") |
|
624 args.append("--no-patch") |
|
625 args.append("HEAD") |
|
626 |
|
627 output = "" |
|
628 process = QProcess() |
|
629 process.setWorkingDirectory(self.repodir) |
|
630 process.start('git', args) |
|
631 procStarted = process.waitForStarted(5000) |
|
632 if procStarted: |
|
633 finished = process.waitForFinished(30000) |
|
634 if finished and process.exitCode() == 0: |
|
635 output = str(process.readAllStandardOutput(), |
|
636 Preferences.getSystem("IOEncoding"), |
|
637 'replace') |
|
638 else: |
|
639 if not finished: |
|
640 errMsg = self.tr( |
|
641 "The git process did not finish within 30s.") |
|
642 else: |
|
643 errMsg = self.tr("Could not start the git executable.") |
|
644 |
|
645 if errMsg: |
|
646 E5MessageBox.critical( |
|
647 self, |
|
648 self.tr("Git Error"), |
|
649 errMsg) |
|
650 |
|
651 if output: |
|
652 self.__projectRevision = output.strip() |
|
653 |
|
654 def __generateLogItem(self, author, date, committer, commitDate, subject, |
|
655 message, commitId, changedPaths, parents, refnames, |
|
656 authorMail, committerMail): |
|
657 """ |
|
658 Private method to generate a log tree entry. |
|
659 |
|
660 @param author author info (string) |
|
661 @param date date info (string) |
|
662 @param committer committer info (string) |
|
663 @param commitDate commit date info (string) |
|
664 @param subject subject of the log entry (string) |
|
665 @param message text of the log message (list of strings) |
|
666 @param commitId commit id info (string) |
|
667 @param changedPaths list of dictionary objects containing |
|
668 info about the changed files/directories |
|
669 @param parents list of parent revisions (list of integers) |
|
670 @param refnames tags and branches of the commit (string) |
|
671 @param authorMail author's email address (string) |
|
672 @param committerMail committer's email address (string) |
|
673 @return reference to the generated item (QTreeWidgetItem) |
|
674 """ |
|
675 branches = [] |
|
676 allBranches = [] |
|
677 tags = [] |
|
678 names = refnames.strip()[1:-1].split(",") |
|
679 for name in names: |
|
680 name = name.strip() |
|
681 if name: |
|
682 if "HEAD" in name: |
|
683 tags.append(name) |
|
684 elif name.startswith("tag: "): |
|
685 tags.append(name.split()[1]) |
|
686 else: |
|
687 if "/" not in name: |
|
688 branches.append(name) |
|
689 elif "refs/bisect/" in name: |
|
690 bname = name.replace("refs/", "").split("-", 1)[0] |
|
691 branches.append(bname) |
|
692 else: |
|
693 branches.append(name) |
|
694 allBranches.append(name) |
|
695 |
|
696 logMessageColumnWidth = self.vcs.getPlugin().getPreferences( |
|
697 "LogSubjectColumnWidth") |
|
698 msgtxt = subject |
|
699 if logMessageColumnWidth and len(msgtxt) > logMessageColumnWidth: |
|
700 msgtxt = "{0}...".format(msgtxt[:logMessageColumnWidth]) |
|
701 columnLabels = [ |
|
702 "", |
|
703 commitId, |
|
704 author, |
|
705 date.rsplit(None, 1)[0].rsplit(":", 1)[0], |
|
706 committer, |
|
707 commitDate.rsplit(None, 1)[0].rsplit(":", 1)[0], |
|
708 msgtxt, |
|
709 ", ".join(branches), |
|
710 ", ".join(tags), |
|
711 ] |
|
712 itm = QTreeWidgetItem(self.logTree, columnLabels) |
|
713 |
|
714 parents = [p.strip() for p in parents.split()] |
|
715 column, color, edges = self.__generateEdges(commitId, parents) |
|
716 |
|
717 itm.setData(0, self.__subjectRole, subject) |
|
718 itm.setData(0, self.__messageRole, message) |
|
719 itm.setData(0, self.__changesRole, changedPaths) |
|
720 itm.setData(0, self.__edgesRole, edges) |
|
721 itm.setData(0, self.__branchesRole, allBranches) |
|
722 itm.setData(0, self.__authorMailRole, authorMail) |
|
723 itm.setData(0, self.__committerMailRole, committerMail) |
|
724 if not parents: |
|
725 itm.setData(0, self.__parentsRole, []) |
|
726 else: |
|
727 itm.setData(0, self.__parentsRole, parents) |
|
728 for parent in parents: |
|
729 self.__childrenInfo[parent].append(commitId) |
|
730 |
|
731 topedges = ( |
|
732 self.logTree.topLevelItem( |
|
733 self.logTree.indexOfTopLevelItem(itm) - 1 |
|
734 ).data(0, self.__edgesRole) |
|
735 if self.logTree.topLevelItemCount() > 1 else |
|
736 None |
|
737 ) |
|
738 |
|
739 icon = self.__generateIcon(column, color, edges, topedges, |
|
740 QColor("blue"), |
|
741 commitId == self.__projectRevision) |
|
742 itm.setIcon(0, icon) |
|
743 |
|
744 return itm |
|
745 |
|
746 def __generateFileItem(self, action, path, copyfrom, additions, deletions): |
|
747 """ |
|
748 Private method to generate a changed files tree entry. |
|
749 |
|
750 @param action indicator for the change action ("A", "C", "D", "M", |
|
751 "R", "T", "U", "X") |
|
752 @param path path of the file in the repository (string) |
|
753 @param copyfrom path the file was copied from (string) |
|
754 @param additions number of added lines (int) |
|
755 @param deletions number of deleted lines (int) |
|
756 @return reference to the generated item (QTreeWidgetItem) |
|
757 """ |
|
758 if len(action) > 1: |
|
759 # includes confidence level |
|
760 confidence = int(action[1:]) |
|
761 actionTxt = self.tr("{0} ({1}%)", "action, confidence").format( |
|
762 self.flags[action[0]], confidence) |
|
763 else: |
|
764 actionTxt = self.flags[action] |
|
765 itm = QTreeWidgetItem(self.filesTree, [ |
|
766 actionTxt, |
|
767 path, |
|
768 str(additions), |
|
769 str(deletions), |
|
770 copyfrom, |
|
771 ]) |
|
772 |
|
773 itm.setTextAlignment(2, Qt.AlignmentFlag.AlignRight) |
|
774 itm.setTextAlignment(3, Qt.AlignmentFlag.AlignRight) |
|
775 |
|
776 return itm |
|
777 |
|
778 def __getLogEntries(self, skip=0, noEntries=0): |
|
779 """ |
|
780 Private method to retrieve log entries from the repository. |
|
781 |
|
782 @param skip number of log entries to skip (integer) |
|
783 @param noEntries number of entries to get (0 = default) (int) |
|
784 """ |
|
785 self.buttonBox.button( |
|
786 QDialogButtonBox.StandardButton.Close).setEnabled(False) |
|
787 self.buttonBox.button( |
|
788 QDialogButtonBox.StandardButton.Cancel).setEnabled(True) |
|
789 self.buttonBox.button( |
|
790 QDialogButtonBox.StandardButton.Cancel).setDefault(True) |
|
791 QApplication.processEvents() |
|
792 |
|
793 self.buf = [] |
|
794 self.cancelled = False |
|
795 self.errors.clear() |
|
796 self.intercept = False |
|
797 |
|
798 if noEntries == 0: |
|
799 noEntries = self.limitSpinBox.value() |
|
800 |
|
801 args = self.vcs.initCommand("log") |
|
802 args.append('--max-count={0}'.format(noEntries)) |
|
803 args.append('--numstat') |
|
804 args.append('--abbrev={0}'.format( |
|
805 self.vcs.getPlugin().getPreferences("CommitIdLength"))) |
|
806 if self.vcs.getPlugin().getPreferences("FindCopiesHarder"): |
|
807 args.append('--find-copies-harder') |
|
808 args.append('--format={0}'.format(self.__formatTemplate)) |
|
809 args.append('--full-history') |
|
810 args.append('--all') |
|
811 args.append('--skip={0}'.format(skip)) |
|
812 if not self.projectMode: |
|
813 if not self.stopCheckBox.isChecked(): |
|
814 args.append('--follow') |
|
815 args.append('--') |
|
816 args.append(self.__filename) |
|
817 |
|
818 self.__process.kill() |
|
819 |
|
820 self.__process.setWorkingDirectory(self.repodir) |
|
821 |
|
822 self.__process.start('git', args) |
|
823 procStarted = self.__process.waitForStarted(5000) |
|
824 if not procStarted: |
|
825 self.inputGroup.setEnabled(False) |
|
826 self.inputGroup.hide() |
|
827 E5MessageBox.critical( |
|
828 self, |
|
829 self.tr('Process Generation Error'), |
|
830 self.tr( |
|
831 'The process {0} could not be started. ' |
|
832 'Ensure, that it is in the search path.' |
|
833 ).format('git')) |
|
834 |
|
835 def start(self, fn, isFile=False, noEntries=0): |
|
836 """ |
|
837 Public slot to start the git log command. |
|
838 |
|
839 @param fn filename to show the log for (string) |
|
840 @param isFile flag indicating log for a file is to be shown |
|
841 (boolean) |
|
842 @param noEntries number of entries to get (0 = default) (int) |
|
843 """ |
|
844 self.__isFile = isFile |
|
845 |
|
846 self.sbsSelectLabel.clear() |
|
847 |
|
848 self.errorGroup.hide() |
|
849 QApplication.processEvents() |
|
850 |
|
851 self.__initData() |
|
852 |
|
853 self.__filename = fn |
|
854 self.dname, self.fname = self.vcs.splitPath(fn) |
|
855 |
|
856 # find the root of the repo |
|
857 self.repodir = self.dname |
|
858 while not os.path.isdir(os.path.join(self.repodir, self.vcs.adminDir)): |
|
859 self.repodir = os.path.dirname(self.repodir) |
|
860 if os.path.splitdrive(self.repodir)[1] == os.sep: |
|
861 return |
|
862 |
|
863 self.projectMode = (self.fname == "." and self.dname == self.repodir) |
|
864 self.stopCheckBox.setDisabled(self.projectMode or self.fname == ".") |
|
865 self.activateWindow() |
|
866 self.raise_() |
|
867 |
|
868 self.logTree.clear() |
|
869 self.__started = True |
|
870 self.__identifyProject() |
|
871 self.__getLogEntries(noEntries=noEntries) |
|
872 |
|
873 def __procFinished(self, exitCode, exitStatus): |
|
874 """ |
|
875 Private slot connected to the finished signal. |
|
876 |
|
877 @param exitCode exit code of the process (integer) |
|
878 @param exitStatus exit status of the process (QProcess.ExitStatus) |
|
879 """ |
|
880 self.__processBuffer() |
|
881 self.__finish() |
|
882 |
|
883 def __finish(self): |
|
884 """ |
|
885 Private slot called when the process finished or the user pressed |
|
886 the button. |
|
887 """ |
|
888 if ( |
|
889 self.__process is not None and |
|
890 self.__process.state() != QProcess.ProcessState.NotRunning |
|
891 ): |
|
892 self.__process.terminate() |
|
893 QTimer.singleShot(2000, self.__process.kill) |
|
894 self.__process.waitForFinished(3000) |
|
895 |
|
896 self.buttonBox.button( |
|
897 QDialogButtonBox.StandardButton.Close).setEnabled(True) |
|
898 self.buttonBox.button( |
|
899 QDialogButtonBox.StandardButton.Cancel).setEnabled(False) |
|
900 self.buttonBox.button( |
|
901 QDialogButtonBox.StandardButton.Close).setDefault(True) |
|
902 |
|
903 self.inputGroup.setEnabled(False) |
|
904 self.inputGroup.hide() |
|
905 self.refreshButton.setEnabled(True) |
|
906 |
|
907 while self.__finishCallbacks: |
|
908 self.__finishCallbacks.pop(0)() |
|
909 |
|
910 def __processBufferItem(self, logEntry): |
|
911 """ |
|
912 Private method to process a log entry. |
|
913 |
|
914 @param logEntry dictionary as generated by __processBuffer |
|
915 """ |
|
916 self.__generateLogItem( |
|
917 logEntry["author"], logEntry["authordate"], |
|
918 logEntry["committer"], logEntry["committerdate"], |
|
919 logEntry["subject"], logEntry["body"], |
|
920 logEntry["commit"], logEntry["changed_files"], |
|
921 logEntry["parents"], logEntry["refnames"], |
|
922 logEntry["authormail"], logEntry["committermail"] |
|
923 ) |
|
924 for date in [logEntry["authordate"], logEntry["committerdate"]]: |
|
925 dt = QDate.fromString(date, Qt.DateFormat.ISODate) |
|
926 if ( |
|
927 not self.__maxDate.isValid() and |
|
928 not self.__minDate.isValid() |
|
929 ): |
|
930 self.__maxDate = dt |
|
931 self.__minDate = dt |
|
932 else: |
|
933 if self.__maxDate < dt: |
|
934 self.__maxDate = dt |
|
935 if self.__minDate > dt: |
|
936 self.__minDate = dt |
|
937 |
|
938 def __processBuffer(self): |
|
939 """ |
|
940 Private method to process the buffered output of the git log command. |
|
941 """ |
|
942 noEntries = 0 |
|
943 logEntry = {"changed_files": []} |
|
944 descriptionBody = False |
|
945 |
|
946 for line in self.buf: |
|
947 line = line.rstrip() |
|
948 if line == "recordstart": |
|
949 if len(logEntry) > 1: |
|
950 self.__processBufferItem(logEntry) |
|
951 noEntries += 1 |
|
952 logEntry = {"changed_files": []} |
|
953 descriptionBody = False |
|
954 fileChanges = False |
|
955 body = [] |
|
956 elif line == "bodystart": |
|
957 descriptionBody = True |
|
958 elif line == "bodyend": |
|
959 if bool(body) and not bool(body[-1]): |
|
960 body.pop() |
|
961 logEntry["body"] = body |
|
962 descriptionBody = False |
|
963 fileChanges = True |
|
964 elif descriptionBody: |
|
965 body.append(line) |
|
966 elif fileChanges: |
|
967 if line: |
|
968 if "changed_files" not in logEntry: |
|
969 logEntry["changed_files"] = [] |
|
970 changeInfo = line.strip().split("\t") |
|
971 if "=>" in changeInfo[2]: |
|
972 # copy/move |
|
973 if "{" in changeInfo[2] and "}" in changeInfo[2]: |
|
974 # change info of the form |
|
975 # test/{pack1 => pack2}/file1.py |
|
976 head, tail = changeInfo[2].split("{", 1) |
|
977 middle, tail = tail.split("}", 1) |
|
978 middleSrc, middleDst = middle.split("=>") |
|
979 src = head + middleSrc.strip() + tail |
|
980 dst = head + middleDst.strip() + tail |
|
981 else: |
|
982 src, dst = changeInfo[2].split("=>") |
|
983 logEntry["changed_files"].append({ |
|
984 "action": "C", |
|
985 "added": changeInfo[0].strip(), |
|
986 "deleted": changeInfo[1].strip(), |
|
987 "path": dst.strip(), |
|
988 "copyfrom": src.strip(), |
|
989 }) |
|
990 else: |
|
991 logEntry["changed_files"].append({ |
|
992 "action": "M", |
|
993 "added": changeInfo[0].strip(), |
|
994 "deleted": changeInfo[1].strip(), |
|
995 "path": changeInfo[2].strip(), |
|
996 "copyfrom": "", |
|
997 }) |
|
998 else: |
|
999 try: |
|
1000 key, value = line.split("|", 1) |
|
1001 except ValueError: |
|
1002 key = "" |
|
1003 value = line |
|
1004 if key in ("commit", "parents", "author", "authormail", |
|
1005 "authordate", "committer", "committermail", |
|
1006 "committerdate", "refnames", "subject"): |
|
1007 logEntry[key] = value.strip() |
|
1008 if len(logEntry) > 1: |
|
1009 self.__processBufferItem(logEntry) |
|
1010 noEntries += 1 |
|
1011 |
|
1012 self.__resizeColumnsLog() |
|
1013 |
|
1014 if self.__started: |
|
1015 if self.__selectedCommitIDs: |
|
1016 self.logTree.setCurrentItem(self.logTree.findItems( |
|
1017 self.__selectedCommitIDs[0], Qt.MatchFlag.MatchExactly, |
|
1018 self.CommitIdColumn)[0]) |
|
1019 else: |
|
1020 self.logTree.setCurrentItem(self.logTree.topLevelItem(0)) |
|
1021 self.__started = False |
|
1022 |
|
1023 self.__skipEntries += noEntries |
|
1024 if noEntries < self.limitSpinBox.value() and not self.cancelled: |
|
1025 self.nextButton.setEnabled(False) |
|
1026 self.limitSpinBox.setEnabled(False) |
|
1027 else: |
|
1028 self.nextButton.setEnabled(True) |
|
1029 self.limitSpinBox.setEnabled(True) |
|
1030 |
|
1031 # update the log filters |
|
1032 self.__filterLogsEnabled = False |
|
1033 self.fromDate.setMinimumDate(self.__minDate) |
|
1034 self.fromDate.setMaximumDate(self.__maxDate) |
|
1035 self.fromDate.setDate(self.__minDate) |
|
1036 self.toDate.setMinimumDate(self.__minDate) |
|
1037 self.toDate.setMaximumDate(self.__maxDate) |
|
1038 self.toDate.setDate(self.__maxDate) |
|
1039 |
|
1040 self.__filterLogsEnabled = True |
|
1041 if self.__actionMode() == "filter": |
|
1042 self.__filterLogs() |
|
1043 |
|
1044 self.__updateToolMenuActions() |
|
1045 |
|
1046 # restore selected items |
|
1047 if self.__selectedCommitIDs: |
|
1048 for commitID in self.__selectedCommitIDs: |
|
1049 items = self.logTree.findItems( |
|
1050 commitID, Qt.MatchFlag.MatchExactly, self.CommitIdColumn) |
|
1051 if items: |
|
1052 items[0].setSelected(True) |
|
1053 self.__selectedCommitIDs = [] |
|
1054 |
|
1055 def __readStdout(self): |
|
1056 """ |
|
1057 Private slot to handle the readyReadStandardOutput signal. |
|
1058 |
|
1059 It reads the output of the process and inserts it into a buffer. |
|
1060 """ |
|
1061 self.__process.setReadChannel(QProcess.ProcessChannel.StandardOutput) |
|
1062 |
|
1063 while self.__process.canReadLine(): |
|
1064 line = str(self.__process.readLine(), |
|
1065 Preferences.getSystem("IOEncoding"), |
|
1066 'replace') |
|
1067 self.buf.append(line) |
|
1068 |
|
1069 def __readStderr(self): |
|
1070 """ |
|
1071 Private slot to handle the readyReadStandardError signal. |
|
1072 |
|
1073 It reads the error output of the process and inserts it into the |
|
1074 error pane. |
|
1075 """ |
|
1076 if self.__process is not None: |
|
1077 s = str(self.__process.readAllStandardError(), |
|
1078 Preferences.getSystem("IOEncoding"), |
|
1079 'replace') |
|
1080 self.__showError(s) |
|
1081 |
|
1082 def __showError(self, out): |
|
1083 """ |
|
1084 Private slot to show some error. |
|
1085 |
|
1086 @param out error to be shown (string) |
|
1087 """ |
|
1088 self.errorGroup.show() |
|
1089 self.errors.insertPlainText(out) |
|
1090 self.errors.ensureCursorVisible() |
|
1091 |
|
1092 # show input in case the process asked for some input |
|
1093 self.inputGroup.setEnabled(True) |
|
1094 self.inputGroup.show() |
|
1095 |
|
1096 def on_buttonBox_clicked(self, button): |
|
1097 """ |
|
1098 Private slot called by a button of the button box clicked. |
|
1099 |
|
1100 @param button button that was clicked (QAbstractButton) |
|
1101 """ |
|
1102 if button == self.buttonBox.button( |
|
1103 QDialogButtonBox.StandardButton.Close |
|
1104 ): |
|
1105 self.close() |
|
1106 elif button == self.buttonBox.button( |
|
1107 QDialogButtonBox.StandardButton.Cancel |
|
1108 ): |
|
1109 self.cancelled = True |
|
1110 self.__finish() |
|
1111 elif button == self.refreshButton: |
|
1112 self.on_refreshButton_clicked() |
|
1113 |
|
1114 @pyqtSlot() |
|
1115 def on_refreshButton_clicked(self): |
|
1116 """ |
|
1117 Private slot to refresh the log. |
|
1118 """ |
|
1119 self.buttonBox.button( |
|
1120 QDialogButtonBox.StandardButton.Close).setEnabled(False) |
|
1121 self.buttonBox.button( |
|
1122 QDialogButtonBox.StandardButton.Cancel).setEnabled(True) |
|
1123 self.buttonBox.button( |
|
1124 QDialogButtonBox.StandardButton.Cancel).setDefault(True) |
|
1125 |
|
1126 self.refreshButton.setEnabled(False) |
|
1127 |
|
1128 # save the selected items commit IDs |
|
1129 self.__selectedCommitIDs = [] |
|
1130 for item in self.logTree.selectedItems(): |
|
1131 self.__selectedCommitIDs.append(item.text(self.CommitIdColumn)) |
|
1132 |
|
1133 self.start(self.__filename, isFile=self.__isFile, |
|
1134 noEntries=self.logTree.topLevelItemCount()) |
|
1135 |
|
1136 def on_passwordCheckBox_toggled(self, isOn): |
|
1137 """ |
|
1138 Private slot to handle the password checkbox toggled. |
|
1139 |
|
1140 @param isOn flag indicating the status of the check box (boolean) |
|
1141 """ |
|
1142 if isOn: |
|
1143 self.input.setEchoMode(QLineEdit.EchoMode.Password) |
|
1144 else: |
|
1145 self.input.setEchoMode(QLineEdit.EchoMode.Normal) |
|
1146 |
|
1147 @pyqtSlot() |
|
1148 def on_sendButton_clicked(self): |
|
1149 """ |
|
1150 Private slot to send the input to the git process. |
|
1151 """ |
|
1152 inputTxt = self.input.text() |
|
1153 inputTxt += os.linesep |
|
1154 |
|
1155 if self.passwordCheckBox.isChecked(): |
|
1156 self.errors.insertPlainText(os.linesep) |
|
1157 self.errors.ensureCursorVisible() |
|
1158 else: |
|
1159 self.errors.insertPlainText(inputTxt) |
|
1160 self.errors.ensureCursorVisible() |
|
1161 self.errorGroup.show() |
|
1162 |
|
1163 self.__process.write(strToQByteArray(inputTxt)) |
|
1164 |
|
1165 self.passwordCheckBox.setChecked(False) |
|
1166 self.input.clear() |
|
1167 |
|
1168 def on_input_returnPressed(self): |
|
1169 """ |
|
1170 Private slot to handle the press of the return key in the input field. |
|
1171 """ |
|
1172 self.intercept = True |
|
1173 self.on_sendButton_clicked() |
|
1174 |
|
1175 def keyPressEvent(self, evt): |
|
1176 """ |
|
1177 Protected slot to handle a key press event. |
|
1178 |
|
1179 @param evt the key press event (QKeyEvent) |
|
1180 """ |
|
1181 if self.intercept: |
|
1182 self.intercept = False |
|
1183 evt.accept() |
|
1184 return |
|
1185 super().keyPressEvent(evt) |
|
1186 |
|
1187 def __prepareFieldSearch(self): |
|
1188 """ |
|
1189 Private slot to prepare the filed search data. |
|
1190 |
|
1191 @return tuple of field index, search expression and flag indicating |
|
1192 that the field index is a data role (integer, string, boolean) |
|
1193 """ |
|
1194 indexIsRole = False |
|
1195 txt = self.fieldCombo.itemData(self.fieldCombo.currentIndex()) |
|
1196 if txt == "author": |
|
1197 fieldIndex = self.AuthorColumn |
|
1198 searchRx = re.compile(self.rxEdit.text(), re.IGNORECASE) |
|
1199 elif txt == "committer": |
|
1200 fieldIndex = self.CommitterColumn |
|
1201 searchRx = re.compile(self.rxEdit.text(), re.IGNORECASE) |
|
1202 elif txt == "commitId": |
|
1203 fieldIndex = self.CommitIdColumn |
|
1204 txt = self.rxEdit.text() |
|
1205 if txt.startswith("^"): |
|
1206 searchRx = re.compile(r"^\s*{0}".format(txt[1:]), |
|
1207 re.IGNORECASE) |
|
1208 else: |
|
1209 searchRx = re.compile(txt, re.IGNORECASE) |
|
1210 elif txt == "file": |
|
1211 fieldIndex = self.__changesRole |
|
1212 searchRx = re.compile(self.rxEdit.text(), re.IGNORECASE) |
|
1213 indexIsRole = True |
|
1214 else: |
|
1215 fieldIndex = self.__subjectRole |
|
1216 searchRx = re.compile(self.rxEdit.text(), re.IGNORECASE) |
|
1217 indexIsRole = True |
|
1218 |
|
1219 return fieldIndex, searchRx, indexIsRole |
|
1220 |
|
1221 def __filterLogs(self): |
|
1222 """ |
|
1223 Private method to filter the log entries. |
|
1224 """ |
|
1225 if self.__filterLogsEnabled: |
|
1226 from_ = self.fromDate.date().toString("yyyy-MM-dd") |
|
1227 to_ = self.toDate.date().addDays(1).toString("yyyy-MM-dd") |
|
1228 fieldIndex, searchRx, indexIsRole = self.__prepareFieldSearch() |
|
1229 |
|
1230 visibleItemCount = self.logTree.topLevelItemCount() |
|
1231 currentItem = self.logTree.currentItem() |
|
1232 for topIndex in range(self.logTree.topLevelItemCount()): |
|
1233 topItem = self.logTree.topLevelItem(topIndex) |
|
1234 if indexIsRole: |
|
1235 if fieldIndex == self.__changesRole: |
|
1236 changes = topItem.data(0, self.__changesRole) |
|
1237 txt = "\n".join( |
|
1238 [c["path"] for c in changes] + |
|
1239 [c["copyfrom"] for c in changes] |
|
1240 ) |
|
1241 else: |
|
1242 # Filter based on complete subject text |
|
1243 txt = topItem.data(0, self.__subjectRole) |
|
1244 else: |
|
1245 txt = topItem.text(fieldIndex) |
|
1246 if ( |
|
1247 topItem.text(self.DateColumn) <= to_ and |
|
1248 topItem.text(self.DateColumn) >= from_ and |
|
1249 searchRx.search(txt) is not None |
|
1250 ): |
|
1251 topItem.setHidden(False) |
|
1252 if topItem is currentItem: |
|
1253 self.on_logTree_currentItemChanged(topItem, None) |
|
1254 else: |
|
1255 topItem.setHidden(True) |
|
1256 if topItem is currentItem: |
|
1257 self.filesTree.clear() |
|
1258 visibleItemCount -= 1 |
|
1259 self.logTree.header().setSectionHidden( |
|
1260 self.IconColumn, |
|
1261 visibleItemCount != self.logTree.topLevelItemCount()) |
|
1262 |
|
1263 def __updateSbsSelectLabel(self): |
|
1264 """ |
|
1265 Private slot to update the enabled status of the diff buttons. |
|
1266 """ |
|
1267 self.sbsSelectLabel.clear() |
|
1268 if self.__isFile: |
|
1269 selectedItems = self.logTree.selectedItems() |
|
1270 if len(selectedItems) == 1: |
|
1271 currentItem = selectedItems[0] |
|
1272 commit2 = currentItem.text(self.CommitIdColumn).strip() |
|
1273 parents = currentItem.data(0, self.__parentsRole) |
|
1274 if parents: |
|
1275 parentLinks = [] |
|
1276 for index in range(len(parents)): |
|
1277 parentLinks.append( |
|
1278 '<a href="sbsdiff:{0}_{1}"> {2} </a>' |
|
1279 .format(parents[index], commit2, index + 1)) |
|
1280 self.sbsSelectLabel.setText( |
|
1281 self.tr('Side-by-Side Diff to Parent {0}').format( |
|
1282 " ".join(parentLinks))) |
|
1283 elif len(selectedItems) == 2: |
|
1284 commit2 = selectedItems[0].text(self.CommitIdColumn) |
|
1285 commit1 = selectedItems[1].text(self.CommitIdColumn) |
|
1286 index2 = self.logTree.indexOfTopLevelItem(selectedItems[0]) |
|
1287 index1 = self.logTree.indexOfTopLevelItem(selectedItems[1]) |
|
1288 |
|
1289 if index2 < index1: |
|
1290 # swap to always compare old to new |
|
1291 commit1, commit2 = commit2, commit1 |
|
1292 self.sbsSelectLabel.setText(self.tr( |
|
1293 '<a href="sbsdiff:{0}_{1}">Side-by-Side Compare</a>') |
|
1294 .format(commit1, commit2)) |
|
1295 |
|
1296 def __updateToolMenuActions(self): |
|
1297 """ |
|
1298 Private slot to update the status of the tool menu actions and |
|
1299 the tool menu button. |
|
1300 """ |
|
1301 if self.projectMode: |
|
1302 selectCount = len(self.logTree.selectedItems()) |
|
1303 self.__cherryAct.setEnabled(selectCount > 0) |
|
1304 self.__describeAct.setEnabled(selectCount > 0) |
|
1305 self.__tagAct.setEnabled(selectCount == 1) |
|
1306 self.__switchAct.setEnabled(selectCount == 1) |
|
1307 self.__branchAct.setEnabled(selectCount == 1) |
|
1308 self.__branchSwitchAct.setEnabled(selectCount == 1) |
|
1309 self.__shortlogAct.setEnabled(selectCount == 1) |
|
1310 |
|
1311 self.actionsButton.setEnabled(True) |
|
1312 else: |
|
1313 self.actionsButton.setEnabled(False) |
|
1314 |
|
1315 def __updateDetailsAndFiles(self): |
|
1316 """ |
|
1317 Private slot to update the details and file changes panes. |
|
1318 """ |
|
1319 self.detailsEdit.clear() |
|
1320 self.filesTree.clear() |
|
1321 self.__diffUpdatesFiles = False |
|
1322 |
|
1323 selectedItems = self.logTree.selectedItems() |
|
1324 if len(selectedItems) == 1: |
|
1325 self.detailsEdit.setHtml( |
|
1326 self.__generateDetailsTableText(selectedItems[0])) |
|
1327 self.__updateFilesTree(self.filesTree, selectedItems[0]) |
|
1328 self.__resizeColumnsFiles() |
|
1329 self.__resortFiles() |
|
1330 if self.filesTree.topLevelItemCount() == 0: |
|
1331 self.__diffUpdatesFiles = True |
|
1332 # give diff a chance to update the files list |
|
1333 elif len(selectedItems) == 2: |
|
1334 self.__diffUpdatesFiles = True |
|
1335 index1 = self.logTree.indexOfTopLevelItem(selectedItems[0]) |
|
1336 index2 = self.logTree.indexOfTopLevelItem(selectedItems[1]) |
|
1337 if index1 > index2: |
|
1338 # Swap the entries |
|
1339 selectedItems[0], selectedItems[1] = ( |
|
1340 selectedItems[1], selectedItems[0] |
|
1341 ) |
|
1342 html = "{0}<hr/>{1}".format( |
|
1343 self.__generateDetailsTableText(selectedItems[0]), |
|
1344 self.__generateDetailsTableText(selectedItems[1]), |
|
1345 ) |
|
1346 self.detailsEdit.setHtml(html) |
|
1347 # self.filesTree is updated by the diff |
|
1348 |
|
1349 def __generateDetailsTableText(self, itm): |
|
1350 """ |
|
1351 Private method to generate an HTML table with the details of the given |
|
1352 changeset. |
|
1353 |
|
1354 @param itm reference to the item the table should be based on |
|
1355 @type QTreeWidgetItem |
|
1356 @return HTML table containing details |
|
1357 @rtype str |
|
1358 """ |
|
1359 if itm is not None: |
|
1360 commitId = itm.text(self.CommitIdColumn) |
|
1361 |
|
1362 parentLinks = [] |
|
1363 for parent in [str(x) for x in itm.data(0, self.__parentsRole)]: |
|
1364 parentLinks.append('<a href="rev:{0}">{0}</a>'.format(parent)) |
|
1365 if parentLinks: |
|
1366 parentsStr = self.__parentsTemplate.format( |
|
1367 ", ".join(parentLinks)) |
|
1368 else: |
|
1369 parentsStr = "" |
|
1370 |
|
1371 childLinks = [] |
|
1372 for child in [str(x) for x in self.__childrenInfo[commitId]]: |
|
1373 childLinks.append('<a href="rev:{0}">{0}</a>'.format(child)) |
|
1374 if childLinks: |
|
1375 childrenStr = self.__childrenTemplate.format( |
|
1376 ", ".join(childLinks)) |
|
1377 else: |
|
1378 childrenStr = "" |
|
1379 |
|
1380 branchLinks = [] |
|
1381 for branch, branchHead in self.__getBranchesForCommit(commitId): |
|
1382 branchLinks.append('<a href="rev:{0}">{1}</a>'.format( |
|
1383 branchHead, branch)) |
|
1384 if branchLinks: |
|
1385 branchesStr = self.__branchesTemplate.format( |
|
1386 ", ".join(branchLinks)) |
|
1387 else: |
|
1388 branchesStr = "" |
|
1389 |
|
1390 tagLinks = [] |
|
1391 for tag, tagCommit in self.__getTagsForCommit(commitId): |
|
1392 if tagCommit: |
|
1393 tagLinks.append('<a href="rev:{0}">{1}</a>'.format( |
|
1394 tagCommit, tag)) |
|
1395 else: |
|
1396 tagLinks.append(tag) |
|
1397 if tagLinks: |
|
1398 tagsStr = self.__tagsTemplate.format( |
|
1399 ", ".join(tagLinks)) |
|
1400 else: |
|
1401 tagsStr = "" |
|
1402 |
|
1403 if itm.data(0, self.__messageRole): |
|
1404 messageStr = self.__mesageTemplate.format( |
|
1405 "<br/>".join(itm.data(0, self.__messageRole))) |
|
1406 else: |
|
1407 messageStr = "" |
|
1408 |
|
1409 html = self.__detailsTemplate.format( |
|
1410 commitId, |
|
1411 itm.text(self.DateColumn), |
|
1412 itm.text(self.AuthorColumn), |
|
1413 itm.data(0, self.__authorMailRole).strip(), |
|
1414 itm.text(self.CommitDateColumn), |
|
1415 itm.text(self.CommitterColumn), |
|
1416 itm.data(0, self.__committerMailRole).strip(), |
|
1417 parentsStr + childrenStr + branchesStr + tagsStr, |
|
1418 itm.data(0, self.__subjectRole), |
|
1419 messageStr, |
|
1420 ) |
|
1421 else: |
|
1422 html = "" |
|
1423 |
|
1424 return html |
|
1425 |
|
1426 def __updateFilesTree(self, parent, itm): |
|
1427 """ |
|
1428 Private method to update the files tree with changes of the given item. |
|
1429 |
|
1430 @param parent parent for the items to be added |
|
1431 @type QTreeWidget or QTreeWidgetItem |
|
1432 @param itm reference to the item the update should be based on |
|
1433 @type QTreeWidgetItem |
|
1434 """ |
|
1435 if itm is not None: |
|
1436 changes = itm.data(0, self.__changesRole) |
|
1437 if len(changes) > 0: |
|
1438 for change in changes: |
|
1439 self.__generateFileItem( |
|
1440 change["action"], change["path"], change["copyfrom"], |
|
1441 change["added"], change["deleted"]) |
|
1442 self.__resizeColumnsFiles() |
|
1443 self.__resortFiles() |
|
1444 |
|
1445 def __getBranchesForCommit(self, commitId): |
|
1446 """ |
|
1447 Private method to get all branches reachable from a commit ID. |
|
1448 |
|
1449 @param commitId commit ID to get the branches for |
|
1450 @type str |
|
1451 @return list of tuples containing the branch name and the associated |
|
1452 commit ID of its branch head |
|
1453 @rtype tuple of (str, str) |
|
1454 """ |
|
1455 branches = [] |
|
1456 |
|
1457 args = self.vcs.initCommand("branch") |
|
1458 args.append("--list") |
|
1459 args.append("--verbose") |
|
1460 args.append("--contains") |
|
1461 args.append(commitId) |
|
1462 |
|
1463 output = "" |
|
1464 process = QProcess() |
|
1465 process.setWorkingDirectory(self.repodir) |
|
1466 process.start('git', args) |
|
1467 procStarted = process.waitForStarted(5000) |
|
1468 if procStarted: |
|
1469 finished = process.waitForFinished(30000) |
|
1470 if finished and process.exitCode() == 0: |
|
1471 output = str(process.readAllStandardOutput(), |
|
1472 Preferences.getSystem("IOEncoding"), |
|
1473 'replace') |
|
1474 |
|
1475 if output: |
|
1476 for line in output.splitlines(): |
|
1477 name, commitId = line[2:].split(None, 2)[:2] |
|
1478 branches.append((name, commitId)) |
|
1479 |
|
1480 return branches |
|
1481 |
|
1482 def __getTagsForCommit(self, commitId): |
|
1483 """ |
|
1484 Private method to get all tags reachable from a commit ID. |
|
1485 |
|
1486 @param commitId commit ID to get the tags for |
|
1487 @type str |
|
1488 @return list of tuples containing the tag name and the associated |
|
1489 commit ID |
|
1490 @rtype tuple of (str, str) |
|
1491 """ |
|
1492 tags = [] |
|
1493 |
|
1494 args = self.vcs.initCommand("tag") |
|
1495 args.append("--list") |
|
1496 args.append("--contains") |
|
1497 args.append(commitId) |
|
1498 |
|
1499 output = "" |
|
1500 process = QProcess() |
|
1501 process.setWorkingDirectory(self.repodir) |
|
1502 process.start('git', args) |
|
1503 procStarted = process.waitForStarted(5000) |
|
1504 if procStarted: |
|
1505 finished = process.waitForFinished(30000) |
|
1506 if finished and process.exitCode() == 0: |
|
1507 output = str(process.readAllStandardOutput(), |
|
1508 Preferences.getSystem("IOEncoding"), |
|
1509 'replace') |
|
1510 |
|
1511 if output: |
|
1512 tagNames = [] |
|
1513 for line in output.splitlines(): |
|
1514 tagNames.append(line.strip()) |
|
1515 |
|
1516 # determine the commit IDs for the tags |
|
1517 for tagName in tagNames: |
|
1518 commitId = self.__getCommitForTag(tagName) |
|
1519 tags.append((tagName, commitId)) |
|
1520 |
|
1521 return tags |
|
1522 |
|
1523 def __getCommitForTag(self, tag): |
|
1524 """ |
|
1525 Private method to get the commit id for a tag. |
|
1526 |
|
1527 @param tag tag name (string) |
|
1528 @return commit id shortened to 10 characters (string) |
|
1529 """ |
|
1530 args = self.vcs.initCommand("show") |
|
1531 args.append("--abbrev-commit") |
|
1532 args.append("--abbrev={0}".format( |
|
1533 self.vcs.getPlugin().getPreferences("CommitIdLength"))) |
|
1534 args.append("--no-patch") |
|
1535 args.append(tag) |
|
1536 |
|
1537 output = "" |
|
1538 process = QProcess() |
|
1539 process.setWorkingDirectory(self.repodir) |
|
1540 process.start('git', args) |
|
1541 procStarted = process.waitForStarted(5000) |
|
1542 if procStarted: |
|
1543 finished = process.waitForFinished(30000) |
|
1544 if finished and process.exitCode() == 0: |
|
1545 output = str(process.readAllStandardOutput(), |
|
1546 Preferences.getSystem("IOEncoding"), |
|
1547 'replace') |
|
1548 |
|
1549 if output: |
|
1550 for line in output.splitlines(): |
|
1551 if line.startswith("commit "): |
|
1552 commitId = line.split()[1].strip() |
|
1553 return commitId |
|
1554 |
|
1555 return "" |
|
1556 |
|
1557 @pyqtSlot(QPoint) |
|
1558 def on_logTree_customContextMenuRequested(self, pos): |
|
1559 """ |
|
1560 Private slot to show the context menu of the log tree. |
|
1561 |
|
1562 @param pos position of the mouse pointer (QPoint) |
|
1563 """ |
|
1564 self.__logTreeMenu.popup(self.logTree.mapToGlobal(pos)) |
|
1565 |
|
1566 @pyqtSlot(QTreeWidgetItem, QTreeWidgetItem) |
|
1567 def on_logTree_currentItemChanged(self, current, previous): |
|
1568 """ |
|
1569 Private slot called, when the current item of the log tree changes. |
|
1570 |
|
1571 @param current reference to the new current item (QTreeWidgetItem) |
|
1572 @param previous reference to the old current item (QTreeWidgetItem) |
|
1573 """ |
|
1574 self.__updateToolMenuActions() |
|
1575 |
|
1576 # Highlight the current entry using a bold font |
|
1577 for col in range(self.logTree.columnCount()): |
|
1578 current and current.setFont(col, self.__logTreeBoldFont) |
|
1579 previous and previous.setFont(col, self.__logTreeNormalFont) |
|
1580 |
|
1581 # set the state of the up and down buttons |
|
1582 self.upButton.setEnabled( |
|
1583 current is not None and |
|
1584 self.logTree.indexOfTopLevelItem(current) > 0) |
|
1585 self.downButton.setEnabled( |
|
1586 current is not None and |
|
1587 len(current.data(0, self.__parentsRole)) > 0 and |
|
1588 (self.logTree.indexOfTopLevelItem(current) < |
|
1589 self.logTree.topLevelItemCount() - 1 or |
|
1590 self.nextButton.isEnabled())) |
|
1591 |
|
1592 @pyqtSlot() |
|
1593 def on_logTree_itemSelectionChanged(self): |
|
1594 """ |
|
1595 Private slot called, when the selection has changed. |
|
1596 """ |
|
1597 self.__updateDetailsAndFiles() |
|
1598 self.__updateSbsSelectLabel() |
|
1599 self.__updateToolMenuActions() |
|
1600 self.__generateDiffs() |
|
1601 |
|
1602 @pyqtSlot() |
|
1603 def on_upButton_clicked(self): |
|
1604 """ |
|
1605 Private slot to move the current item up one entry. |
|
1606 """ |
|
1607 itm = self.logTree.itemAbove(self.logTree.currentItem()) |
|
1608 if itm: |
|
1609 self.logTree.setCurrentItem(itm) |
|
1610 |
|
1611 @pyqtSlot() |
|
1612 def on_downButton_clicked(self): |
|
1613 """ |
|
1614 Private slot to move the current item down one entry. |
|
1615 """ |
|
1616 itm = self.logTree.itemBelow(self.logTree.currentItem()) |
|
1617 if itm: |
|
1618 self.logTree.setCurrentItem(itm) |
|
1619 else: |
|
1620 # load the next bunch and try again |
|
1621 if self.nextButton.isEnabled(): |
|
1622 self.__addFinishCallback(self.on_downButton_clicked) |
|
1623 self.on_nextButton_clicked() |
|
1624 |
|
1625 @pyqtSlot() |
|
1626 def on_nextButton_clicked(self): |
|
1627 """ |
|
1628 Private slot to handle the Next button. |
|
1629 """ |
|
1630 if self.__skipEntries > 0 and self.nextButton.isEnabled(): |
|
1631 self.__getLogEntries(skip=self.__skipEntries) |
|
1632 |
|
1633 @pyqtSlot(QDate) |
|
1634 def on_fromDate_dateChanged(self, date): |
|
1635 """ |
|
1636 Private slot called, when the from date changes. |
|
1637 |
|
1638 @param date new date (QDate) |
|
1639 """ |
|
1640 if self.__actionMode() == "filter": |
|
1641 self.__filterLogs() |
|
1642 |
|
1643 @pyqtSlot(QDate) |
|
1644 def on_toDate_dateChanged(self, date): |
|
1645 """ |
|
1646 Private slot called, when the from date changes. |
|
1647 |
|
1648 @param date new date (QDate) |
|
1649 """ |
|
1650 if self.__actionMode() == "filter": |
|
1651 self.__filterLogs() |
|
1652 |
|
1653 @pyqtSlot(int) |
|
1654 def on_fieldCombo_activated(self, index): |
|
1655 """ |
|
1656 Private slot called, when a new filter field is selected. |
|
1657 |
|
1658 @param index index of the selected entry |
|
1659 @type int |
|
1660 """ |
|
1661 if self.__actionMode() == "filter": |
|
1662 self.__filterLogs() |
|
1663 |
|
1664 @pyqtSlot(str) |
|
1665 def on_rxEdit_textChanged(self, txt): |
|
1666 """ |
|
1667 Private slot called, when a filter expression is entered. |
|
1668 |
|
1669 @param txt filter expression (string) |
|
1670 """ |
|
1671 if self.__actionMode() == "filter": |
|
1672 self.__filterLogs() |
|
1673 elif self.__actionMode() == "find": |
|
1674 self.__findItem(self.__findBackwards, interactive=True) |
|
1675 |
|
1676 @pyqtSlot() |
|
1677 def on_rxEdit_returnPressed(self): |
|
1678 """ |
|
1679 Private slot handling a press of the Return key in the rxEdit input. |
|
1680 """ |
|
1681 if self.__actionMode() == "find": |
|
1682 self.__findItem(self.__findBackwards, interactive=True) |
|
1683 |
|
1684 @pyqtSlot(bool) |
|
1685 def on_stopCheckBox_clicked(self, checked): |
|
1686 """ |
|
1687 Private slot called, when the stop on copy/move checkbox is clicked. |
|
1688 |
|
1689 @param checked flag indicating the state of the check box (boolean) |
|
1690 """ |
|
1691 self.vcs.getPlugin().setPreferences("StopLogOnCopy", |
|
1692 self.stopCheckBox.isChecked()) |
|
1693 self.nextButton.setEnabled(True) |
|
1694 self.limitSpinBox.setEnabled(True) |
|
1695 |
|
1696 ################################################################## |
|
1697 ## Tool button menu action methods below |
|
1698 ################################################################## |
|
1699 |
|
1700 @pyqtSlot() |
|
1701 def __cherryActTriggered(self): |
|
1702 """ |
|
1703 Private slot to handle the Copy Commits action. |
|
1704 """ |
|
1705 commits = {} |
|
1706 |
|
1707 for itm in self.logTree.selectedItems(): |
|
1708 index = self.logTree.indexOfTopLevelItem(itm) |
|
1709 commits[index] = itm.text(self.CommitIdColumn) |
|
1710 |
|
1711 if commits: |
|
1712 pfile = e5App().getObject("Project").getProjectFile() |
|
1713 lastModified = QFileInfo(pfile).lastModified().toString() |
|
1714 shouldReopen = ( |
|
1715 self.vcs.gitCherryPick( |
|
1716 self.repodir, |
|
1717 [commits[i] for i in sorted(commits.keys(), reverse=True)] |
|
1718 ) or |
|
1719 QFileInfo(pfile).lastModified().toString() != lastModified |
|
1720 ) |
|
1721 if shouldReopen: |
|
1722 res = E5MessageBox.yesNo( |
|
1723 None, |
|
1724 self.tr("Copy Changesets"), |
|
1725 self.tr( |
|
1726 """The project should be reread. Do this now?"""), |
|
1727 yesDefault=True) |
|
1728 if res: |
|
1729 e5App().getObject("Project").reopenProject() |
|
1730 return |
|
1731 |
|
1732 self.on_refreshButton_clicked() |
|
1733 |
|
1734 @pyqtSlot() |
|
1735 def __tagActTriggered(self): |
|
1736 """ |
|
1737 Private slot to tag the selected commit. |
|
1738 """ |
|
1739 if len(self.logTree.selectedItems()) == 1: |
|
1740 itm = self.logTree.selectedItems()[0] |
|
1741 commit = itm.text(self.CommitIdColumn) |
|
1742 tag = itm.text(self.TagsColumn).strip().split(", ", 1)[0] |
|
1743 res = self.vcs.vcsTag(self.repodir, revision=commit, tagName=tag) |
|
1744 if res: |
|
1745 self.on_refreshButton_clicked() |
|
1746 |
|
1747 @pyqtSlot() |
|
1748 def __switchActTriggered(self): |
|
1749 """ |
|
1750 Private slot to switch the working directory to the |
|
1751 selected commit. |
|
1752 """ |
|
1753 if len(self.logTree.selectedItems()) == 1: |
|
1754 itm = self.logTree.selectedItems()[0] |
|
1755 commit = itm.text(self.CommitIdColumn) |
|
1756 branches = [b for b in itm.text(self.BranchColumn).split(", ") |
|
1757 if "/" not in b] |
|
1758 if len(branches) == 1: |
|
1759 branch = branches[0] |
|
1760 elif len(branches) > 1: |
|
1761 branch, ok = QInputDialog.getItem( |
|
1762 self, |
|
1763 self.tr("Switch"), |
|
1764 self.tr("Select a branch"), |
|
1765 [""] + branches, |
|
1766 0, False) |
|
1767 if not ok: |
|
1768 return |
|
1769 else: |
|
1770 branch = "" |
|
1771 if branch: |
|
1772 rev = branch |
|
1773 else: |
|
1774 rev = commit |
|
1775 pfile = e5App().getObject("Project").getProjectFile() |
|
1776 lastModified = QFileInfo(pfile).lastModified().toString() |
|
1777 shouldReopen = ( |
|
1778 self.vcs.vcsUpdate(self.repodir, revision=rev) or |
|
1779 QFileInfo(pfile).lastModified().toString() != lastModified |
|
1780 ) |
|
1781 if shouldReopen: |
|
1782 res = E5MessageBox.yesNo( |
|
1783 None, |
|
1784 self.tr("Switch"), |
|
1785 self.tr( |
|
1786 """The project should be reread. Do this now?"""), |
|
1787 yesDefault=True) |
|
1788 if res: |
|
1789 e5App().getObject("Project").reopenProject() |
|
1790 return |
|
1791 |
|
1792 self.on_refreshButton_clicked() |
|
1793 |
|
1794 @pyqtSlot() |
|
1795 def __branchActTriggered(self): |
|
1796 """ |
|
1797 Private slot to create a new branch starting at the selected commit. |
|
1798 """ |
|
1799 if len(self.logTree.selectedItems()) == 1: |
|
1800 from .GitBranchDialog import GitBranchDialog |
|
1801 itm = self.logTree.selectedItems()[0] |
|
1802 commit = itm.text(self.CommitIdColumn) |
|
1803 branches = [b for b in itm.text(self.BranchColumn).split(", ") |
|
1804 if "/" not in b] |
|
1805 if len(branches) == 1: |
|
1806 branch = branches[0] |
|
1807 elif len(branches) > 1: |
|
1808 branch, ok = QInputDialog.getItem( |
|
1809 self, |
|
1810 self.tr("Branch"), |
|
1811 self.tr("Select a default branch"), |
|
1812 [""] + branches, |
|
1813 0, False) |
|
1814 if not ok: |
|
1815 return |
|
1816 else: |
|
1817 branch = "" |
|
1818 res = self.vcs.gitBranch( |
|
1819 self.repodir, revision=commit, branchName=branch, |
|
1820 branchOp=GitBranchDialog.CreateBranch) |
|
1821 if res: |
|
1822 self.on_refreshButton_clicked() |
|
1823 |
|
1824 @pyqtSlot() |
|
1825 def __branchSwitchActTriggered(self): |
|
1826 """ |
|
1827 Private slot to create a new branch starting at the selected commit |
|
1828 and switch the work tree to it. |
|
1829 """ |
|
1830 if len(self.logTree.selectedItems()) == 1: |
|
1831 from .GitBranchDialog import GitBranchDialog |
|
1832 itm = self.logTree.selectedItems()[0] |
|
1833 commit = itm.text(self.CommitIdColumn) |
|
1834 branches = [b for b in itm.text(self.BranchColumn).split(", ") |
|
1835 if "/" not in b] |
|
1836 if len(branches) == 1: |
|
1837 branch = branches[0] |
|
1838 elif len(branches) > 1: |
|
1839 branch, ok = QInputDialog.getItem( |
|
1840 self, |
|
1841 self.tr("Branch & Switch"), |
|
1842 self.tr("Select a default branch"), |
|
1843 [""] + branches, |
|
1844 0, False) |
|
1845 if not ok: |
|
1846 return |
|
1847 else: |
|
1848 branch = "" |
|
1849 pfile = e5App().getObject("Project").getProjectFile() |
|
1850 lastModified = QFileInfo(pfile).lastModified().toString() |
|
1851 res, shouldReopen = self.vcs.gitBranch( |
|
1852 self.repodir, revision=commit, branchName=branch, |
|
1853 branchOp=GitBranchDialog.CreateSwitchBranch) |
|
1854 shouldReopen = ( |
|
1855 shouldReopen or |
|
1856 QFileInfo(pfile).lastModified().toString() != lastModified |
|
1857 ) |
|
1858 if res: |
|
1859 if shouldReopen: |
|
1860 res = E5MessageBox.yesNo( |
|
1861 None, |
|
1862 self.tr("Switch"), |
|
1863 self.tr( |
|
1864 """The project should be reread. Do this now?"""), |
|
1865 yesDefault=True) |
|
1866 if res: |
|
1867 e5App().getObject("Project").reopenProject() |
|
1868 return |
|
1869 |
|
1870 self.on_refreshButton_clicked() |
|
1871 |
|
1872 @pyqtSlot() |
|
1873 def __shortlogActTriggered(self): |
|
1874 """ |
|
1875 Private slot to show a short log suitable for release announcements. |
|
1876 """ |
|
1877 if len(self.logTree.selectedItems()) == 1: |
|
1878 itm = self.logTree.selectedItems()[0] |
|
1879 commit = itm.text(self.CommitIdColumn) |
|
1880 branch = itm.text(self.BranchColumn).split(", ", 1)[0] |
|
1881 branches = [b for b in itm.text(self.BranchColumn).split(", ") |
|
1882 if "/" not in b] |
|
1883 if len(branches) == 1: |
|
1884 branch = branches[0] |
|
1885 elif len(branches) > 1: |
|
1886 branch, ok = QInputDialog.getItem( |
|
1887 self, |
|
1888 self.tr("Show Short Log"), |
|
1889 self.tr("Select a branch"), |
|
1890 [""] + branches, |
|
1891 0, False) |
|
1892 if not ok: |
|
1893 return |
|
1894 else: |
|
1895 branch = "" |
|
1896 if branch: |
|
1897 rev = branch |
|
1898 else: |
|
1899 rev = commit |
|
1900 self.vcs.gitShortlog(self.repodir, commit=rev) |
|
1901 |
|
1902 @pyqtSlot() |
|
1903 def __describeActTriggered(self): |
|
1904 """ |
|
1905 Private slot to show the most recent tag reachable from a commit. |
|
1906 """ |
|
1907 commits = [] |
|
1908 |
|
1909 for itm in self.logTree.selectedItems(): |
|
1910 commits.append(itm.text(self.CommitIdColumn)) |
|
1911 |
|
1912 if commits: |
|
1913 self.vcs.gitDescribe(self.repodir, commits) |
|
1914 |
|
1915 ################################################################## |
|
1916 ## Log context menu action methods below |
|
1917 ################################################################## |
|
1918 |
|
1919 @pyqtSlot(bool) |
|
1920 def __showCommitterColumns(self, on): |
|
1921 """ |
|
1922 Private slot to show/hide the committer columns. |
|
1923 |
|
1924 @param on flag indicating the selection state (boolean) |
|
1925 """ |
|
1926 self.logTree.setColumnHidden(self.CommitterColumn, not on) |
|
1927 self.logTree.setColumnHidden(self.CommitDateColumn, not on) |
|
1928 self.vcs.getPlugin().setPreferences("ShowCommitterColumns", on) |
|
1929 self.__resizeColumnsLog() |
|
1930 |
|
1931 @pyqtSlot(bool) |
|
1932 def __showAuthorColumns(self, on): |
|
1933 """ |
|
1934 Private slot to show/hide the committer columns. |
|
1935 |
|
1936 @param on flag indicating the selection state (boolean) |
|
1937 """ |
|
1938 self.logTree.setColumnHidden(self.AuthorColumn, not on) |
|
1939 self.logTree.setColumnHidden(self.DateColumn, not on) |
|
1940 self.vcs.getPlugin().setPreferences("ShowAuthorColumns", on) |
|
1941 self.__resizeColumnsLog() |
|
1942 |
|
1943 @pyqtSlot(bool) |
|
1944 def __showCommitIdColumn(self, on): |
|
1945 """ |
|
1946 Private slot to show/hide the commit ID column. |
|
1947 |
|
1948 @param on flag indicating the selection state (boolean) |
|
1949 """ |
|
1950 self.logTree.setColumnHidden(self.CommitIdColumn, not on) |
|
1951 self.vcs.getPlugin().setPreferences("ShowCommitIdColumn", on) |
|
1952 self.__resizeColumnsLog() |
|
1953 |
|
1954 @pyqtSlot(bool) |
|
1955 def __showBranchesColumn(self, on): |
|
1956 """ |
|
1957 Private slot to show/hide the branches column. |
|
1958 |
|
1959 @param on flag indicating the selection state (boolean) |
|
1960 """ |
|
1961 self.logTree.setColumnHidden(self.BranchColumn, not on) |
|
1962 self.vcs.getPlugin().setPreferences("ShowBranchesColumn", on) |
|
1963 self.__resizeColumnsLog() |
|
1964 |
|
1965 @pyqtSlot(bool) |
|
1966 def __showTagsColumn(self, on): |
|
1967 """ |
|
1968 Private slot to show/hide the tags column. |
|
1969 |
|
1970 @param on flag indicating the selection state (boolean) |
|
1971 """ |
|
1972 self.logTree.setColumnHidden(self.TagsColumn, not on) |
|
1973 self.vcs.getPlugin().setPreferences("ShowTagsColumn", on) |
|
1974 self.__resizeColumnsLog() |
|
1975 |
|
1976 ################################################################## |
|
1977 ## Search and filter methods below |
|
1978 ################################################################## |
|
1979 |
|
1980 def __actionMode(self): |
|
1981 """ |
|
1982 Private method to get the selected action mode. |
|
1983 |
|
1984 @return selected action mode (string, one of filter or find) |
|
1985 """ |
|
1986 return self.modeComboBox.itemData( |
|
1987 self.modeComboBox.currentIndex()) |
|
1988 |
|
1989 @pyqtSlot(int) |
|
1990 def on_modeComboBox_currentIndexChanged(self, index): |
|
1991 """ |
|
1992 Private slot to react on mode changes. |
|
1993 |
|
1994 @param index index of the selected entry (integer) |
|
1995 """ |
|
1996 mode = self.modeComboBox.itemData(index) |
|
1997 findMode = mode == "find" |
|
1998 filterMode = mode == "filter" |
|
1999 |
|
2000 self.fromDate.setEnabled(filterMode) |
|
2001 self.toDate.setEnabled(filterMode) |
|
2002 self.findPrevButton.setVisible(findMode) |
|
2003 self.findNextButton.setVisible(findMode) |
|
2004 |
|
2005 if findMode: |
|
2006 for topIndex in range(self.logTree.topLevelItemCount()): |
|
2007 self.logTree.topLevelItem(topIndex).setHidden(False) |
|
2008 self.logTree.header().setSectionHidden(self.IconColumn, False) |
|
2009 elif filterMode: |
|
2010 self.__filterLogs() |
|
2011 |
|
2012 @pyqtSlot() |
|
2013 def on_findPrevButton_clicked(self): |
|
2014 """ |
|
2015 Private slot to find the previous item matching the entered criteria. |
|
2016 """ |
|
2017 self.__findItem(True) |
|
2018 |
|
2019 @pyqtSlot() |
|
2020 def on_findNextButton_clicked(self): |
|
2021 """ |
|
2022 Private slot to find the next item matching the entered criteria. |
|
2023 """ |
|
2024 self.__findItem(False) |
|
2025 |
|
2026 def __findItem(self, backwards=False, interactive=False): |
|
2027 """ |
|
2028 Private slot to find an item matching the entered criteria. |
|
2029 |
|
2030 @param backwards flag indicating to search backwards (boolean) |
|
2031 @param interactive flag indicating an interactive search (boolean) |
|
2032 """ |
|
2033 self.__findBackwards = backwards |
|
2034 |
|
2035 fieldIndex, searchRx, indexIsRole = self.__prepareFieldSearch() |
|
2036 currentIndex = self.logTree.indexOfTopLevelItem( |
|
2037 self.logTree.currentItem()) |
|
2038 if backwards: |
|
2039 if interactive: |
|
2040 indexes = range(currentIndex, -1, -1) |
|
2041 else: |
|
2042 indexes = range(currentIndex - 1, -1, -1) |
|
2043 else: |
|
2044 if interactive: |
|
2045 indexes = range(currentIndex, self.logTree.topLevelItemCount()) |
|
2046 else: |
|
2047 indexes = range(currentIndex + 1, |
|
2048 self.logTree.topLevelItemCount()) |
|
2049 |
|
2050 for index in indexes: |
|
2051 topItem = self.logTree.topLevelItem(index) |
|
2052 if indexIsRole: |
|
2053 if fieldIndex == self.__changesRole: |
|
2054 changes = topItem.data(0, self.__changesRole) |
|
2055 txt = "\n".join( |
|
2056 [c["path"] for c in changes] + |
|
2057 [c["copyfrom"] for c in changes] |
|
2058 ) |
|
2059 else: |
|
2060 # Filter based on complete subject text |
|
2061 txt = topItem.data(0, self.__subjectRole) |
|
2062 else: |
|
2063 txt = topItem.text(fieldIndex) |
|
2064 if searchRx.search(txt) is not None: |
|
2065 self.logTree.setCurrentItem(self.logTree.topLevelItem(index)) |
|
2066 break |
|
2067 else: |
|
2068 E5MessageBox.information( |
|
2069 self, |
|
2070 self.tr("Find Commit"), |
|
2071 self.tr("""'{0}' was not found.""").format(self.rxEdit.text())) |
|
2072 |
|
2073 ################################################################## |
|
2074 ## Commit navigation methods below |
|
2075 ################################################################## |
|
2076 |
|
2077 def __commitIdClicked(self, url): |
|
2078 """ |
|
2079 Private slot to handle the anchorClicked signal of the changeset |
|
2080 details pane. |
|
2081 |
|
2082 @param url URL that was clicked |
|
2083 @type QUrl |
|
2084 """ |
|
2085 if url.scheme() == "rev": |
|
2086 # a commit ID was clicked, show the respective item |
|
2087 commitId = url.path() |
|
2088 items = self.logTree.findItems( |
|
2089 commitId, Qt.MatchFlag.MatchStartsWith, self.CommitIdColumn) |
|
2090 if items: |
|
2091 itm = items[0] |
|
2092 if itm.isHidden(): |
|
2093 itm.setHidden(False) |
|
2094 self.logTree.setCurrentItem(itm) |
|
2095 else: |
|
2096 # load the next batch and try again |
|
2097 if self.nextButton.isEnabled(): |
|
2098 self.__addFinishCallback( |
|
2099 lambda: self.__commitIdClicked(url)) |
|
2100 self.on_nextButton_clicked() |
|
2101 |
|
2102 ########################################################################### |
|
2103 ## Diff handling methods below |
|
2104 ########################################################################### |
|
2105 |
|
2106 def __generateDiffs(self, parent=1): |
|
2107 """ |
|
2108 Private slot to generate diff outputs for the selected item. |
|
2109 |
|
2110 @param parent number of parent to diff against |
|
2111 @type int |
|
2112 """ |
|
2113 self.diffEdit.clear() |
|
2114 self.diffLabel.setText(self.tr("Differences")) |
|
2115 self.diffSelectLabel.clear() |
|
2116 with contextlib.suppress(AttributeError): |
|
2117 self.diffHighlighter.regenerateRules() |
|
2118 |
|
2119 selectedItems = self.logTree.selectedItems() |
|
2120 if len(selectedItems) == 1: |
|
2121 currentItem = selectedItems[0] |
|
2122 commit2 = currentItem.text(self.CommitIdColumn) |
|
2123 parents = currentItem.data(0, self.__parentsRole) |
|
2124 if len(parents) >= parent: |
|
2125 self.diffLabel.setText( |
|
2126 self.tr("Differences to Parent {0}").format(parent)) |
|
2127 commit1 = parents[parent - 1] |
|
2128 |
|
2129 self.__diffGenerator.start(self.__filename, [commit1, commit2]) |
|
2130 |
|
2131 if len(parents) > 1: |
|
2132 parentLinks = [] |
|
2133 for index in range(1, len(parents) + 1): |
|
2134 if parent == index: |
|
2135 parentLinks.append(" {0} ".format(index)) |
|
2136 else: |
|
2137 parentLinks.append( |
|
2138 '<a href="diff:{0}"> {0} </a>' |
|
2139 .format(index)) |
|
2140 self.diffSelectLabel.setText( |
|
2141 self.tr('Diff to Parent {0}') |
|
2142 .format(" ".join(parentLinks))) |
|
2143 elif len(selectedItems) == 2: |
|
2144 commit2 = selectedItems[0].text(self.CommitIdColumn) |
|
2145 commit1 = selectedItems[1].text(self.CommitIdColumn) |
|
2146 index2 = self.logTree.indexOfTopLevelItem(selectedItems[0]) |
|
2147 index1 = self.logTree.indexOfTopLevelItem(selectedItems[1]) |
|
2148 |
|
2149 if index2 < index1: |
|
2150 # swap to always compare old to new |
|
2151 commit1, commit2 = commit2, commit1 |
|
2152 |
|
2153 self.__diffGenerator.start(self.__filename, [commit1, commit2]) |
|
2154 |
|
2155 def __generatorFinished(self): |
|
2156 """ |
|
2157 Private slot connected to the finished signal of the diff generator. |
|
2158 """ |
|
2159 diff, _, errors, fileSeparators = self.__diffGenerator.getResult() |
|
2160 |
|
2161 if diff: |
|
2162 self.diffEdit.setPlainText("".join(diff)) |
|
2163 elif errors: |
|
2164 self.diffEdit.setPlainText("".join(errors)) |
|
2165 else: |
|
2166 self.diffEdit.setPlainText(self.tr('There is no difference.')) |
|
2167 |
|
2168 self.saveLabel.setVisible(bool(diff)) |
|
2169 |
|
2170 fileSeparators = self.__mergeFileSeparators(fileSeparators) |
|
2171 if self.__diffUpdatesFiles: |
|
2172 for oldFileName, newFileName, lineNumber, _ in fileSeparators: |
|
2173 if oldFileName == newFileName: |
|
2174 item = QTreeWidgetItem(self.filesTree, ["", oldFileName]) |
|
2175 elif oldFileName == "/dev/null": |
|
2176 item = QTreeWidgetItem(self.filesTree, ["", newFileName]) |
|
2177 else: |
|
2178 item = QTreeWidgetItem( |
|
2179 self.filesTree, ["", newFileName, "", "", oldFileName]) |
|
2180 item.setData(0, self.__diffFileLineRole, lineNumber) |
|
2181 self.__resizeColumnsFiles() |
|
2182 self.__resortFiles() |
|
2183 else: |
|
2184 for oldFileName, newFileName, lineNumber, _ in fileSeparators: |
|
2185 for fileName in (oldFileName, newFileName): |
|
2186 if fileName != "/dev/null": |
|
2187 items = self.filesTree.findItems( |
|
2188 fileName, Qt.MatchFlag.MatchExactly, 1) |
|
2189 for item in items: |
|
2190 item.setData(0, self.__diffFileLineRole, |
|
2191 lineNumber) |
|
2192 |
|
2193 tc = self.diffEdit.textCursor() |
|
2194 tc.movePosition(QTextCursor.MoveOperation.Start) |
|
2195 self.diffEdit.setTextCursor(tc) |
|
2196 self.diffEdit.ensureCursorVisible() |
|
2197 |
|
2198 def __mergeFileSeparators(self, fileSeparators): |
|
2199 """ |
|
2200 Private method to merge the file separator entries. |
|
2201 |
|
2202 @param fileSeparators list of file separator entries to be merged |
|
2203 @return merged list of file separator entries |
|
2204 """ |
|
2205 separators = {} |
|
2206 for oldFile, newFile, pos1, pos2 in sorted(fileSeparators): |
|
2207 if (oldFile, newFile) not in separators: |
|
2208 separators[(oldFile, newFile)] = [oldFile, newFile, pos1, pos2] |
|
2209 else: |
|
2210 if pos1 != -2: |
|
2211 separators[(oldFile, newFile)][2] = pos1 |
|
2212 if pos2 != -2: |
|
2213 separators[(oldFile, newFile)][3] = pos2 |
|
2214 return list(separators.values()) |
|
2215 |
|
2216 @pyqtSlot(QTreeWidgetItem, QTreeWidgetItem) |
|
2217 def on_filesTree_currentItemChanged(self, current, previous): |
|
2218 """ |
|
2219 Private slot called, when the current item of the files tree changes. |
|
2220 |
|
2221 @param current reference to the new current item (QTreeWidgetItem) |
|
2222 @param previous reference to the old current item (QTreeWidgetItem) |
|
2223 """ |
|
2224 if current: |
|
2225 para = current.data(0, self.__diffFileLineRole) |
|
2226 if para is not None: |
|
2227 if para == 0: |
|
2228 tc = self.diffEdit.textCursor() |
|
2229 tc.movePosition(QTextCursor.MoveOperation.Start) |
|
2230 self.diffEdit.setTextCursor(tc) |
|
2231 self.diffEdit.ensureCursorVisible() |
|
2232 elif para == -1: |
|
2233 tc = self.diffEdit.textCursor() |
|
2234 tc.movePosition(QTextCursor.MoveOperation.End) |
|
2235 self.diffEdit.setTextCursor(tc) |
|
2236 self.diffEdit.ensureCursorVisible() |
|
2237 else: |
|
2238 # step 1: move cursor to end |
|
2239 tc = self.diffEdit.textCursor() |
|
2240 tc.movePosition(QTextCursor.MoveOperation.End) |
|
2241 self.diffEdit.setTextCursor(tc) |
|
2242 self.diffEdit.ensureCursorVisible() |
|
2243 |
|
2244 # step 2: move cursor to desired line |
|
2245 tc = self.diffEdit.textCursor() |
|
2246 delta = tc.blockNumber() - para |
|
2247 tc.movePosition(QTextCursor.MoveOperation.PreviousBlock, |
|
2248 QTextCursor.MoveMode.MoveAnchor, delta) |
|
2249 self.diffEdit.setTextCursor(tc) |
|
2250 self.diffEdit.ensureCursorVisible() |
|
2251 |
|
2252 @pyqtSlot(str) |
|
2253 def on_diffSelectLabel_linkActivated(self, link): |
|
2254 """ |
|
2255 Private slot to handle the selection of a diff target. |
|
2256 |
|
2257 @param link activated link |
|
2258 @type str |
|
2259 """ |
|
2260 if ":" in link: |
|
2261 scheme, parent = link.split(":", 1) |
|
2262 if scheme == "diff": |
|
2263 with contextlib.suppress(ValueError): |
|
2264 parent = int(parent) |
|
2265 self.__generateDiffs(parent) |
|
2266 |
|
2267 @pyqtSlot(str) |
|
2268 def on_saveLabel_linkActivated(self, link): |
|
2269 """ |
|
2270 Private slot to handle the selection of the save link. |
|
2271 |
|
2272 @param link activated link |
|
2273 @type str |
|
2274 """ |
|
2275 if ":" not in link: |
|
2276 return |
|
2277 |
|
2278 scheme, rest = link.split(":", 1) |
|
2279 if scheme != "save" or rest != "me": |
|
2280 return |
|
2281 |
|
2282 if self.projectMode: |
|
2283 fname = self.vcs.splitPath(self.__filename)[0] |
|
2284 fname += "/{0}.diff".format(os.path.split(fname)[-1]) |
|
2285 else: |
|
2286 dname, fname = self.vcs.splitPath(self.__filename) |
|
2287 if fname != '.': |
|
2288 fname = "{0}.diff".format(self.__filename) |
|
2289 else: |
|
2290 fname = dname |
|
2291 |
|
2292 fname, selectedFilter = E5FileDialog.getSaveFileNameAndFilter( |
|
2293 self, |
|
2294 self.tr("Save Diff"), |
|
2295 fname, |
|
2296 self.tr("Patch Files (*.diff)"), |
|
2297 None, |
|
2298 E5FileDialog.Options(E5FileDialog.DontConfirmOverwrite)) |
|
2299 |
|
2300 if not fname: |
|
2301 return # user aborted |
|
2302 |
|
2303 ext = QFileInfo(fname).suffix() |
|
2304 if not ext: |
|
2305 ex = selectedFilter.split("(*")[1].split(")")[0] |
|
2306 if ex: |
|
2307 fname += ex |
|
2308 if QFileInfo(fname).exists(): |
|
2309 res = E5MessageBox.yesNo( |
|
2310 self, |
|
2311 self.tr("Save Diff"), |
|
2312 self.tr("<p>The patch file <b>{0}</b> already exists." |
|
2313 " Overwrite it?</p>").format(fname), |
|
2314 icon=E5MessageBox.Warning) |
|
2315 if not res: |
|
2316 return |
|
2317 fname = Utilities.toNativeSeparators(fname) |
|
2318 |
|
2319 eol = e5App().getObject("Project").getEolString() |
|
2320 try: |
|
2321 with open(fname, "w", encoding="utf-8", newline="") as f: |
|
2322 f.write(eol.join(self.diffEdit.toPlainText().splitlines())) |
|
2323 f.write(eol) |
|
2324 except OSError as why: |
|
2325 E5MessageBox.critical( |
|
2326 self, self.tr('Save Diff'), |
|
2327 self.tr( |
|
2328 '<p>The patch file <b>{0}</b> could not be saved.' |
|
2329 '<br>Reason: {1}</p>') |
|
2330 .format(fname, str(why))) |
|
2331 |
|
2332 @pyqtSlot(str) |
|
2333 def on_sbsSelectLabel_linkActivated(self, link): |
|
2334 """ |
|
2335 Private slot to handle selection of a side-by-side link. |
|
2336 |
|
2337 @param link text of the selected link |
|
2338 @type str |
|
2339 """ |
|
2340 if ":" in link: |
|
2341 scheme, path = link.split(":", 1) |
|
2342 if scheme == "sbsdiff" and "_" in path: |
|
2343 commit1, commit2 = path.split("_", 1) |
|
2344 self.vcs.gitSbsDiff(self.__filename, |
|
2345 revisions=(commit1, commit2)) |