eric7/Plugins/VcsPlugins/vcsGit/GitLogBrowserDialog.py

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

eric ide

mercurial