Plugins/VcsPlugins/vcsGit/GitLogBrowserDialog.py

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

eric ide

mercurial