src/eric7/Plugins/VcsPlugins/vcsGit/GitLogBrowserDialog.py

branch
eric7
changeset 9209
b99e7fd55fd3
parent 9153
506e35e424d5
child 9221
bf71ee032bb4
equal deleted inserted replaced
9208:3fc8dfeb6ebe 9209:b99e7fd55fd3
1 # -*- coding: utf-8 -*-
2
3 # Copyright (c) 2014 - 2022 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 import pathlib
15
16 from PyQt6.QtCore import (
17 pyqtSlot, Qt, QDate, QProcess, QTimer, QSize, QPoint
18 )
19 from PyQt6.QtGui import (
20 QColor, QPixmap, QPainter, QPen, QIcon, QTextCursor, QPalette
21 )
22 from PyQt6.QtWidgets import (
23 QWidget, QDialogButtonBox, QHeaderView, QTreeWidgetItem, QApplication,
24 QLineEdit, QMenu, QInputDialog
25 )
26
27 from EricWidgets.EricApplication import ericApp
28 from EricWidgets import EricMessageBox, EricFileDialog
29 from EricGui.EricOverrideCursor import EricOverrideCursorProcess
30
31 from Globals import strToQByteArray
32
33 from .Ui_GitLogBrowserDialog import Ui_GitLogBrowserDialog
34
35 from .GitDiffHighlighter import GitDiffHighlighter
36 from .GitDiffGenerator import GitDiffGenerator
37
38 import UI.PixmapCache
39 import Preferences
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 = ericApp().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 = EricOverrideCursorProcess()
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 EricMessageBox.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 EricMessageBox.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 = (
1713 pathlib.Path(ericApp().getObject("Project").getProjectFile())
1714 )
1715 lastModified = pfile.stat().st_mtime
1716 shouldReopen = (
1717 self.vcs.gitCherryPick(
1718 self.repodir,
1719 [commits[i] for i in sorted(commits.keys(), reverse=True)]
1720 ) or
1721 pfile.stat().st_mtime != lastModified
1722 )
1723 if shouldReopen:
1724 res = EricMessageBox.yesNo(
1725 None,
1726 self.tr("Copy Changesets"),
1727 self.tr(
1728 """The project should be reread. Do this now?"""),
1729 yesDefault=True)
1730 if res:
1731 ericApp().getObject("Project").reopenProject()
1732 return
1733
1734 self.on_refreshButton_clicked()
1735
1736 @pyqtSlot()
1737 def __tagActTriggered(self):
1738 """
1739 Private slot to tag the selected commit.
1740 """
1741 if len(self.logTree.selectedItems()) == 1:
1742 itm = self.logTree.selectedItems()[0]
1743 commit = itm.text(self.CommitIdColumn)
1744 tag = itm.text(self.TagsColumn).strip().split(", ", 1)[0]
1745 res = self.vcs.vcsTag(self.repodir, revision=commit, tagName=tag)
1746 if res:
1747 self.on_refreshButton_clicked()
1748
1749 @pyqtSlot()
1750 def __switchActTriggered(self):
1751 """
1752 Private slot to switch the working directory to the
1753 selected commit.
1754 """
1755 if len(self.logTree.selectedItems()) == 1:
1756 itm = self.logTree.selectedItems()[0]
1757 commit = itm.text(self.CommitIdColumn)
1758 branches = [b for b in itm.text(self.BranchColumn).split(", ")
1759 if "/" not in b]
1760 if len(branches) == 1:
1761 branch = branches[0]
1762 elif len(branches) > 1:
1763 branch, ok = QInputDialog.getItem(
1764 self,
1765 self.tr("Switch"),
1766 self.tr("Select a branch"),
1767 [""] + branches,
1768 0, False)
1769 if not ok:
1770 return
1771 else:
1772 branch = ""
1773 if branch:
1774 rev = branch
1775 else:
1776 rev = commit
1777 pfile = (
1778 pathlib.Path(ericApp().getObject("Project").getProjectFile())
1779 )
1780 lastModified = pfile.stat().st_mtime
1781 shouldReopen = (
1782 self.vcs.vcsUpdate(self.repodir, revision=rev) or
1783 pfile.stat().st_mtime != lastModified
1784 )
1785 if shouldReopen:
1786 res = EricMessageBox.yesNo(
1787 None,
1788 self.tr("Switch"),
1789 self.tr(
1790 """The project should be reread. Do this now?"""),
1791 yesDefault=True)
1792 if res:
1793 ericApp().getObject("Project").reopenProject()
1794 return
1795
1796 self.on_refreshButton_clicked()
1797
1798 @pyqtSlot()
1799 def __branchActTriggered(self):
1800 """
1801 Private slot to create a new branch starting at the selected commit.
1802 """
1803 if len(self.logTree.selectedItems()) == 1:
1804 from .GitBranchDialog import GitBranchDialog
1805 itm = self.logTree.selectedItems()[0]
1806 commit = itm.text(self.CommitIdColumn)
1807 branches = [b for b in itm.text(self.BranchColumn).split(", ")
1808 if "/" not in b]
1809 if len(branches) == 1:
1810 branch = branches[0]
1811 elif len(branches) > 1:
1812 branch, ok = QInputDialog.getItem(
1813 self,
1814 self.tr("Branch"),
1815 self.tr("Select a default branch"),
1816 [""] + branches,
1817 0, False)
1818 if not ok:
1819 return
1820 else:
1821 branch = ""
1822 res = self.vcs.gitBranch(
1823 self.repodir, revision=commit, branchName=branch,
1824 branchOp=GitBranchDialog.CreateBranch)
1825 if res:
1826 self.on_refreshButton_clicked()
1827
1828 @pyqtSlot()
1829 def __branchSwitchActTriggered(self):
1830 """
1831 Private slot to create a new branch starting at the selected commit
1832 and switch the work tree to it.
1833 """
1834 if len(self.logTree.selectedItems()) == 1:
1835 from .GitBranchDialog import GitBranchDialog
1836 itm = self.logTree.selectedItems()[0]
1837 commit = itm.text(self.CommitIdColumn)
1838 branches = [b for b in itm.text(self.BranchColumn).split(", ")
1839 if "/" not in b]
1840 if len(branches) == 1:
1841 branch = branches[0]
1842 elif len(branches) > 1:
1843 branch, ok = QInputDialog.getItem(
1844 self,
1845 self.tr("Branch & Switch"),
1846 self.tr("Select a default branch"),
1847 [""] + branches,
1848 0, False)
1849 if not ok:
1850 return
1851 else:
1852 branch = ""
1853 pfile = (
1854 pathlib.Path(ericApp().getObject("Project").getProjectFile())
1855 )
1856 lastModified = pfile.stat().st_mtime
1857 res, shouldReopen = self.vcs.gitBranch(
1858 self.repodir, revision=commit, branchName=branch,
1859 branchOp=GitBranchDialog.CreateSwitchBranch)
1860 shouldReopen |= pfile.stat().st_mtime != lastModified
1861 if res:
1862 if shouldReopen:
1863 res = EricMessageBox.yesNo(
1864 None,
1865 self.tr("Switch"),
1866 self.tr(
1867 """The project should be reread. Do this now?"""),
1868 yesDefault=True)
1869 if res:
1870 ericApp().getObject("Project").reopenProject()
1871 return
1872
1873 self.on_refreshButton_clicked()
1874
1875 @pyqtSlot()
1876 def __shortlogActTriggered(self):
1877 """
1878 Private slot to show a short log suitable for release announcements.
1879 """
1880 if len(self.logTree.selectedItems()) == 1:
1881 itm = self.logTree.selectedItems()[0]
1882 commit = itm.text(self.CommitIdColumn)
1883 branch = itm.text(self.BranchColumn).split(", ", 1)[0]
1884 branches = [b for b in itm.text(self.BranchColumn).split(", ")
1885 if "/" not in b]
1886 if len(branches) == 1:
1887 branch = branches[0]
1888 elif len(branches) > 1:
1889 branch, ok = QInputDialog.getItem(
1890 self,
1891 self.tr("Show Short Log"),
1892 self.tr("Select a branch"),
1893 [""] + branches,
1894 0, False)
1895 if not ok:
1896 return
1897 else:
1898 branch = ""
1899 if branch:
1900 rev = branch
1901 else:
1902 rev = commit
1903 self.vcs.gitShortlog(self.repodir, commit=rev)
1904
1905 @pyqtSlot()
1906 def __describeActTriggered(self):
1907 """
1908 Private slot to show the most recent tag reachable from a commit.
1909 """
1910 commits = []
1911
1912 for itm in self.logTree.selectedItems():
1913 commits.append(itm.text(self.CommitIdColumn))
1914
1915 if commits:
1916 self.vcs.gitDescribe(self.repodir, commits)
1917
1918 ##################################################################
1919 ## Log context menu action methods below
1920 ##################################################################
1921
1922 @pyqtSlot(bool)
1923 def __showCommitterColumns(self, on):
1924 """
1925 Private slot to show/hide the committer columns.
1926
1927 @param on flag indicating the selection state (boolean)
1928 """
1929 self.logTree.setColumnHidden(self.CommitterColumn, not on)
1930 self.logTree.setColumnHidden(self.CommitDateColumn, not on)
1931 self.vcs.getPlugin().setPreferences("ShowCommitterColumns", on)
1932 self.__resizeColumnsLog()
1933
1934 @pyqtSlot(bool)
1935 def __showAuthorColumns(self, on):
1936 """
1937 Private slot to show/hide the committer columns.
1938
1939 @param on flag indicating the selection state (boolean)
1940 """
1941 self.logTree.setColumnHidden(self.AuthorColumn, not on)
1942 self.logTree.setColumnHidden(self.DateColumn, not on)
1943 self.vcs.getPlugin().setPreferences("ShowAuthorColumns", on)
1944 self.__resizeColumnsLog()
1945
1946 @pyqtSlot(bool)
1947 def __showCommitIdColumn(self, on):
1948 """
1949 Private slot to show/hide the commit ID column.
1950
1951 @param on flag indicating the selection state (boolean)
1952 """
1953 self.logTree.setColumnHidden(self.CommitIdColumn, not on)
1954 self.vcs.getPlugin().setPreferences("ShowCommitIdColumn", on)
1955 self.__resizeColumnsLog()
1956
1957 @pyqtSlot(bool)
1958 def __showBranchesColumn(self, on):
1959 """
1960 Private slot to show/hide the branches column.
1961
1962 @param on flag indicating the selection state (boolean)
1963 """
1964 self.logTree.setColumnHidden(self.BranchColumn, not on)
1965 self.vcs.getPlugin().setPreferences("ShowBranchesColumn", on)
1966 self.__resizeColumnsLog()
1967
1968 @pyqtSlot(bool)
1969 def __showTagsColumn(self, on):
1970 """
1971 Private slot to show/hide the tags column.
1972
1973 @param on flag indicating the selection state (boolean)
1974 """
1975 self.logTree.setColumnHidden(self.TagsColumn, not on)
1976 self.vcs.getPlugin().setPreferences("ShowTagsColumn", on)
1977 self.__resizeColumnsLog()
1978
1979 ##################################################################
1980 ## Search and filter methods below
1981 ##################################################################
1982
1983 def __actionMode(self):
1984 """
1985 Private method to get the selected action mode.
1986
1987 @return selected action mode (string, one of filter or find)
1988 """
1989 return self.modeComboBox.itemData(
1990 self.modeComboBox.currentIndex())
1991
1992 @pyqtSlot(int)
1993 def on_modeComboBox_currentIndexChanged(self, index):
1994 """
1995 Private slot to react on mode changes.
1996
1997 @param index index of the selected entry (integer)
1998 """
1999 mode = self.modeComboBox.itemData(index)
2000 findMode = mode == "find"
2001 filterMode = mode == "filter"
2002
2003 self.fromDate.setEnabled(filterMode)
2004 self.toDate.setEnabled(filterMode)
2005 self.findPrevButton.setVisible(findMode)
2006 self.findNextButton.setVisible(findMode)
2007
2008 if findMode:
2009 for topIndex in range(self.logTree.topLevelItemCount()):
2010 self.logTree.topLevelItem(topIndex).setHidden(False)
2011 self.logTree.header().setSectionHidden(self.IconColumn, False)
2012 elif filterMode:
2013 self.__filterLogs()
2014
2015 @pyqtSlot()
2016 def on_findPrevButton_clicked(self):
2017 """
2018 Private slot to find the previous item matching the entered criteria.
2019 """
2020 self.__findItem(True)
2021
2022 @pyqtSlot()
2023 def on_findNextButton_clicked(self):
2024 """
2025 Private slot to find the next item matching the entered criteria.
2026 """
2027 self.__findItem(False)
2028
2029 def __findItem(self, backwards=False, interactive=False):
2030 """
2031 Private slot to find an item matching the entered criteria.
2032
2033 @param backwards flag indicating to search backwards (boolean)
2034 @param interactive flag indicating an interactive search (boolean)
2035 """
2036 self.__findBackwards = backwards
2037
2038 fieldIndex, searchRx, indexIsRole = self.__prepareFieldSearch()
2039 currentIndex = self.logTree.indexOfTopLevelItem(
2040 self.logTree.currentItem())
2041 if backwards:
2042 if interactive:
2043 indexes = range(currentIndex, -1, -1)
2044 else:
2045 indexes = range(currentIndex - 1, -1, -1)
2046 else:
2047 if interactive:
2048 indexes = range(currentIndex, self.logTree.topLevelItemCount())
2049 else:
2050 indexes = range(currentIndex + 1,
2051 self.logTree.topLevelItemCount())
2052
2053 for index in indexes:
2054 topItem = self.logTree.topLevelItem(index)
2055 if indexIsRole:
2056 if fieldIndex == self.__changesRole:
2057 changes = topItem.data(0, self.__changesRole)
2058 txt = "\n".join(
2059 [c["path"] for c in changes] +
2060 [c["copyfrom"] for c in changes]
2061 )
2062 else:
2063 # Filter based on complete subject text
2064 txt = topItem.data(0, self.__subjectRole)
2065 else:
2066 txt = topItem.text(fieldIndex)
2067 if searchRx.search(txt) is not None:
2068 self.logTree.setCurrentItem(self.logTree.topLevelItem(index))
2069 break
2070 else:
2071 EricMessageBox.information(
2072 self,
2073 self.tr("Find Commit"),
2074 self.tr("""'{0}' was not found.""").format(self.rxEdit.text()))
2075
2076 ##################################################################
2077 ## Commit navigation methods below
2078 ##################################################################
2079
2080 def __commitIdClicked(self, url):
2081 """
2082 Private slot to handle the anchorClicked signal of the changeset
2083 details pane.
2084
2085 @param url URL that was clicked
2086 @type QUrl
2087 """
2088 if url.scheme() == "rev":
2089 # a commit ID was clicked, show the respective item
2090 commitId = url.path()
2091 items = self.logTree.findItems(
2092 commitId, Qt.MatchFlag.MatchStartsWith, self.CommitIdColumn)
2093 if items:
2094 itm = items[0]
2095 if itm.isHidden():
2096 itm.setHidden(False)
2097 self.logTree.setCurrentItem(itm)
2098 else:
2099 # load the next batch and try again
2100 if self.nextButton.isEnabled():
2101 self.__addFinishCallback(
2102 lambda: self.__commitIdClicked(url))
2103 self.on_nextButton_clicked()
2104
2105 ###########################################################################
2106 ## Diff handling methods below
2107 ###########################################################################
2108
2109 def __generateDiffs(self, parent=1):
2110 """
2111 Private slot to generate diff outputs for the selected item.
2112
2113 @param parent number of parent to diff against
2114 @type int
2115 """
2116 self.diffEdit.clear()
2117 self.diffLabel.setText(self.tr("Differences"))
2118 self.diffSelectLabel.clear()
2119 with contextlib.suppress(AttributeError):
2120 self.diffHighlighter.regenerateRules()
2121
2122 selectedItems = self.logTree.selectedItems()
2123 if len(selectedItems) == 1:
2124 currentItem = selectedItems[0]
2125 commit2 = currentItem.text(self.CommitIdColumn)
2126 parents = currentItem.data(0, self.__parentsRole)
2127 if len(parents) >= parent:
2128 self.diffLabel.setText(
2129 self.tr("Differences to Parent {0}").format(parent))
2130 commit1 = parents[parent - 1]
2131
2132 self.__diffGenerator.start(self.__filename, [commit1, commit2])
2133
2134 if len(parents) > 1:
2135 parentLinks = []
2136 for index in range(1, len(parents) + 1):
2137 if parent == index:
2138 parentLinks.append("&nbsp;{0}&nbsp;".format(index))
2139 else:
2140 parentLinks.append(
2141 '<a href="diff:{0}">&nbsp;{0}&nbsp;</a>'
2142 .format(index))
2143 self.diffSelectLabel.setText(
2144 self.tr('Diff to Parent {0}')
2145 .format(" ".join(parentLinks)))
2146 elif len(selectedItems) == 2:
2147 commit2 = selectedItems[0].text(self.CommitIdColumn)
2148 commit1 = selectedItems[1].text(self.CommitIdColumn)
2149 index2 = self.logTree.indexOfTopLevelItem(selectedItems[0])
2150 index1 = self.logTree.indexOfTopLevelItem(selectedItems[1])
2151
2152 if index2 < index1:
2153 # swap to always compare old to new
2154 commit1, commit2 = commit2, commit1
2155
2156 self.__diffGenerator.start(self.__filename, [commit1, commit2])
2157
2158 def __generatorFinished(self):
2159 """
2160 Private slot connected to the finished signal of the diff generator.
2161 """
2162 diff, _, errors, fileSeparators = self.__diffGenerator.getResult()
2163
2164 if diff:
2165 self.diffEdit.setPlainText("".join(diff))
2166 elif errors:
2167 self.diffEdit.setPlainText("".join(errors))
2168 else:
2169 self.diffEdit.setPlainText(self.tr('There is no difference.'))
2170
2171 self.saveLabel.setVisible(bool(diff))
2172
2173 fileSeparators = self.__mergeFileSeparators(fileSeparators)
2174 if self.__diffUpdatesFiles:
2175 for oldFileName, newFileName, lineNumber, _ in fileSeparators:
2176 if oldFileName == newFileName:
2177 item = QTreeWidgetItem(self.filesTree, ["", oldFileName])
2178 elif oldFileName == "/dev/null":
2179 item = QTreeWidgetItem(self.filesTree, ["", newFileName])
2180 else:
2181 item = QTreeWidgetItem(
2182 self.filesTree, ["", newFileName, "", "", oldFileName])
2183 item.setData(0, self.__diffFileLineRole, lineNumber)
2184 self.__resizeColumnsFiles()
2185 self.__resortFiles()
2186 else:
2187 for oldFileName, newFileName, lineNumber, _ in fileSeparators:
2188 for fileName in (oldFileName, newFileName):
2189 if fileName != "/dev/null":
2190 items = self.filesTree.findItems(
2191 fileName, Qt.MatchFlag.MatchExactly, 1)
2192 for item in items:
2193 item.setData(0, self.__diffFileLineRole,
2194 lineNumber)
2195
2196 tc = self.diffEdit.textCursor()
2197 tc.movePosition(QTextCursor.MoveOperation.Start)
2198 self.diffEdit.setTextCursor(tc)
2199 self.diffEdit.ensureCursorVisible()
2200
2201 def __mergeFileSeparators(self, fileSeparators):
2202 """
2203 Private method to merge the file separator entries.
2204
2205 @param fileSeparators list of file separator entries to be merged
2206 @return merged list of file separator entries
2207 """
2208 separators = {}
2209 for oldFile, newFile, pos1, pos2 in sorted(fileSeparators):
2210 if (oldFile, newFile) not in separators:
2211 separators[(oldFile, newFile)] = [oldFile, newFile, pos1, pos2]
2212 else:
2213 if pos1 != -2:
2214 separators[(oldFile, newFile)][2] = pos1
2215 if pos2 != -2:
2216 separators[(oldFile, newFile)][3] = pos2
2217 return list(separators.values())
2218
2219 @pyqtSlot(QTreeWidgetItem, QTreeWidgetItem)
2220 def on_filesTree_currentItemChanged(self, current, previous):
2221 """
2222 Private slot called, when the current item of the files tree changes.
2223
2224 @param current reference to the new current item (QTreeWidgetItem)
2225 @param previous reference to the old current item (QTreeWidgetItem)
2226 """
2227 if current:
2228 para = current.data(0, self.__diffFileLineRole)
2229 if para is not None:
2230 if para == 0:
2231 tc = self.diffEdit.textCursor()
2232 tc.movePosition(QTextCursor.MoveOperation.Start)
2233 self.diffEdit.setTextCursor(tc)
2234 self.diffEdit.ensureCursorVisible()
2235 elif para == -1:
2236 tc = self.diffEdit.textCursor()
2237 tc.movePosition(QTextCursor.MoveOperation.End)
2238 self.diffEdit.setTextCursor(tc)
2239 self.diffEdit.ensureCursorVisible()
2240 else:
2241 # step 1: move cursor to end
2242 tc = self.diffEdit.textCursor()
2243 tc.movePosition(QTextCursor.MoveOperation.End)
2244 self.diffEdit.setTextCursor(tc)
2245 self.diffEdit.ensureCursorVisible()
2246
2247 # step 2: move cursor to desired line
2248 tc = self.diffEdit.textCursor()
2249 delta = tc.blockNumber() - para
2250 tc.movePosition(QTextCursor.MoveOperation.PreviousBlock,
2251 QTextCursor.MoveMode.MoveAnchor, delta)
2252 self.diffEdit.setTextCursor(tc)
2253 self.diffEdit.ensureCursorVisible()
2254
2255 @pyqtSlot(str)
2256 def on_diffSelectLabel_linkActivated(self, link):
2257 """
2258 Private slot to handle the selection of a diff target.
2259
2260 @param link activated link
2261 @type str
2262 """
2263 if ":" in link:
2264 scheme, parent = link.split(":", 1)
2265 if scheme == "diff":
2266 with contextlib.suppress(ValueError):
2267 parent = int(parent)
2268 self.__generateDiffs(parent)
2269
2270 @pyqtSlot(str)
2271 def on_saveLabel_linkActivated(self, link):
2272 """
2273 Private slot to handle the selection of the save link.
2274
2275 @param link activated link
2276 @type str
2277 """
2278 if ":" not in link:
2279 return
2280
2281 scheme, rest = link.split(":", 1)
2282 if scheme != "save" or rest != "me":
2283 return
2284
2285 if self.projectMode:
2286 fname = self.vcs.splitPath(self.__filename)[0]
2287 fname += "/{0}.diff".format(os.path.split(fname)[-1])
2288 else:
2289 dname, fname = self.vcs.splitPath(self.__filename)
2290 if fname != '.':
2291 fname = "{0}.diff".format(self.__filename)
2292 else:
2293 fname = dname
2294
2295 fname, selectedFilter = EricFileDialog.getSaveFileNameAndFilter(
2296 self,
2297 self.tr("Save Diff"),
2298 fname,
2299 self.tr("Patch Files (*.diff)"),
2300 None,
2301 EricFileDialog.DontConfirmOverwrite)
2302
2303 if not fname:
2304 return # user aborted
2305
2306 fpath = pathlib.Path(fname)
2307 if not fpath.suffix:
2308 ex = selectedFilter.split("(*")[1].split(")")[0]
2309 if ex:
2310 fpath = fpath.with_suffix(ex)
2311 if fpath.exists():
2312 res = EricMessageBox.yesNo(
2313 self,
2314 self.tr("Save Diff"),
2315 self.tr("<p>The patch file <b>{0}</b> already exists."
2316 " Overwrite it?</p>").format(fpath),
2317 icon=EricMessageBox.Warning)
2318 if not res:
2319 return
2320
2321 eol = ericApp().getObject("Project").getEolString()
2322 try:
2323 with fpath.open("w", encoding="utf-8", newline="") as f:
2324 f.write(eol.join(self.diffEdit.toPlainText().splitlines()))
2325 f.write(eol)
2326 except OSError as why:
2327 EricMessageBox.critical(
2328 self, self.tr('Save Diff'),
2329 self.tr(
2330 '<p>The patch file <b>{0}</b> could not be saved.'
2331 '<br>Reason: {1}</p>')
2332 .format(fpath, str(why)))
2333
2334 @pyqtSlot(str)
2335 def on_sbsSelectLabel_linkActivated(self, link):
2336 """
2337 Private slot to handle selection of a side-by-side link.
2338
2339 @param link text of the selected link
2340 @type str
2341 """
2342 if ":" in link:
2343 scheme, path = link.split(":", 1)
2344 if scheme == "sbsdiff" and "_" in path:
2345 commit1, commit2 = path.split("_", 1)
2346 self.vcs.vcsSbsDiff(self.__filename,
2347 revisions=(commit1, commit2))

eric ide

mercurial