eric6/Plugins/VcsPlugins/vcsGit/GitLogBrowserDialog.py

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

eric ide

mercurial