eric7/Plugins/VcsPlugins/vcsMercurial/HgLogBrowserDialog.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) 2010 - 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 re
12 import collections
13 import contextlib
14
15 from PyQt5.QtCore import pyqtSlot, Qt, QDate, QSize, QPoint, QFileInfo
16 from PyQt5.QtGui import (
17 QColor, QPixmap, QPainter, QPen, QBrush, QIcon, QTextCursor, QPalette
18 )
19 from PyQt5.QtWidgets import (
20 QWidget, QDialogButtonBox, QHeaderView, QTreeWidgetItem, QApplication,
21 QLineEdit, QMenu, QInputDialog
22 )
23
24 from E5Gui.E5Application import e5App
25 from E5Gui import E5MessageBox, E5FileDialog
26 from E5Gui.E5OverrideCursor import E5OverrideCursor
27
28 from .Ui_HgLogBrowserDialog import Ui_HgLogBrowserDialog
29
30 from .HgDiffHighlighter import HgDiffHighlighter
31 from .HgDiffGenerator import HgDiffGenerator
32
33 import UI.PixmapCache
34 import Preferences
35 import Utilities
36
37 COLORNAMES = ["blue", "darkgreen", "red", "green", "darkblue", "purple",
38 "cyan", "olive", "magenta", "darkred", "darkmagenta",
39 "darkcyan", "gray", "yellow"]
40 COLORS = [str(QColor(x).name()) for x in COLORNAMES]
41
42 LIGHTCOLORS = ["#aaaaff", "#7faa7f", "#ffaaaa", "#aaffaa", "#7f7faa",
43 "#ffaaff", "#aaffff", "#d5d579", "#ffaaff", "#d57979",
44 "#d579d5", "#79d5d5", "#d5d5d5", "#d5d500",
45 ]
46
47
48 class HgLogBrowserDialog(QWidget, Ui_HgLogBrowserDialog):
49 """
50 Class implementing a dialog to browse the log history.
51 """
52 IconColumn = 0
53 BranchColumn = 1
54 RevisionColumn = 2
55 PhaseColumn = 3
56 AuthorColumn = 4
57 DateColumn = 5
58 MessageColumn = 6
59 TagsColumn = 7
60 BookmarksColumn = 8
61
62 LargefilesCacheL = ".hglf/"
63 LargefilesCacheW = ".hglf\\"
64 PathSeparatorRe = re.compile(r"/|\\")
65
66 ClosedIndicator = " \u2612"
67
68 def __init__(self, vcs, mode="", parent=None):
69 """
70 Constructor
71
72 @param vcs reference to the vcs object
73 @type Hg
74 @param mode mode of the dialog
75 @type str (one of log, full_log, incoming, outgoing)
76 @param parent parent widget
77 @type QWidget
78 """
79 super().__init__(parent)
80 self.setupUi(self)
81
82 windowFlags = self.windowFlags()
83 windowFlags |= Qt.WindowType.WindowContextHelpButtonHint
84 self.setWindowFlags(windowFlags)
85
86 self.mainSplitter.setSizes([300, 400])
87 self.mainSplitter.setStretchFactor(0, 1)
88 self.mainSplitter.setStretchFactor(1, 2)
89 self.diffSplitter.setStretchFactor(0, 1)
90 self.diffSplitter.setStretchFactor(1, 2)
91
92 if not mode:
93 if vcs.getPlugin().getPreferences("LogBrowserShowFullLog"):
94 mode = "full_log"
95 else:
96 mode = "log"
97
98 if mode == "log":
99 self.setWindowTitle(self.tr("Mercurial Log"))
100 elif mode == "incoming":
101 self.setWindowTitle(self.tr("Mercurial Log (Incoming)"))
102 elif mode == "outgoing":
103 self.setWindowTitle(self.tr("Mercurial Log (Outgoing)"))
104 elif mode == "full_log":
105 self.setWindowTitle(self.tr("Mercurial Full Log"))
106
107 self.buttonBox.button(
108 QDialogButtonBox.StandardButton.Close).setEnabled(False)
109 self.buttonBox.button(
110 QDialogButtonBox.StandardButton.Cancel).setDefault(True)
111
112 self.filesTree.headerItem().setText(self.filesTree.columnCount(), "")
113 self.filesTree.header().setSortIndicator(
114 0, Qt.SortOrder.AscendingOrder)
115
116 self.upButton.setIcon(UI.PixmapCache.getIcon("1uparrow"))
117 self.downButton.setIcon(UI.PixmapCache.getIcon("1downarrow"))
118
119 self.refreshButton = self.buttonBox.addButton(
120 self.tr("&Refresh"), QDialogButtonBox.ButtonRole.ActionRole)
121 self.refreshButton.setToolTip(
122 self.tr("Press to refresh the list of changesets"))
123 self.refreshButton.setEnabled(False)
124
125 self.findPrevButton.setIcon(UI.PixmapCache.getIcon("1leftarrow"))
126 self.findNextButton.setIcon(UI.PixmapCache.getIcon("1rightarrow"))
127 self.__findBackwards = False
128
129 self.modeComboBox.addItem(self.tr("Find"), "find")
130 self.modeComboBox.addItem(self.tr("Filter"), "filter")
131
132 self.fieldCombo.addItem(self.tr("Revision"), "revision")
133 self.fieldCombo.addItem(self.tr("Author"), "author")
134 self.fieldCombo.addItem(self.tr("Message"), "message")
135 self.fieldCombo.addItem(self.tr("File"), "file")
136 self.fieldCombo.addItem(self.tr("Phase"), "phase")
137
138 font = Preferences.getEditorOtherFonts("MonospacedFont")
139 self.diffEdit.document().setDefaultFont(font)
140
141 self.diffHighlighter = HgDiffHighlighter(self.diffEdit.document())
142 self.__diffGenerator = HgDiffGenerator(vcs, self)
143 self.__diffGenerator.finished.connect(self.__generatorFinished)
144
145 self.vcs = vcs
146 if mode in ("log", "incoming", "outgoing", "full_log"):
147 if mode == "full_log":
148 self.commandMode = "incoming"
149 else:
150 self.commandMode = mode
151 self.initialCommandMode = mode
152 else:
153 self.commandMode = "log"
154 self.initialCommandMode = "log"
155 self.__hgClient = vcs.getClient()
156
157 self.__detailsTemplate = self.tr(
158 "<table>"
159 "<tr><td><b>Revision</b></td><td>{0}</td></tr>"
160 "<tr><td><b>Date</b></td><td>{1}</td></tr>"
161 "<tr><td><b>Author</b></td><td>{2}</td></tr>"
162 "<tr><td><b>Branch</b></td><td>{3}</td></tr>"
163 "{4}"
164 "<tr><td><b>Message</b></td><td>{5}</td></tr>"
165 "</table>"
166 )
167 self.__parentsTemplate = self.tr(
168 "<tr><td><b>Parents</b></td><td>{0}</td></tr>"
169 )
170 self.__childrenTemplate = self.tr(
171 "<tr><td><b>Children</b></td><td>{0}</td></tr>"
172 )
173 self.__tagsTemplate = self.tr(
174 "<tr><td><b>Tags</b></td><td>{0}</td></tr>"
175 )
176 self.__latestTagTemplate = self.tr(
177 "<tr><td><b>Latest Tag</b></td><td>{0}</td></tr>"
178 )
179 self.__bookmarksTemplate = self.tr(
180 "<tr><td><b>Bookmarks</b></td><td>{0}</td></tr>"
181 )
182
183 self.__bundle = ""
184 self.__filename = ""
185 self.__isFile = False
186 self.__selectedRevisions = []
187 self.intercept = False
188
189 self.__initData()
190
191 self.__allBranchesFilter = self.tr("All")
192
193 self.fromDate.setDisplayFormat("yyyy-MM-dd")
194 self.toDate.setDisplayFormat("yyyy-MM-dd")
195 self.__resetUI()
196
197 # roles used in the log tree
198 self.__messageRole = Qt.ItemDataRole.UserRole
199 self.__changesRole = Qt.ItemDataRole.UserRole + 1
200 self.__edgesRole = Qt.ItemDataRole.UserRole + 2
201 self.__parentsRole = Qt.ItemDataRole.UserRole + 3
202 self.__latestTagRole = Qt.ItemDataRole.UserRole + 4
203 self.__incomingRole = Qt.ItemDataRole.UserRole + 5
204
205 # roles used in the file tree
206 self.__diffFileLineRole = Qt.ItemDataRole.UserRole
207
208 self.flags = {
209 'A': self.tr('Added'),
210 'D': self.tr('Deleted'),
211 'M': self.tr('Modified'),
212 }
213
214 self.phases = {
215 'draft': self.tr("Draft"),
216 'public': self.tr("Public"),
217 'secret': self.tr("Secret"),
218 }
219
220 self.__dotRadius = 8
221 self.__rowHeight = 20
222
223 self.logTree.setIconSize(
224 QSize(100 * self.__rowHeight, self.__rowHeight))
225 self.BookmarksColumn = self.logTree.columnCount()
226 self.logTree.headerItem().setText(
227 self.BookmarksColumn, self.tr("Bookmarks"))
228
229 self.__logTreeNormalFont = self.logTree.font()
230 self.__logTreeNormalFont.setBold(False)
231 self.__logTreeBoldFont = self.logTree.font()
232 self.__logTreeBoldFont.setBold(True)
233 self.__logTreeHasDarkBackground = e5App().usesDarkPalette()
234
235 self.detailsEdit.anchorClicked.connect(self.__revisionClicked)
236
237 self.__initActionsMenu()
238
239 self.__finishCallbacks = []
240 if self.initialCommandMode == "full_log":
241 self.__addFinishCallback(self.on_nextButton_clicked)
242
243 def __addFinishCallback(self, callback):
244 """
245 Private method to add a method to be called once the process finished.
246
247 The callback methods are invoke in a FIFO style and are consumed. If
248 a callback method needs to be called again, it must be added again.
249
250 @param callback callback method
251 @type function
252 """
253 if callback not in self.__finishCallbacks:
254 self.__finishCallbacks.append(callback)
255
256 def __initActionsMenu(self):
257 """
258 Private method to initialize the actions menu.
259 """
260 self.__actionsMenu = QMenu()
261 self.__actionsMenu.setTearOffEnabled(True)
262 self.__actionsMenu.setToolTipsVisible(True)
263
264 self.__graftAct = self.__actionsMenu.addAction(
265 UI.PixmapCache.getIcon("vcsGraft"),
266 self.tr("Copy Changesets"), self.__graftActTriggered)
267 self.__graftAct.setToolTip(self.tr(
268 "Copy the selected changesets to the current branch"))
269
270 self.__mergeAct = self.__actionsMenu.addAction(
271 UI.PixmapCache.getIcon("vcsMerge"),
272 self.tr("Merge with Changeset"), self.__mergeActTriggered)
273 self.__mergeAct.setToolTip(self.tr(
274 "Merge the working directory with the selected changeset"))
275
276 self.__phaseAct = self.__actionsMenu.addAction(
277 self.tr("Change Phase"), self.__phaseActTriggered)
278 self.__phaseAct.setToolTip(self.tr(
279 "Change the phase of the selected revisions"))
280 self.__phaseAct.setWhatsThis(self.tr(
281 """<b>Change Phase</b>\n<p>This changes the phase of the"""
282 """ selected revisions. The selected revisions have to have"""
283 """ the same current phase.</p>"""))
284
285 self.__tagAct = self.__actionsMenu.addAction(
286 UI.PixmapCache.getIcon("vcsTag"), self.tr("Tag"),
287 self.__tagActTriggered)
288 self.__tagAct.setToolTip(self.tr("Tag the selected revision"))
289
290 self.__closeHeadsAct = self.__actionsMenu.addAction(
291 UI.PixmapCache.getIcon("closehead"), self.tr("Close Heads"),
292 self.__closeHeadsActTriggered)
293 self.__closeHeadsAct.setToolTip(self.tr("Close the selected heads"))
294
295 self.__switchAct = self.__actionsMenu.addAction(
296 UI.PixmapCache.getIcon("vcsSwitch"), self.tr("Switch"),
297 self.__switchActTriggered)
298 self.__switchAct.setToolTip(self.tr(
299 "Switch the working directory to the selected revision"))
300
301 self.__actionsMenu.addSeparator()
302
303 self.__bookmarkAct = self.__actionsMenu.addAction(
304 UI.PixmapCache.getIcon("addBookmark"),
305 self.tr("Define Bookmark..."), self.__bookmarkActTriggered)
306 self.__bookmarkAct.setToolTip(
307 self.tr("Bookmark the selected revision"))
308 self.__bookmarkMoveAct = self.__actionsMenu.addAction(
309 UI.PixmapCache.getIcon("moveBookmark"),
310 self.tr("Move Bookmark..."), self.__bookmarkMoveActTriggered)
311 self.__bookmarkMoveAct.setToolTip(
312 self.tr("Move bookmark to the selected revision"))
313
314 self.__actionsMenu.addSeparator()
315
316 self.__pullAct = self.__actionsMenu.addAction(
317 UI.PixmapCache.getIcon("vcsUpdate"), self.tr("Pull Changes"),
318 self.__pullActTriggered)
319 self.__pullAct.setToolTip(self.tr(
320 "Pull changes from a remote repository"))
321 self.__lfPullAct = self.__actionsMenu.addAction(
322 self.tr("Pull Large Files"), self.__lfPullActTriggered)
323 self.__lfPullAct.setToolTip(self.tr(
324 "Pull large files for selected revisions"))
325
326 self.__actionsMenu.addSeparator()
327
328 self.__pushAct = self.__actionsMenu.addAction(
329 UI.PixmapCache.getIcon("vcsCommit"),
330 self.tr("Push Selected Changes"), self.__pushActTriggered)
331 self.__pushAct.setToolTip(self.tr(
332 "Push changes of the selected changeset and its ancestors"
333 " to a remote repository"))
334 self.__pushAllAct = self.__actionsMenu.addAction(
335 UI.PixmapCache.getIcon("vcsCommit"),
336 self.tr("Push All Changes"), self.__pushAllActTriggered)
337 self.__pushAllAct.setToolTip(self.tr(
338 "Push all changes to a remote repository"))
339
340 self.__actionsMenu.addSeparator()
341
342 self.__bundleAct = self.__actionsMenu.addAction(
343 UI.PixmapCache.getIcon("vcsCreateChangegroup"),
344 self.tr("Create Changegroup"), self.__bundleActTriggered)
345 self.__bundleAct.setToolTip(self.tr(
346 "Create a changegroup file containing the selected changesets"))
347 self.__bundleAct.setWhatsThis(self.tr(
348 """<b>Create Changegroup</b>\n<p>This creates a changegroup"""
349 """ file containing the selected revisions. If no revisions"""
350 """ are selected, all changesets will be bundled. If one"""
351 """ revision is selected, it will be interpreted as the base"""
352 """ revision. Otherwise the lowest revision will be used as"""
353 """ the base revision and all other revision will be bundled."""
354 """ If the dialog is showing outgoing changesets, all"""
355 """ selected changesets will be bundled.</p>"""))
356 self.__unbundleAct = self.__actionsMenu.addAction(
357 UI.PixmapCache.getIcon("vcsApplyChangegroup"),
358 self.tr("Apply Changegroup"), self.__unbundleActTriggered)
359 self.__unbundleAct.setToolTip(self.tr(
360 "Apply the currently viewed changegroup file"))
361
362 self.__actionsMenu.addSeparator()
363
364 self.__gpgSignAct = self.__actionsMenu.addAction(
365 UI.PixmapCache.getIcon("changesetSign"),
366 self.tr("Sign Revisions"), self.__gpgSignActTriggered)
367 self.__gpgSignAct.setToolTip(self.tr(
368 "Add a signature for the selected revisions"))
369 self.__gpgVerifyAct = self.__actionsMenu.addAction(
370 UI.PixmapCache.getIcon("changesetSignVerify"),
371 self.tr("Verify Signatures"), self.__gpgVerifyActTriggered)
372 self.__gpgVerifyAct.setToolTip(self.tr(
373 "Verify all signatures there may be for the selected revision"))
374
375 self.__actionsMenu.addSeparator()
376
377 self.__stripAct = self.__actionsMenu.addAction(
378 UI.PixmapCache.getIcon("fileDelete"),
379 self.tr("Strip Changesets"), self.__stripActTriggered)
380 self.__stripAct.setToolTip(self.tr(
381 "Strip changesets from a repository"))
382
383 self.__actionsMenu.addSeparator()
384
385 self.__selectAllAct = self.__actionsMenu.addAction(
386 self.tr("Select All Entries"), self.__selectAllActTriggered)
387 self.__unselectAllAct = self.__actionsMenu.addAction(
388 self.tr("Deselect All Entries"),
389 lambda: self.__selectAllActTriggered(False))
390
391 self.actionsButton.setIcon(
392 UI.PixmapCache.getIcon("actionsToolButton"))
393 self.actionsButton.setMenu(self.__actionsMenu)
394
395 def __initData(self):
396 """
397 Private method to (re-)initialize some data.
398 """
399 self.__maxDate = QDate()
400 self.__minDate = QDate()
401 self.__filterLogsEnabled = True
402
403 self.buf = [] # buffer for stdout
404 self.diff = None
405 self.__started = False
406 self.__lastRev = 0
407 self.projectMode = False
408
409 # attributes to store log graph data
410 self.__revs = []
411 self.__revColors = {}
412 self.__revColor = 0
413
414 self.__branchColors = {}
415
416 self.__projectWorkingDirParents = []
417 self.__projectBranch = ""
418
419 self.__childrenInfo = collections.defaultdict(list)
420
421 def closeEvent(self, e):
422 """
423 Protected slot implementing a close event handler.
424
425 @param e close event (QCloseEvent)
426 """
427 if self.__hgClient.isExecuting():
428 self.__hgClient.cancel()
429
430 self.vcs.getPlugin().setPreferences(
431 "LogBrowserGeometry", self.saveGeometry())
432 self.vcs.getPlugin().setPreferences(
433 "LogBrowserSplitterStates", [
434 self.mainSplitter.saveState(),
435 self.detailsSplitter.saveState(),
436 self.diffSplitter.saveState(),
437 ]
438 )
439
440 e.accept()
441
442 def show(self):
443 """
444 Public slot to show the dialog.
445 """
446 self.__reloadGeometry()
447 self.__restoreSplitterStates()
448 self.__resetUI()
449
450 super().show()
451
452 def __reloadGeometry(self):
453 """
454 Private method to restore the geometry.
455 """
456 geom = self.vcs.getPlugin().getPreferences("LogBrowserGeometry")
457 if geom.isEmpty():
458 s = QSize(1000, 800)
459 self.resize(s)
460 else:
461 self.restoreGeometry(geom)
462
463 def __restoreSplitterStates(self):
464 """
465 Private method to restore the state of the various splitters.
466 """
467 states = self.vcs.getPlugin().getPreferences(
468 "LogBrowserSplitterStates")
469 if len(states) == 3:
470 # we have three splitters
471 self.mainSplitter.restoreState(states[0])
472 self.detailsSplitter.restoreState(states[1])
473 self.diffSplitter.restoreState(states[2])
474
475 def __resetUI(self):
476 """
477 Private method to reset the user interface.
478 """
479 self.branchCombo.clear()
480 self.fromDate.setDate(QDate.currentDate())
481 self.toDate.setDate(QDate.currentDate())
482 self.fieldCombo.setCurrentIndex(self.fieldCombo.findData("message"))
483 self.limitSpinBox.setValue(self.vcs.getPlugin().getPreferences(
484 "LogLimit"))
485 self.stopCheckBox.setChecked(self.vcs.getPlugin().getPreferences(
486 "StopLogOnCopy"))
487
488 if self.initialCommandMode in ("incoming", "outgoing"):
489 self.nextButton.setEnabled(False)
490 self.limitSpinBox.setEnabled(False)
491 else:
492 self.nextButton.setEnabled(True)
493 self.limitSpinBox.setEnabled(True)
494
495 self.logTree.clear()
496
497 if self.initialCommandMode == "full_log":
498 self.commandMode = "incoming"
499 else:
500 self.commandMode = self.initialCommandMode
501
502 def __resizeColumnsLog(self):
503 """
504 Private method to resize the log tree columns.
505 """
506 self.logTree.header().resizeSections(
507 QHeaderView.ResizeMode.ResizeToContents)
508 self.logTree.header().setStretchLastSection(True)
509
510 def __resizeColumnsFiles(self):
511 """
512 Private method to resize the changed files tree columns.
513 """
514 self.filesTree.header().resizeSections(
515 QHeaderView.ResizeMode.ResizeToContents)
516 self.filesTree.header().setStretchLastSection(True)
517
518 def __resortFiles(self):
519 """
520 Private method to resort the changed files tree.
521 """
522 sortColumn = self.filesTree.sortColumn()
523 self.filesTree.sortItems(
524 1, self.filesTree.header().sortIndicatorOrder())
525 self.filesTree.sortItems(
526 sortColumn, self.filesTree.header().sortIndicatorOrder())
527
528 def __getColor(self, n):
529 """
530 Private method to get the (rotating) name of the color given an index.
531
532 @param n color index
533 @type int
534 @return color name
535 @rtype str
536 """
537 if self.__logTreeHasDarkBackground:
538 return LIGHTCOLORS[n % len(LIGHTCOLORS)]
539 else:
540 return COLORS[n % len(COLORS)]
541
542 def __branchColor(self, branchName):
543 """
544 Private method to calculate a color for a given branch name.
545
546 @param branchName name of the branch (string)
547 @return name of the color to use (string)
548 """
549 if branchName not in self.__branchColors:
550 self.__branchColors[branchName] = self.__getColor(
551 len(self.__branchColors))
552 return self.__branchColors[branchName]
553
554 def __generateEdges(self, rev, parents):
555 """
556 Private method to generate edge info for the give data.
557
558 @param rev revision to calculate edge info for (integer)
559 @param parents list of parent revisions (list of integers)
560 @return tuple containing the column and color index for
561 the given node and a list of tuples indicating the edges
562 between the given node and its parents
563 (integer, integer, [(integer, integer, integer), ...])
564 """
565 if rev not in self.__revs:
566 # new head
567 self.__revs.append(rev)
568 self.__revColors[rev] = self.__revColor
569 self.__revColor += 1
570
571 col = self.__revs.index(rev)
572 color = self.__revColors.pop(rev)
573 nextRevs = self.__revs[:]
574
575 # add parents to next
576 addparents = [p for p in parents if p not in nextRevs]
577 nextRevs[col:col + 1] = addparents
578
579 # set colors for the parents
580 for i, p in enumerate(addparents):
581 if not i:
582 self.__revColors[p] = color
583 else:
584 self.__revColors[p] = self.__revColor
585 self.__revColor += 1
586
587 # add edges to the graph
588 edges = []
589 if parents[0] != -1:
590 for ecol, erev in enumerate(self.__revs):
591 if erev in nextRevs:
592 edges.append(
593 (ecol, nextRevs.index(erev), self.__revColors[erev]))
594 elif erev == rev:
595 for p in parents:
596 edges.append(
597 (ecol, nextRevs.index(p), self.__revColors[p]))
598
599 self.__revs = nextRevs
600 return col, color, edges
601
602 def __generateIcon(self, column, color, bottomedges, topedges, dotColor,
603 currentRev, closed, isPushableDraft):
604 """
605 Private method to generate an icon containing the revision tree for the
606 given data.
607
608 @param column column index of the revision
609 @type int
610 @param color color of the node
611 @type int
612 @param bottomedges list of edges for the bottom of the node
613 @type list of tuples of (int, int, int)
614 @param topedges list of edges for the top of the node
615 @type list of tuples of (int, int, int)
616 @param dotColor color to be used for the dot
617 @type QColor
618 @param currentRev flag indicating to draw the icon for the
619 current revision
620 @type bool
621 @param closed flag indicating to draw an icon for a closed
622 branch
623 @type bool
624 @param isPushableDraft flag indicating an entry of phase 'draft',
625 that can by pushed
626 @type bool
627 @return icon for the node
628 @rtype QIcon
629 """
630 def col2x(col, radius):
631 """
632 Local function to calculate a x-position for a column.
633
634 @param col column number (integer)
635 @param radius radius of the indicator circle (integer)
636 """
637 return int(1.2 * radius) * col + radius // 2 + 3
638
639 textColor = self.logTree.palette().color(QPalette.ColorRole.Text)
640
641 radius = self.__dotRadius
642 w = len(bottomedges) * radius + 20
643 h = self.__rowHeight
644
645 dot_x = col2x(column, radius) - radius // 2
646 dot_y = h // 2
647
648 pix = QPixmap(w, h)
649 pix.fill(QColor(0, 0, 0, 0)) # draw transparent background
650 painter = QPainter(pix)
651 painter.setRenderHint(QPainter.RenderHint.Antialiasing)
652
653 # draw the revision history lines
654 for y1, y2, lines in ((0, h, bottomedges),
655 (-h, 0, topedges)):
656 if lines:
657 for start, end, ecolor in lines:
658 lpen = QPen(QColor(self.__getColor(ecolor)))
659 lpen.setWidth(2)
660 painter.setPen(lpen)
661 x1 = col2x(start, radius)
662 x2 = col2x(end, radius)
663 painter.drawLine(x1, dot_y + y1, x2, dot_y + y2)
664
665 penradius = 1
666 pencolor = textColor
667
668 dot_y = (h // 2) - radius // 2
669
670 # draw an indicator for the revision
671 if currentRev:
672 # enlarge for the current revision
673 delta = 1
674 radius += 2 * delta
675 dot_y -= delta
676 dot_x -= delta
677 penradius = 3
678 painter.setBrush(dotColor)
679 pen = QPen(pencolor)
680 pen.setWidth(penradius)
681 painter.setPen(pen)
682 if closed:
683 painter.drawRect(dot_x - 2, dot_y + 1,
684 radius + 4, radius - 2)
685 elif self.commandMode in ("incoming", "outgoing"):
686 offset = radius // 2
687 if self.commandMode == "incoming":
688 # incoming: draw a down arrow
689 painter.drawConvexPolygon(
690 QPoint(dot_x, dot_y),
691 QPoint(dot_x + 2 * offset, dot_y),
692 QPoint(dot_x + offset, dot_y + 2 * offset)
693 )
694 else:
695 # outgoing: draw an up arrow
696 painter.drawConvexPolygon(
697 QPoint(dot_x + offset, dot_y),
698 QPoint(dot_x, dot_y + 2 * offset),
699 QPoint(dot_x + 2 * offset, dot_y + 2 * offset)
700 )
701 else:
702 if isPushableDraft:
703 # 'draft' phase: draw an up arrow like outgoing,
704 # if it can be pushed
705 offset = radius // 2
706 painter.drawConvexPolygon(
707 QPoint(dot_x + offset, dot_y),
708 QPoint(dot_x, dot_y + 2 * offset),
709 QPoint(dot_x + 2 * offset, dot_y + 2 * offset)
710 )
711 else:
712 painter.drawEllipse(dot_x, dot_y, radius, radius)
713 painter.end()
714 return QIcon(pix)
715
716 def __getParents(self, rev):
717 """
718 Private method to get the parents of the currently viewed
719 file/directory.
720
721 @param rev revision number to get parents for (string)
722 @return list of parent revisions (list of integers)
723 """
724 errMsg = ""
725 parents = [-1]
726
727 if int(rev) > 0:
728 args = self.vcs.initCommand("parents")
729 if self.commandMode == "incoming":
730 if self.__bundle:
731 args.append("--repository")
732 args.append(self.__bundle)
733 elif (
734 self.vcs.bundleFile and
735 os.path.exists(self.vcs.bundleFile)
736 ):
737 args.append("--repository")
738 args.append(self.vcs.bundleFile)
739 args.append("--template")
740 args.append("{rev}\n")
741 args.append("-r")
742 args.append(rev)
743 if not self.projectMode:
744 args.append(self.__filename)
745
746 output, errMsg = self.__hgClient.runcommand(args)
747
748 if output:
749 parents = [int(p) for p in output.strip().splitlines()]
750
751 return parents
752
753 def __identifyProject(self):
754 """
755 Private method to determine the revision of the project directory.
756 """
757 errMsg = ""
758
759 args = self.vcs.initCommand("identify")
760 args.append("-nb")
761
762 output, errMsg = self.__hgClient.runcommand(args)
763
764 if errMsg:
765 E5MessageBox.critical(
766 self,
767 self.tr("Mercurial Error"),
768 errMsg)
769
770 if output:
771 outputList = output.strip().split(None, 1)
772 if len(outputList) == 2:
773 outputRevs = outputList[0].strip()
774 if outputRevs.endswith("+"):
775 outputRevs = outputRevs[:-1]
776 self.__projectWorkingDirParents = outputRevs.split('+')
777 else:
778 self.__projectWorkingDirParents = [outputRevs]
779 self.__projectBranch = outputList[1].strip()
780
781 def __getClosedBranches(self):
782 """
783 Private method to get the list of closed branches.
784 """
785 self.__closedBranchesRevs = []
786 errMsg = ""
787
788 args = self.vcs.initCommand("branches")
789 args.append("--closed")
790
791 output, errMsg = self.__hgClient.runcommand(args)
792
793 if errMsg:
794 E5MessageBox.critical(
795 self,
796 self.tr("Mercurial Error"),
797 errMsg)
798
799 if output:
800 for line in output.splitlines():
801 if line.strip().endswith("(closed)"):
802 parts = line.split()
803 self.__closedBranchesRevs.append(
804 parts[-2].split(":", 1)[0])
805
806 def __getHeads(self):
807 """
808 Private method to get the list of all heads.
809 """
810 self.__headRevisions = []
811 errMsg = ""
812
813 args = self.vcs.initCommand("heads")
814 args.append("--closed")
815 args.append("--template")
816 args.append("{rev}\n")
817
818 output, errMsg = self.__hgClient.runcommand(args)
819
820 if errMsg:
821 E5MessageBox.critical(
822 self,
823 self.tr("Mercurial Error"),
824 errMsg)
825
826 if output:
827 for line in output.splitlines():
828 line = line.strip()
829 if line:
830 self.__headRevisions.append(line)
831
832 def __getRevisionOfTag(self, tag):
833 """
834 Private method to get the revision of a tag.
835
836 @param tag tag name
837 @type str
838 @return tuple containing the revision and changeset ID
839 @rtype tuple of (str, str)
840 """
841 errMsg = ""
842
843 args = self.vcs.initCommand("tags")
844
845 output, errMsg = self.__hgClient.runcommand(args)
846
847 if errMsg:
848 E5MessageBox.critical(
849 self,
850 self.tr("Mercurial Error"),
851 errMsg)
852
853 res = ("", "")
854 if output:
855 for line in output.splitlines():
856 if line.strip():
857 with contextlib.suppress(ValueError):
858 name, rev = line.strip().rsplit(None, 1)
859 if name == tag:
860 res = tuple(rev.split(":", 1))
861 break
862
863 return res
864
865 def __generateLogItem(self, author, date, message, revision, changedPaths,
866 parents, branches, tags, phase, bookmarks,
867 latestTag, canPush=False):
868 """
869 Private method to generate a log tree entry.
870
871 @param author author info
872 @type str
873 @param date date info
874 @type str
875 @param message text of the log message
876 @type list of str
877 @param revision revision info
878 @type str
879 @param changedPaths list of dictionary objects containing
880 info about the changed files/directories
881 @type dict
882 @param parents list of parent revisions
883 @type list of int
884 @param branches list of branches
885 @type list of str
886 @param tags list of tags
887 @type str
888 @param phase phase of the entry
889 @type str
890 @param bookmarks list of bookmarks
891 @type str
892 @param latestTag the latest tag(s) reachable from the changeset
893 @type list of str
894 @param canPush flag indicating that changesets can be pushed
895 @type bool
896 @return reference to the generated item
897 @rtype QTreeWidgetItem
898 """
899 logMessageColumnWidth = self.vcs.getPlugin().getPreferences(
900 "LogMessageColumnWidth")
901 msgtxt = ""
902 for line in message:
903 if ". " in line:
904 msgtxt += " " + line.strip().split(". ", 1)[0] + "."
905 break
906 else:
907 msgtxt += " " + line.strip()
908 if len(msgtxt) > logMessageColumnWidth:
909 msgtxt = "{0}...".format(msgtxt[:logMessageColumnWidth])
910
911 rev, node = revision.split(":")
912 closedStr = (self.ClosedIndicator
913 if rev in self.__closedBranchesRevs else "")
914 phaseStr = self.phases.get(phase, phase)
915 columnLabels = [
916 "",
917 branches[0] + closedStr,
918 "{0:>7}:{1}".format(rev, node),
919 phaseStr,
920 author,
921 date,
922 msgtxt,
923 ", ".join(tags),
924 ]
925 if bookmarks is not None:
926 columnLabels.append(", ".join(bookmarks))
927 itm = QTreeWidgetItem(self.logTree, columnLabels)
928
929 itm.setForeground(self.BranchColumn,
930 QBrush(QColor(self.__branchColor(branches[0]))))
931
932 if not self.projectMode:
933 parents = self.__getParents(rev)
934 if not parents:
935 parents = [int(rev) - 1]
936 column, color, edges = self.__generateEdges(int(rev), parents)
937
938 itm.setData(0, self.__messageRole, message)
939 itm.setData(0, self.__changesRole, changedPaths)
940 itm.setData(0, self.__edgesRole, edges)
941 itm.setData(0, self.__latestTagRole, latestTag)
942 if parents == [-1]:
943 itm.setData(0, self.__parentsRole, [])
944 else:
945 itm.setData(0, self.__parentsRole, parents)
946 for parent in parents:
947 self.__childrenInfo[parent].append(int(rev))
948 itm.setData(0, self.__incomingRole, self.commandMode == "incoming")
949
950 topedges = (
951 self.logTree.topLevelItem(
952 self.logTree.indexOfTopLevelItem(itm) - 1
953 ).data(0, self.__edgesRole)
954 if self.logTree.topLevelItemCount() > 1 else
955 None
956 )
957
958 icon = self.__generateIcon(column, color, edges, topedges,
959 QColor(self.__branchColor(branches[0])),
960 rev in self.__projectWorkingDirParents,
961 rev in self.__closedBranchesRevs,
962 phase == "draft" and canPush)
963 itm.setIcon(0, icon)
964
965 try:
966 self.__lastRev = int(revision.split(":")[0])
967 except ValueError:
968 self.__lastRev = 0
969
970 return itm
971
972 def __getLogEntries(self, startRev=None, noEntries=0):
973 """
974 Private method to retrieve log entries from the repository.
975
976 @param startRev revision number to start from (integer, string)
977 @param noEntries number of entries to get (0 = default) (int)
978 """
979 self.buttonBox.button(
980 QDialogButtonBox.StandardButton.Close).setEnabled(False)
981 self.buttonBox.button(
982 QDialogButtonBox.StandardButton.Cancel).setEnabled(True)
983 self.buttonBox.button(
984 QDialogButtonBox.StandardButton.Cancel).setDefault(True)
985 QApplication.processEvents()
986
987 with E5OverrideCursor():
988 self.buf = []
989 self.cancelled = False
990 self.errors.clear()
991 self.intercept = False
992
993 if noEntries == 0:
994 noEntries = self.limitSpinBox.value()
995
996 preargs = []
997 args = self.vcs.initCommand(self.commandMode)
998 args.append('--verbose')
999 if self.commandMode not in ("incoming", "outgoing"):
1000 args.append('--limit')
1001 args.append(str(noEntries))
1002 if self.commandMode in ("incoming", "outgoing"):
1003 args.append("--newest-first")
1004 if self.vcs.hasSubrepositories():
1005 args.append("--subrepos")
1006 if startRev is not None:
1007 args.append('--rev')
1008 args.append('{0}:0'.format(startRev))
1009 if (
1010 not self.projectMode and
1011 not self.stopCheckBox.isChecked()
1012 ):
1013 args.append('--follow')
1014 if self.commandMode == "log":
1015 args.append('--copies')
1016 args.append('--template')
1017 args.append(os.path.join(os.path.dirname(__file__),
1018 "templates",
1019 "logBrowserBookmarkPhase.tmpl"))
1020 if self.commandMode == "incoming":
1021 if self.__bundle:
1022 args.append(self.__bundle)
1023 elif not self.vcs.hasSubrepositories():
1024 project = e5App().getObject("Project")
1025 self.vcs.bundleFile = os.path.join(
1026 project.getProjectManagementDir(), "hg-bundle.hg")
1027 if os.path.exists(self.vcs.bundleFile):
1028 os.remove(self.vcs.bundleFile)
1029 preargs = args[:]
1030 preargs.append("--quiet")
1031 preargs.append('--bundle')
1032 preargs.append(self.vcs.bundleFile)
1033 args.append(self.vcs.bundleFile)
1034 if not self.projectMode:
1035 args.append(self.__filename)
1036
1037 if preargs:
1038 out, err = self.__hgClient.runcommand(preargs)
1039 else:
1040 err = ""
1041 if err:
1042 if (
1043 self.commandMode == "incoming" and
1044 self.initialCommandMode == "full_log"
1045 ):
1046 # ignore the error
1047 self.commandMode = "log"
1048 else:
1049 self.__showError(err)
1050 elif (
1051 self.commandMode != "incoming" or
1052 (self.vcs.bundleFile and
1053 os.path.exists(self.vcs.bundleFile)) or
1054 self.__bundle
1055 ):
1056 out, err = self.__hgClient.runcommand(args)
1057 self.buf = out.splitlines(True)
1058 if err:
1059 self.__showError(err)
1060 self.__processBuffer()
1061 elif (
1062 self.commandMode == "incoming" and
1063 self.initialCommandMode == "full_log"
1064 ):
1065 # no incoming changesets, just switch to log mode
1066 self.commandMode = "log"
1067 self.__finish()
1068
1069 def start(self, name=None, bundle=None, isFile=False, noEntries=0):
1070 """
1071 Public slot to start the hg log command.
1072
1073 @param name file/directory name to show the log for
1074 @type str
1075 @param bundle name of a bundle file
1076 @type str
1077 @param isFile flag indicating log for a file is to be shown
1078 @type bool
1079 @param noEntries number of entries to get (0 = default)
1080 @type int
1081 """
1082 self.__bundle = bundle
1083 self.__isFile = isFile
1084
1085 if self.initialCommandMode == "full_log":
1086 if isFile:
1087 self.commandMode = "log"
1088 self.__finishCallbacks = []
1089 else:
1090 self.commandMode = "incoming"
1091 self.__addFinishCallback(self.on_nextButton_clicked)
1092
1093 self.sbsSelectLabel.clear()
1094
1095 self.errorGroup.hide()
1096 self.errors.clear()
1097 QApplication.processEvents()
1098
1099 self.__initData()
1100
1101 self.__filename = name
1102
1103 self.projectMode = name is None
1104 self.stopCheckBox.setDisabled(self.projectMode)
1105 self.activateWindow()
1106 self.raise_()
1107
1108 self.logTree.clear()
1109 self.__started = True
1110 self.__identifyProject()
1111 self.__getClosedBranches()
1112 self.__getHeads()
1113 self.__getLogEntries(noEntries=noEntries)
1114
1115 def __finish(self):
1116 """
1117 Private slot called when the process finished or the user pressed
1118 the button.
1119 """
1120 self.buttonBox.button(
1121 QDialogButtonBox.StandardButton.Close).setEnabled(True)
1122 self.buttonBox.button(
1123 QDialogButtonBox.StandardButton.Cancel).setEnabled(False)
1124 self.buttonBox.button(
1125 QDialogButtonBox.StandardButton.Close).setDefault(True)
1126
1127 self.refreshButton.setEnabled(True)
1128
1129 while self.__finishCallbacks:
1130 self.__finishCallbacks.pop(0)()
1131
1132 def __modifyForLargeFiles(self, filename):
1133 """
1134 Private method to convert the displayed file name for a large file.
1135
1136 @param filename file name to be processed (string)
1137 @return processed file name (string)
1138 """
1139 if filename.startswith((self.LargefilesCacheL, self.LargefilesCacheW)):
1140 return self.tr("{0} (large file)").format(
1141 self.PathSeparatorRe.split(filename, 1)[1])
1142 else:
1143 return filename
1144
1145 def __processBuffer(self):
1146 """
1147 Private method to process the buffered output of the hg log command.
1148 """
1149 noEntries = 0
1150 log = {"message": [], "bookmarks": None, "phase": ""}
1151 changedPaths = []
1152 initialText = True
1153 fileCopies = {}
1154 canPush = self.vcs.canPush()
1155 for s in self.buf:
1156 if s != "@@@\n":
1157 try:
1158 key, value = s.split("|", 1)
1159 except ValueError:
1160 key = ""
1161 value = s
1162 if key == "change":
1163 initialText = False
1164 log["revision"] = value.strip()
1165 elif key == "user":
1166 log["author"] = value.strip()
1167 elif key == "parents":
1168 log["parents"] = [
1169 int(x.split(":", 1)[0])
1170 for x in value.strip().split()
1171 ]
1172 elif key == "date":
1173 log["date"] = " ".join(value.strip().split()[:2])
1174 elif key == "description":
1175 log["message"].append(value.strip())
1176 elif key == "file_adds":
1177 if value.strip():
1178 for f in value.strip().split(", "):
1179 if f in fileCopies:
1180 changedPaths.append({
1181 "action": "A",
1182 "path": self.__modifyForLargeFiles(f),
1183 "copyfrom": self.__modifyForLargeFiles(
1184 fileCopies[f]),
1185 })
1186 else:
1187 changedPaths.append({
1188 "action": "A",
1189 "path": self.__modifyForLargeFiles(f),
1190 "copyfrom": "",
1191 })
1192 elif key == "files_mods":
1193 if value.strip():
1194 for f in value.strip().split(", "):
1195 changedPaths.append({
1196 "action": "M",
1197 "path": self.__modifyForLargeFiles(f),
1198 "copyfrom": "",
1199 })
1200 elif key == "file_dels":
1201 if value.strip():
1202 for f in value.strip().split(", "):
1203 changedPaths.append({
1204 "action": "D",
1205 "path": self.__modifyForLargeFiles(f),
1206 "copyfrom": "",
1207 })
1208 elif key == "file_copies":
1209 if value.strip():
1210 for entry in value.strip().split(", "):
1211 newName, oldName = entry[:-1].split(" (")
1212 fileCopies[newName] = oldName
1213 elif key == "branches":
1214 if value.strip():
1215 log["branches"] = value.strip().split(", ")
1216 else:
1217 log["branches"] = ["default"]
1218 elif key == "tags":
1219 log["tags"] = value.strip().split(", ")
1220 elif key == "bookmarks":
1221 log["bookmarks"] = value.strip().split(", ")
1222 elif key == "phase":
1223 log["phase"] = value.strip()
1224 elif key == "latesttag":
1225 tag = value.strip()
1226 if tag == "null":
1227 log["latesttag"] = []
1228 elif ":" in tag:
1229 log["latesttag"] = [
1230 t.strip() for t in tag.split(":") if t.strip()]
1231 else:
1232 log["latesttag"] = [tag]
1233 else:
1234 if initialText:
1235 continue
1236 if value.strip():
1237 log["message"].append(value.strip())
1238 else:
1239 if len(log) > 1:
1240 self.__generateLogItem(
1241 log["author"], log["date"],
1242 log["message"], log["revision"], changedPaths,
1243 log["parents"], log["branches"], log["tags"],
1244 log["phase"], log["bookmarks"], log["latesttag"],
1245 canPush=canPush)
1246 dt = QDate.fromString(log["date"], Qt.DateFormat.ISODate)
1247 if (
1248 not self.__maxDate.isValid() and
1249 not self.__minDate.isValid()
1250 ):
1251 self.__maxDate = dt
1252 self.__minDate = dt
1253 else:
1254 if self.__maxDate < dt:
1255 self.__maxDate = dt
1256 if self.__minDate > dt:
1257 self.__minDate = dt
1258 noEntries += 1
1259 log = {"message": [], "bookmarks": None, "phase": ""}
1260 changedPaths = []
1261 fileCopies = {}
1262
1263 self.__resizeColumnsLog()
1264
1265 if self.__started and not self.__finishCallbacks:
1266 # we are really done
1267 if self.__selectedRevisions:
1268 foundItems = self.logTree.findItems(
1269 self.__selectedRevisions[0], Qt.MatchFlag.MatchExactly,
1270 self.RevisionColumn)
1271 if foundItems:
1272 self.logTree.setCurrentItem(foundItems[0])
1273 else:
1274 self.logTree.setCurrentItem(
1275 self.logTree.topLevelItem(0))
1276 elif self.__projectWorkingDirParents:
1277 for rev in self.__projectWorkingDirParents:
1278 # rev string format must match with the format of the
1279 # __generateLogItem() method
1280 items = self.logTree.findItems(
1281 "{0:>7}:".format(rev),
1282 Qt.MatchFlag.MatchStartsWith,
1283 self.RevisionColumn)
1284 if items:
1285 self.logTree.setCurrentItem(items[0])
1286 break
1287 else:
1288 self.logTree.setCurrentItem(
1289 self.logTree.topLevelItem(0))
1290 else:
1291 self.logTree.setCurrentItem(self.logTree.topLevelItem(0))
1292 self.__started = False
1293
1294 if self.commandMode in ("incoming", "outgoing"):
1295 self.commandMode = "log" # switch to log mode
1296 if self.__lastRev > 0:
1297 self.nextButton.setEnabled(True)
1298 self.limitSpinBox.setEnabled(True)
1299 else:
1300 if noEntries < self.limitSpinBox.value() and not self.cancelled:
1301 self.nextButton.setEnabled(False)
1302 self.limitSpinBox.setEnabled(False)
1303
1304 # update the log filters
1305 self.__filterLogsEnabled = False
1306 self.fromDate.setMinimumDate(self.__minDate)
1307 self.fromDate.setMaximumDate(self.__maxDate)
1308 self.fromDate.setDate(self.__minDate)
1309 self.toDate.setMinimumDate(self.__minDate)
1310 self.toDate.setMaximumDate(self.__maxDate)
1311 self.toDate.setDate(self.__maxDate)
1312
1313 branchFilter = self.branchCombo.currentText()
1314 if not branchFilter:
1315 branchFilter = self.__allBranchesFilter
1316 self.branchCombo.clear()
1317 self.branchCombo.addItems(
1318 [self.__allBranchesFilter] + sorted(self.__branchColors.keys()))
1319 self.branchCombo.setCurrentIndex(
1320 self.branchCombo.findText(branchFilter))
1321
1322 self.__filterLogsEnabled = True
1323 if self.__actionMode() == "filter":
1324 self.__filterLogs()
1325 self.__updateToolMenuActions()
1326
1327 # restore selected item
1328 if self.__selectedRevisions and not self.__finishCallbacks:
1329 # we are really done
1330 for revision in self.__selectedRevisions:
1331 items = self.logTree.findItems(
1332 revision, Qt.MatchFlag.MatchExactly, self.RevisionColumn)
1333 if items:
1334 items[0].setSelected(True)
1335 self.__selectedRevisions = []
1336
1337 def __showError(self, out):
1338 """
1339 Private slot to show some error.
1340
1341 @param out error to be shown (string)
1342 """
1343 self.errorGroup.show()
1344 self.errors.insertPlainText(out)
1345 self.errors.ensureCursorVisible()
1346
1347 def on_buttonBox_clicked(self, button):
1348 """
1349 Private slot called by a button of the button box clicked.
1350
1351 @param button button that was clicked (QAbstractButton)
1352 """
1353 if button == self.buttonBox.button(
1354 QDialogButtonBox.StandardButton.Close
1355 ):
1356 self.close()
1357 elif button == self.buttonBox.button(
1358 QDialogButtonBox.StandardButton.Cancel
1359 ):
1360 self.cancelled = True
1361 self.__hgClient.cancel()
1362 elif button == self.refreshButton:
1363 self.on_refreshButton_clicked()
1364
1365 def __updateSbsSelectLabel(self):
1366 """
1367 Private slot to update the enabled status of the diff buttons.
1368 """
1369 self.sbsSelectLabel.clear()
1370 if self.__isFile:
1371 selectedItems = self.logTree.selectedItems()
1372 if len(selectedItems) == 1:
1373 currentItem = selectedItems[0]
1374 rev2 = (
1375 currentItem.text(self.RevisionColumn).split(":", 1)[0]
1376 .strip()
1377 )
1378 parents = currentItem.data(0, self.__parentsRole)
1379 if parents:
1380 parentLinks = []
1381 for index in range(len(parents)):
1382 parentLinks.append(
1383 '<a href="sbsdiff:{0}_{1}">&nbsp;{2}&nbsp;</a>'
1384 .format(parents[index], rev2, index + 1))
1385 self.sbsSelectLabel.setText(
1386 self.tr('Side-by-Side Diff to Parent {0}').format(
1387 " ".join(parentLinks)))
1388 elif len(selectedItems) == 2:
1389 rev1 = int(selectedItems[0].text(self.RevisionColumn)
1390 .split(":", 1)[0])
1391 rev2 = int(selectedItems[1].text(self.RevisionColumn)
1392 .split(":", 1)[0])
1393 if rev1 > rev2:
1394 # Swap the entries, so that rev1 < rev2
1395 rev1, rev2 = rev2, rev1
1396 self.sbsSelectLabel.setText(self.tr(
1397 '<a href="sbsdiff:{0}_{1}">Side-by-Side Compare</a>')
1398 .format(rev1, rev2))
1399
1400 def __updateToolMenuActions(self):
1401 """
1402 Private slot to update the status of the tool menu actions and
1403 the tool menu button.
1404 """
1405 if self.initialCommandMode in ("log", "full_log") and self.projectMode:
1406 # do the phase action
1407 # step 1: count entries with changeable phases
1408 secret = 0
1409 draft = 0
1410 public = 0
1411 for itm in [item for item in self.logTree.selectedItems()
1412 if not item.data(0, self.__incomingRole)]:
1413 # count phase for local items only
1414 phase = itm.text(self.PhaseColumn)
1415 if phase == self.phases["draft"]:
1416 draft += 1
1417 elif phase == self.phases["secret"]:
1418 secret += 1
1419 else:
1420 public += 1
1421
1422 # step 2: set the status of the phase action
1423 if (
1424 public == 0 and
1425 ((secret > 0 and draft == 0) or
1426 (secret == 0 and draft > 0))
1427 ):
1428 self.__phaseAct.setEnabled(True)
1429 else:
1430 self.__phaseAct.setEnabled(False)
1431
1432 # do the graft action
1433 # step 1: count selected entries not belonging to the
1434 # current branch
1435 otherBranches = 0
1436 for itm in [item for item in self.logTree.selectedItems()
1437 if not item.data(0, self.__incomingRole)]:
1438 # for local items only
1439 branch = itm.text(self.BranchColumn)
1440 if branch != self.__projectBranch:
1441 otherBranches += 1
1442
1443 # step 2: set the status of the graft action
1444 self.__graftAct.setEnabled(otherBranches > 0)
1445
1446 selectedItemsCount = len([
1447 itm for itm in self.logTree.selectedItems()
1448 if not itm.data(0, self.__incomingRole)
1449 ])
1450 selectedIncomingItemsCount = len([
1451 itm for itm in self.logTree.selectedItems()
1452 if itm.data(0, self.__incomingRole)
1453 ])
1454
1455 self.__mergeAct.setEnabled(selectedItemsCount == 1)
1456 self.__tagAct.setEnabled(selectedItemsCount == 1)
1457 self.__switchAct.setEnabled(selectedItemsCount == 1)
1458 self.__bookmarkAct.setEnabled(selectedItemsCount == 1)
1459 self.__bookmarkMoveAct.setEnabled(selectedItemsCount == 1)
1460
1461 if selectedIncomingItemsCount > 0:
1462 self.__pullAct.setText(self.tr("Pull Selected Changes"))
1463 else:
1464 self.__pullAct.setText(self.tr("Pull Changes"))
1465 if self.vcs.canPull():
1466 self.__pullAct.setEnabled(True)
1467 self.__lfPullAct.setEnabled(
1468 self.vcs.isExtensionActive("largefiles") and
1469 selectedItemsCount > 0)
1470 else:
1471 self.__pullAct.setEnabled(False)
1472 self.__lfPullAct.setEnabled(False)
1473
1474 if self.vcs.canPush():
1475 self.__pushAct.setEnabled(
1476 selectedItemsCount == 1 and
1477 not self.logTree.selectedItems()[0].data(
1478 0, self.__incomingRole) and
1479 self.logTree.selectedItems()[0].text(self.PhaseColumn) ==
1480 self.phases["draft"])
1481 self.__pushAllAct.setEnabled(True)
1482 else:
1483 self.__pushAct.setEnabled(False)
1484 self.__pushAllAct.setEnabled(False)
1485
1486 self.__stripAct.setEnabled(
1487 self.vcs.isExtensionActive("strip") and
1488 selectedItemsCount == 1)
1489
1490 # count incoming items for 'full_log'
1491 if self.initialCommandMode == "full_log":
1492 # incoming items are at the top
1493 incomingCount = 0
1494 for row in range(self.logTree.topLevelItemCount()):
1495 if self.logTree.topLevelItem(row).data(
1496 0, self.__incomingRole):
1497 incomingCount += 1
1498 else:
1499 break
1500 localCount = self.logTree.topLevelItemCount() - incomingCount
1501 else:
1502 localCount = self.logTree.topLevelItemCount()
1503 self.__bundleAct.setEnabled(localCount > 0)
1504 self.__unbundleAct.setEnabled(False)
1505
1506 self.__gpgSignAct.setEnabled(
1507 self.vcs.isExtensionActive("gpg") and
1508 selectedItemsCount > 0)
1509 self.__gpgVerifyAct.setEnabled(
1510 self.vcs.isExtensionActive("gpg") and
1511 selectedItemsCount == 1)
1512
1513 if self.vcs.isExtensionActive("closehead"):
1514 revs = [itm.text(self.RevisionColumn).strip().split(":", 1)[0]
1515 for itm in self.logTree.selectedItems()
1516 if not itm.data(0, self.__incomingRole)]
1517 revs = [rev for rev in revs if rev in self.__headRevisions]
1518 self.__closeHeadsAct.setEnabled(len(revs) > 0)
1519 else:
1520 self.__closeHeadsAct.setEnabled(False)
1521 self.actionsButton.setEnabled(True)
1522
1523 elif self.initialCommandMode == "incoming" and self.projectMode:
1524 for act in [self.__phaseAct, self.__graftAct, self.__mergeAct,
1525 self.__tagAct, self.__closeHeadsAct, self.__switchAct,
1526 self.__bookmarkAct, self.__bookmarkMoveAct,
1527 self.__pushAct, self.__pushAllAct, self.__stripAct,
1528 self.__bundleAct, self.__gpgSignAct,
1529 self.__gpgVerifyAct]:
1530 act.setEnabled(False)
1531
1532 self.__pullAct.setText(self.tr("Pull Selected Changes"))
1533 if self.vcs.canPull() and not bool(self.__bundle):
1534 selectedIncomingItemsCount = len([
1535 itm for itm in self.logTree.selectedItems()
1536 if itm.data(0, self.__incomingRole)
1537 ])
1538 self.__pullAct.setEnabled(selectedIncomingItemsCount > 0)
1539 self.__lfPullAct.setEnabled(
1540 self.vcs.isExtensionActive("largefiles") and
1541 selectedIncomingItemsCount > 0)
1542 else:
1543 self.__pullAct.setEnabled(False)
1544 self.__lfPullAct.setEnabled(False)
1545
1546 self.__unbundleAct.setEnabled(bool(self.__bundle))
1547
1548 self.actionsButton.setEnabled(True)
1549
1550 elif self.initialCommandMode == "outgoing" and self.projectMode:
1551 for act in [self.__phaseAct, self.__graftAct, self.__mergeAct,
1552 self.__tagAct, self.__closeHeadsAct, self.__switchAct,
1553 self.__bookmarkAct, self.__bookmarkMoveAct,
1554 self.__pullAct, self.__lfPullAct,
1555 self.__stripAct, self.__gpgSignAct,
1556 self.__gpgVerifyAct, self.__unbundleAct]:
1557 act.setEnabled(False)
1558
1559 selectedItemsCount = len(self.logTree.selectedItems())
1560 if self.vcs.canPush():
1561 self.__pushAct.setEnabled(
1562 selectedItemsCount == 1 and
1563 self.logTree.selectedItems()[0].text(self.PhaseColumn) ==
1564 self.phases["draft"])
1565 self.__pushAllAct.setEnabled(True)
1566 else:
1567 self.__pushAct.setEnabled(False)
1568 self.__pushAllAct.setEnabled(False)
1569
1570 self.__bundleAct.setEnabled(selectedItemsCount > 0)
1571
1572 else:
1573 self.actionsButton.setEnabled(False)
1574
1575 def __updateDetailsAndFiles(self):
1576 """
1577 Private slot to update the details and file changes panes.
1578 """
1579 self.detailsEdit.clear()
1580 self.filesTree.clear()
1581 self.__diffUpdatesFiles = False
1582
1583 selectedItems = self.logTree.selectedItems()
1584 if len(selectedItems) == 1:
1585 self.detailsEdit.setHtml(
1586 self.__generateDetailsTableText(selectedItems[0]))
1587 self.__updateFilesTree(self.filesTree, selectedItems[0])
1588 self.__resizeColumnsFiles()
1589 self.__resortFiles()
1590 elif len(selectedItems) == 2:
1591 self.__diffUpdatesFiles = True
1592 index1 = self.logTree.indexOfTopLevelItem(selectedItems[0])
1593 index2 = self.logTree.indexOfTopLevelItem(selectedItems[1])
1594 if index1 > index2:
1595 # Swap the entries
1596 selectedItems[0], selectedItems[1] = (
1597 selectedItems[1], selectedItems[0]
1598 )
1599 html = "{0}<hr/>{1}".format(
1600 self.__generateDetailsTableText(selectedItems[0]),
1601 self.__generateDetailsTableText(selectedItems[1]),
1602 )
1603 self.detailsEdit.setHtml(html)
1604 # self.filesTree is updated by the diff
1605
1606 def __generateDetailsTableText(self, itm):
1607 """
1608 Private method to generate an HTML table with the details of the given
1609 changeset.
1610
1611 @param itm reference to the item the table should be based on
1612 @type QTreeWidgetItem
1613 @return HTML table containing details
1614 @rtype str
1615 """
1616 if itm is not None:
1617 if itm.text(self.TagsColumn):
1618 tagsStr = self.__tagsTemplate.format(itm.text(self.TagsColumn))
1619 else:
1620 tagsStr = ""
1621
1622 if itm.text(self.BookmarksColumn):
1623 bookmarksStr = self.__bookmarksTemplate.format(
1624 itm.text(self.BookmarksColumn))
1625 else:
1626 bookmarksStr = ""
1627
1628 if self.projectMode and itm.data(0, self.__latestTagRole):
1629 latestTagLinks = []
1630 for tag in itm.data(0, self.__latestTagRole):
1631 latestTagLinks.append('<a href="rev:{0}">{1}</a>'.format(
1632 self.__getRevisionOfTag(tag)[0], tag))
1633 latestTagStr = self.__latestTagTemplate.format(
1634 ", ".join(latestTagLinks))
1635 else:
1636 latestTagStr = ""
1637
1638 rev = int(itm.text(self.RevisionColumn).split(":", 1)[0])
1639
1640 if itm.data(0, self.__parentsRole):
1641 parentLinks = []
1642 for parent in [str(x) for x in
1643 itm.data(0, self.__parentsRole)]:
1644 parentLinks.append(
1645 '<a href="rev:{0}">{0}</a>'.format(parent))
1646 parentsStr = self.__parentsTemplate.format(
1647 ", ".join(parentLinks))
1648 else:
1649 parentsStr = ""
1650
1651 if self.__childrenInfo[rev]:
1652 childLinks = []
1653 for child in [str(x) for x in self.__childrenInfo[rev]]:
1654 childLinks.append(
1655 '<a href="rev:{0}">{0}</a>'.format(child))
1656 childrenStr = self.__childrenTemplate.format(
1657 ", ".join(childLinks))
1658 else:
1659 childrenStr = ""
1660
1661 messageStr = "<br />\n".join([
1662 Utilities.html_encode(line.strip())
1663 for line in itm.data(0, self.__messageRole)
1664 ])
1665
1666 html = self.__detailsTemplate.format(
1667 itm.text(self.RevisionColumn),
1668 itm.text(self.DateColumn),
1669 itm.text(self.AuthorColumn),
1670 itm.text(self.BranchColumn).replace(
1671 self.ClosedIndicator, ""),
1672 parentsStr + childrenStr + tagsStr + latestTagStr +
1673 bookmarksStr,
1674 messageStr,
1675 )
1676 else:
1677 html = ""
1678
1679 return html
1680
1681 def __updateFilesTree(self, parent, itm):
1682 """
1683 Private method to update the files tree with changes of the given item.
1684
1685 @param parent parent for the items to be added
1686 @type QTreeWidget or QTreeWidgetItem
1687 @param itm reference to the item the update should be based on
1688 @type QTreeWidgetItem
1689 """
1690 if itm is not None:
1691 changes = itm.data(0, self.__changesRole)
1692 if len(changes) > 0:
1693 for change in changes:
1694 QTreeWidgetItem(parent, [
1695 self.flags[change["action"]],
1696 change["path"].strip(),
1697 change["copyfrom"].strip(),
1698 ])
1699
1700 @pyqtSlot(QTreeWidgetItem, QTreeWidgetItem)
1701 def on_logTree_currentItemChanged(self, current, previous):
1702 """
1703 Private slot called, when the current item of the log tree changes.
1704
1705 @param current reference to the new current item (QTreeWidgetItem)
1706 @param previous reference to the old current item (QTreeWidgetItem)
1707 """
1708 self.__updateToolMenuActions()
1709
1710 # Highlight the current entry using a bold font
1711 for col in range(self.logTree.columnCount()):
1712 current and current.setFont(col, self.__logTreeBoldFont)
1713 previous and previous.setFont(col, self.__logTreeNormalFont)
1714
1715 # set the state of the up and down buttons
1716 self.upButton.setEnabled(
1717 current is not None and
1718 self.logTree.indexOfTopLevelItem(current) > 0)
1719 self.downButton.setEnabled(
1720 current is not None and
1721 int(current.text(self.RevisionColumn).split(":")[0]) > 0 and
1722 (self.logTree.indexOfTopLevelItem(current) <
1723 self.logTree.topLevelItemCount() - 1 or
1724 self.nextButton.isEnabled()))
1725
1726 @pyqtSlot()
1727 def on_logTree_itemSelectionChanged(self):
1728 """
1729 Private slot called, when the selection has changed.
1730 """
1731 self.__updateDetailsAndFiles()
1732 self.__updateSbsSelectLabel()
1733 self.__updateToolMenuActions()
1734 self.__generateDiffs()
1735
1736 @pyqtSlot()
1737 def on_upButton_clicked(self):
1738 """
1739 Private slot to move the current item up one entry.
1740 """
1741 itm = self.logTree.itemAbove(self.logTree.currentItem())
1742 if itm:
1743 self.logTree.setCurrentItem(itm)
1744
1745 @pyqtSlot()
1746 def on_downButton_clicked(self):
1747 """
1748 Private slot to move the current item down one entry.
1749 """
1750 itm = self.logTree.itemBelow(self.logTree.currentItem())
1751 if itm:
1752 self.logTree.setCurrentItem(itm)
1753 else:
1754 # load the next bunch and try again
1755 if self.nextButton.isEnabled():
1756 self.__addFinishCallback(self.on_downButton_clicked)
1757 self.on_nextButton_clicked()
1758
1759 @pyqtSlot()
1760 def on_nextButton_clicked(self):
1761 """
1762 Private slot to handle the Next button.
1763 """
1764 if self.nextButton.isEnabled():
1765 if self.__lastRev > 0:
1766 self.__getLogEntries(startRev=self.__lastRev - 1)
1767 else:
1768 self.__getLogEntries()
1769
1770 @pyqtSlot(QDate)
1771 def on_fromDate_dateChanged(self, date):
1772 """
1773 Private slot called, when the from date changes.
1774
1775 @param date new date (QDate)
1776 """
1777 if self.__actionMode() == "filter":
1778 self.__filterLogs()
1779
1780 @pyqtSlot(QDate)
1781 def on_toDate_dateChanged(self, date):
1782 """
1783 Private slot called, when the from date changes.
1784
1785 @param date new date (QDate)
1786 """
1787 if self.__actionMode() == "filter":
1788 self.__filterLogs()
1789
1790 @pyqtSlot(int)
1791 def on_branchCombo_activated(self, index):
1792 """
1793 Private slot called, when a new branch is selected.
1794
1795 @param index index of the selected entry
1796 @type int
1797 """
1798 if self.__actionMode() == "filter":
1799 self.__filterLogs()
1800
1801 @pyqtSlot(int)
1802 def on_fieldCombo_activated(self, index):
1803 """
1804 Private slot called, when a new filter field is selected.
1805
1806 @param index index of the selected entry
1807 @type int
1808 """
1809 if self.__actionMode() == "filter":
1810 self.__filterLogs()
1811
1812 @pyqtSlot(str)
1813 def on_rxEdit_textChanged(self, txt):
1814 """
1815 Private slot called, when a filter expression is entered.
1816
1817 @param txt filter expression (string)
1818 """
1819 if self.__actionMode() == "filter":
1820 self.__filterLogs()
1821 elif self.__actionMode() == "find":
1822 self.__findItem(self.__findBackwards, interactive=True)
1823
1824 @pyqtSlot()
1825 def on_rxEdit_returnPressed(self):
1826 """
1827 Private slot handling a press of the Return key in the rxEdit input.
1828 """
1829 if self.__actionMode() == "find":
1830 self.__findItem(self.__findBackwards, interactive=True)
1831
1832 def __filterLogs(self):
1833 """
1834 Private method to filter the log entries.
1835 """
1836 if self.__filterLogsEnabled:
1837 from_ = self.fromDate.date().toString("yyyy-MM-dd")
1838 to_ = self.toDate.date().addDays(1).toString("yyyy-MM-dd")
1839 branch = self.branchCombo.currentText()
1840 closedBranch = branch + '--'
1841 fieldIndex, searchRx, indexIsRole = self.__prepareFieldSearch()
1842
1843 visibleItemCount = self.logTree.topLevelItemCount()
1844 currentItem = self.logTree.currentItem()
1845 for topIndex in range(self.logTree.topLevelItemCount()):
1846 topItem = self.logTree.topLevelItem(topIndex)
1847 if indexIsRole:
1848 if fieldIndex == self.__changesRole:
1849 changes = topItem.data(0, self.__changesRole)
1850 txt = "\n".join(
1851 [c["path"] for c in changes] +
1852 [c["copyfrom"] for c in changes]
1853 )
1854 else:
1855 # Find based on complete message text
1856 txt = "\n".join(topItem.data(0, self.__messageRole))
1857 else:
1858 txt = topItem.text(fieldIndex)
1859 if (
1860 topItem.text(self.DateColumn) <= to_ and
1861 topItem.text(self.DateColumn) >= from_ and
1862 (branch == self.__allBranchesFilter or
1863 topItem.text(self.BranchColumn) in
1864 [branch, closedBranch]) and
1865 searchRx.search(txt) is not None
1866 ):
1867 topItem.setHidden(False)
1868 if topItem is currentItem:
1869 self.on_logTree_currentItemChanged(topItem, None)
1870 else:
1871 topItem.setHidden(True)
1872 if topItem is currentItem:
1873 self.filesTree.clear()
1874 visibleItemCount -= 1
1875 self.logTree.header().setSectionHidden(
1876 self.IconColumn,
1877 visibleItemCount != self.logTree.topLevelItemCount())
1878
1879 def __prepareFieldSearch(self):
1880 """
1881 Private slot to prepare the filed search data.
1882
1883 @return tuple of field index, search expression and flag indicating
1884 that the field index is a data role (integer, string, boolean)
1885 """
1886 indexIsRole = False
1887 txt = self.fieldCombo.itemData(self.fieldCombo.currentIndex())
1888 if txt == "author":
1889 fieldIndex = self.AuthorColumn
1890 searchRx = re.compile(self.rxEdit.text(), re.IGNORECASE)
1891 elif txt == "revision":
1892 fieldIndex = self.RevisionColumn
1893 txt = self.rxEdit.text()
1894 if txt.startswith("^"):
1895 searchRx = re.compile(r"^\s*{0}".format(txt[1:]),
1896 re.IGNORECASE)
1897 else:
1898 searchRx = re.compile(txt, re.IGNORECASE)
1899 elif txt == "file":
1900 fieldIndex = self.__changesRole
1901 searchRx = re.compile(self.rxEdit.text(), re.IGNORECASE)
1902 indexIsRole = True
1903 elif txt == "phase":
1904 fieldIndex = self.PhaseColumn
1905 searchRx = re.compile(self.rxEdit.text(), re.IGNORECASE)
1906 else:
1907 fieldIndex = self.__messageRole
1908 searchRx = re.compile(self.rxEdit.text(), re.IGNORECASE)
1909 indexIsRole = True
1910
1911 return fieldIndex, searchRx, indexIsRole
1912
1913 @pyqtSlot(bool)
1914 def on_stopCheckBox_clicked(self, checked):
1915 """
1916 Private slot called, when the stop on copy/move checkbox is clicked.
1917
1918 @param checked flag indicating the state of the check box (boolean)
1919 """
1920 self.vcs.getPlugin().setPreferences("StopLogOnCopy",
1921 self.stopCheckBox.isChecked())
1922 self.nextButton.setEnabled(True)
1923 self.limitSpinBox.setEnabled(True)
1924
1925 @pyqtSlot()
1926 def on_refreshButton_clicked(self, addNext=False):
1927 """
1928 Private slot to refresh the log.
1929
1930 @param addNext flag indicating to get a second batch of log entries as
1931 well
1932 @type bool
1933 """
1934 self.buttonBox.button(
1935 QDialogButtonBox.StandardButton.Close).setEnabled(False)
1936 self.buttonBox.button(
1937 QDialogButtonBox.StandardButton.Cancel).setEnabled(True)
1938 self.buttonBox.button(
1939 QDialogButtonBox.StandardButton.Cancel).setDefault(True)
1940
1941 self.refreshButton.setEnabled(False)
1942
1943 # save the selected items commit IDs
1944 self.__selectedRevisions = []
1945 for item in self.logTree.selectedItems():
1946 self.__selectedRevisions.append(item.text(self.RevisionColumn))
1947
1948 if self.initialCommandMode in ("incoming", "outgoing"):
1949 self.nextButton.setEnabled(False)
1950 self.limitSpinBox.setEnabled(False)
1951 if addNext:
1952 self.__addFinishCallback(self.on_nextButton_clicked)
1953 else:
1954 self.nextButton.setEnabled(True)
1955 self.limitSpinBox.setEnabled(True)
1956
1957 if self.initialCommandMode == "full_log":
1958 self.commandMode = "incoming"
1959 self.__addFinishCallback(self.on_nextButton_clicked)
1960 else:
1961 self.commandMode = self.initialCommandMode
1962 self.start(self.__filename, bundle=self.__bundle, isFile=self.__isFile,
1963 noEntries=self.logTree.topLevelItemCount())
1964
1965 @pyqtSlot()
1966 def __phaseActTriggered(self):
1967 """
1968 Private slot to handle the Change Phase action.
1969 """
1970 itm = self.logTree.selectedItems()[0]
1971 if not itm.data(0, self.__incomingRole):
1972 currentPhase = itm.text(self.PhaseColumn)
1973 revs = []
1974 for itm in self.logTree.selectedItems():
1975 if itm.text(self.PhaseColumn) == currentPhase:
1976 revs.append(
1977 itm.text(self.RevisionColumn).split(":")[0].strip())
1978
1979 if not revs:
1980 self.__phaseAct.setEnabled(False)
1981 return
1982
1983 if currentPhase == self.phases["draft"]:
1984 newPhase = self.phases["secret"]
1985 data = (revs, "s", True)
1986 else:
1987 newPhase = self.phases["draft"]
1988 data = (revs, "d", False)
1989 res = self.vcs.hgPhase(data)
1990 if res:
1991 for itm in self.logTree.selectedItems():
1992 itm.setText(self.PhaseColumn, newPhase)
1993
1994 @pyqtSlot()
1995 def __graftActTriggered(self):
1996 """
1997 Private slot to handle the Copy Changesets action.
1998 """
1999 revs = []
2000
2001 for itm in [item for item in self.logTree.selectedItems()
2002 if not item.data(0, self.__incomingRole)]:
2003 branch = itm.text(self.BranchColumn)
2004 if branch != self.__projectBranch:
2005 revs.append(
2006 itm.text(self.RevisionColumn).strip().split(":", 1)[0])
2007
2008 if revs:
2009 shouldReopen = self.vcs.hgGraft(revs)
2010 if shouldReopen:
2011 res = E5MessageBox.yesNo(
2012 None,
2013 self.tr("Copy Changesets"),
2014 self.tr(
2015 """The project should be reread. Do this now?"""),
2016 yesDefault=True)
2017 if res:
2018 e5App().getObject("Project").reopenProject()
2019 return
2020
2021 self.on_refreshButton_clicked()
2022
2023 @pyqtSlot()
2024 def __tagActTriggered(self):
2025 """
2026 Private slot to tag the selected revision.
2027 """
2028 if len([itm for itm in self.logTree.selectedItems()
2029 if not itm.data(0, self.__incomingRole)]) == 1:
2030 itm = self.logTree.selectedItems()[0]
2031 rev = itm.text(self.RevisionColumn).strip().split(":", 1)[0]
2032 tag = itm.text(self.TagsColumn).strip().split(", ", 1)[0]
2033 res = self.vcs.vcsTag(revision=rev, tagName=tag)
2034 if res:
2035 self.on_refreshButton_clicked()
2036
2037 @pyqtSlot()
2038 def __closeHeadsActTriggered(self):
2039 """
2040 Private slot to close the selected head revisions.
2041 """
2042 if self.vcs.isExtensionActive("closehead"):
2043 revs = [itm.text(self.RevisionColumn).strip().split(":", 1)[0]
2044 for itm in self.logTree.selectedItems()
2045 if not itm.data(0, self.__incomingRole)]
2046 revs = [rev for rev in revs if rev in self.__headRevisions]
2047 if revs:
2048 closeheadExtension = self.vcs.getExtensionObject("closehead")
2049 if closeheadExtension is not None:
2050 closeheadExtension.hgCloseheads(revisions=revs)
2051
2052 self.on_refreshButton_clicked()
2053
2054 @pyqtSlot()
2055 def __switchActTriggered(self):
2056 """
2057 Private slot to switch the working directory to the
2058 selected revision.
2059 """
2060 if len([itm for itm in self.logTree.selectedItems()
2061 if not itm.data(0, self.__incomingRole)]) == 1:
2062 itm = self.logTree.selectedItems()[0]
2063 rev = itm.text(self.RevisionColumn).strip().split(":", 1)[0]
2064 bookmarks = [bm.strip() for bm in
2065 itm.text(self.BookmarksColumn).strip().split(",")
2066 if bm.strip()]
2067 if bookmarks:
2068 bookmark, ok = QInputDialog.getItem(
2069 self,
2070 self.tr("Switch"),
2071 self.tr("Select bookmark to switch to (leave empty to"
2072 " use revision):"),
2073 [""] + bookmarks,
2074 0, False)
2075 if not ok:
2076 return
2077 if bookmark:
2078 rev = bookmark
2079 if rev:
2080 shouldReopen = self.vcs.vcsUpdate(revision=rev)
2081 if shouldReopen:
2082 res = E5MessageBox.yesNo(
2083 None,
2084 self.tr("Switch"),
2085 self.tr(
2086 """The project should be reread. Do this now?"""),
2087 yesDefault=True)
2088 if res:
2089 e5App().getObject("Project").reopenProject()
2090 return
2091
2092 self.on_refreshButton_clicked()
2093
2094 @pyqtSlot()
2095 def __bookmarkActTriggered(self):
2096 """
2097 Private slot to bookmark the selected revision.
2098 """
2099 if len([itm for itm in self.logTree.selectedItems()
2100 if not itm.data(0, self.__incomingRole)]) == 1:
2101 itm = self.logTree.selectedItems()[0]
2102 rev, changeset = (
2103 itm.text(self.RevisionColumn).strip().split(":", 1)
2104 )
2105 bookmark, ok = QInputDialog.getText(
2106 self,
2107 self.tr("Define Bookmark"),
2108 self.tr('Enter bookmark name for changeset "{0}":').format(
2109 changeset),
2110 QLineEdit.EchoMode.Normal)
2111 if ok and bool(bookmark):
2112 self.vcs.hgBookmarkDefine(
2113 revision="rev({0})".format(rev),
2114 bookmark=bookmark)
2115 self.on_refreshButton_clicked()
2116
2117 @pyqtSlot()
2118 def __bookmarkMoveActTriggered(self):
2119 """
2120 Private slot to move a bookmark to the selected revision.
2121 """
2122 if len([itm for itm in self.logTree.selectedItems()
2123 if not itm.data(0, self.__incomingRole)]) == 1:
2124 itm = self.logTree.selectedItems()[0]
2125 rev, changeset = (
2126 itm.text(self.RevisionColumn).strip().split(":", 1)
2127 )
2128 bookmarksList = self.vcs.hgGetBookmarksList()
2129 bookmark, ok = QInputDialog.getItem(
2130 self,
2131 self.tr("Move Bookmark"),
2132 self.tr('Select the bookmark to be moved to changeset'
2133 ' "{0}":').format(changeset),
2134 [""] + bookmarksList,
2135 0, False)
2136 if ok and bool(bookmark):
2137 self.vcs.hgBookmarkMove(
2138 revision="rev({0})".format(rev),
2139 bookmark=bookmark)
2140 self.on_refreshButton_clicked()
2141
2142 @pyqtSlot()
2143 def __lfPullActTriggered(self):
2144 """
2145 Private slot to pull large files of selected revisions.
2146 """
2147 revs = []
2148 for itm in [item for item in self.logTree.selectedItems()
2149 if not item.data(0, self.__incomingRole)]:
2150 rev = itm.text(self.RevisionColumn).strip().split(":", 1)[0]
2151 if rev:
2152 revs.append(rev)
2153
2154 if revs:
2155 self.vcs.getExtensionObject("largefiles").hgLfPull(revisions=revs)
2156
2157 @pyqtSlot()
2158 def __pullActTriggered(self):
2159 """
2160 Private slot to pull changes from a remote repository.
2161 """
2162 shouldReopen = False
2163 refresh = False
2164 addNext = False
2165
2166 if self.initialCommandMode in ("log", "full_log", "incoming"):
2167 revs = []
2168 for itm in [item for item in self.logTree.selectedItems()
2169 if item.data(0, self.__incomingRole)]:
2170 rev = itm.text(self.RevisionColumn).split(":")[1].strip()
2171 if rev:
2172 revs.append(rev)
2173 shouldReopen = self.vcs.hgPull(revisions=revs)
2174 refresh = True
2175 if self.initialCommandMode == "incoming":
2176 addNext = True
2177
2178 if shouldReopen:
2179 res = E5MessageBox.yesNo(
2180 None,
2181 self.tr("Pull Changes"),
2182 self.tr(
2183 """The project should be reread. Do this now?"""),
2184 yesDefault=True)
2185 if res:
2186 e5App().getObject("Project").reopenProject()
2187 return
2188
2189 if refresh:
2190 self.on_refreshButton_clicked(addNext=addNext)
2191
2192 @pyqtSlot()
2193 def __pushActTriggered(self):
2194 """
2195 Private slot to push changes to a remote repository up to a selected
2196 changeset.
2197 """
2198 itm = self.logTree.selectedItems()[0]
2199 if not itm.data(0, self.__incomingRole):
2200 rev = itm.text(self.RevisionColumn).strip().split(":", 1)[0]
2201 if rev:
2202 self.vcs.hgPush(rev=rev)
2203 self.on_refreshButton_clicked(
2204 addNext=self.initialCommandMode == "outgoing")
2205
2206 @pyqtSlot()
2207 def __pushAllActTriggered(self):
2208 """
2209 Private slot to push all changes to a remote repository.
2210 """
2211 self.vcs.hgPush()
2212 self.on_refreshButton_clicked()
2213
2214 @pyqtSlot()
2215 def __stripActTriggered(self):
2216 """
2217 Private slot to strip changesets from the repository.
2218 """
2219 itm = self.logTree.selectedItems()[0]
2220 if not itm.data(0, self.__incomingRole):
2221 rev = itm.text(self.RevisionColumn).strip().split(":", 1)[1]
2222 shouldReopen = self.vcs.getExtensionObject("strip").hgStrip(
2223 rev=rev)
2224 if shouldReopen:
2225 res = E5MessageBox.yesNo(
2226 None,
2227 self.tr("Strip Changesets"),
2228 self.tr(
2229 """The project should be reread. Do this now?"""),
2230 yesDefault=True)
2231 if res:
2232 e5App().getObject("Project").reopenProject()
2233 return
2234
2235 self.on_refreshButton_clicked()
2236
2237 @pyqtSlot()
2238 def __mergeActTriggered(self):
2239 """
2240 Private slot to merge the working directory with the selected
2241 changeset.
2242 """
2243 itm = self.logTree.selectedItems()[0]
2244 if not itm.data(0, self.__incomingRole):
2245 rev = "rev({0})".format(
2246 itm.text(self.RevisionColumn).strip().split(":", 1)[0])
2247 self.vcs.vcsMerge("", rev=rev)
2248
2249 @pyqtSlot()
2250 def __bundleActTriggered(self):
2251 """
2252 Private slot to create a changegroup file.
2253 """
2254 if self.initialCommandMode in ("log", "full_log"):
2255 selectedItems = [itm for itm in self.logTree.selectedItems()
2256 if not itm.data(0, self.__incomingRole)]
2257 if len(selectedItems) == 0:
2258 # all revisions of the local repository will be bundled
2259 bundleData = {
2260 "revs": [],
2261 "base": "",
2262 "all": True,
2263 }
2264 elif len(selectedItems) == 1:
2265 # the selected changeset is the base
2266 rev = selectedItems[0].text(self.RevisionColumn).split(
2267 ":", 1)[0].strip()
2268 bundleData = {
2269 "revs": [],
2270 "base": rev,
2271 "all": False,
2272 }
2273 else:
2274 # lowest revision is the base, others will be bundled
2275 revs = []
2276 for itm in selectedItems:
2277 rev = itm.text(self.RevisionColumn).split(":", 1)[0]
2278 with contextlib.suppress(ValueError):
2279 revs.append(int(rev))
2280 baseRev = min(revs)
2281 while baseRev in revs:
2282 revs.remove(baseRev)
2283
2284 bundleData = {
2285 "revs": [str(rev) for rev in revs],
2286 "base": str(baseRev),
2287 "all": False,
2288 }
2289 elif self.initialCommandMode == "outgoing":
2290 selectedItems = self.logTree.selectedItems()
2291 if len(selectedItems) > 0:
2292 revs = []
2293 for itm in selectedItems:
2294 rev = itm.text(self.RevisionColumn).split(":", 1)[0]
2295 revs.append(rev.strip())
2296
2297 bundleData = {
2298 "revs": revs,
2299 "base": "",
2300 "all": False,
2301 }
2302
2303 self.vcs.hgBundle(bundleData=bundleData)
2304
2305 @pyqtSlot()
2306 def __unbundleActTriggered(self):
2307 """
2308 Private slot to apply the currently previewed bundle file.
2309 """
2310 if self.initialCommandMode == "incoming" and bool(self.__bundle):
2311 shouldReopen = self.vcs.hgUnbundle(files=[self.__bundle])
2312 if shouldReopen:
2313 res = E5MessageBox.yesNo(
2314 None,
2315 self.tr("Apply Changegroup"),
2316 self.tr("""The project should be reread. Do this now?"""),
2317 yesDefault=True)
2318 if res:
2319 e5App().getObject("Project").reopenProject()
2320 return
2321
2322 self.vcs.vcsLogBrowser()
2323 self.close()
2324
2325 @pyqtSlot()
2326 def __gpgSignActTriggered(self):
2327 """
2328 Private slot to sign the selected revisions.
2329 """
2330 revs = []
2331 for itm in [item for item in self.logTree.selectedItems()
2332 if not item.data(0, self.__incomingRole)]:
2333 rev = itm.text(self.RevisionColumn).split(":", 1)[0].strip()
2334 if rev:
2335 revs.append(rev)
2336
2337 if revs:
2338 self.vcs.getExtensionObject("gpg").hgGpgSign(revisions=revs)
2339
2340 @pyqtSlot()
2341 def __gpgVerifyActTriggered(self):
2342 """
2343 Private slot to verify the signatures of a selected revisions.
2344 """
2345 itm = self.logTree.selectedItems()[0]
2346 if not itm.data(0, self.__incomingRole):
2347 rev = itm.text(self.RevisionColumn).split(":", 1)[0].strip()
2348 if rev:
2349 self.vcs.getExtensionObject("gpg").hgGpgVerifySignatures(
2350 rev=rev)
2351
2352 def __selectAllActTriggered(self, select=True):
2353 """
2354 Private method to select or unselect all log entries.
2355
2356 @param select flag indicating to select all entries
2357 @type bool
2358 """
2359 blocked = self.logTree.blockSignals(True)
2360 for row in range(self.logTree.topLevelItemCount()):
2361 self.logTree.topLevelItem(row).setSelected(select)
2362 self.logTree.blockSignals(blocked)
2363 self.on_logTree_itemSelectionChanged()
2364
2365 def __actionMode(self):
2366 """
2367 Private method to get the selected action mode.
2368
2369 @return selected action mode (string, one of filter or find)
2370 """
2371 return self.modeComboBox.itemData(
2372 self.modeComboBox.currentIndex())
2373
2374 @pyqtSlot(int)
2375 def on_modeComboBox_currentIndexChanged(self, index):
2376 """
2377 Private slot to react on mode changes.
2378
2379 @param index index of the selected entry (integer)
2380 """
2381 mode = self.modeComboBox.itemData(index)
2382 findMode = mode == "find"
2383 filterMode = mode == "filter"
2384
2385 self.fromDate.setEnabled(filterMode)
2386 self.toDate.setEnabled(filterMode)
2387 self.branchCombo.setEnabled(filterMode)
2388 self.findPrevButton.setVisible(findMode)
2389 self.findNextButton.setVisible(findMode)
2390
2391 if findMode:
2392 for topIndex in range(self.logTree.topLevelItemCount()):
2393 self.logTree.topLevelItem(topIndex).setHidden(False)
2394 self.logTree.header().setSectionHidden(self.IconColumn, False)
2395 elif filterMode:
2396 self.__filterLogs()
2397
2398 @pyqtSlot()
2399 def on_findPrevButton_clicked(self):
2400 """
2401 Private slot to find the previous item matching the entered criteria.
2402 """
2403 self.__findItem(True)
2404
2405 @pyqtSlot()
2406 def on_findNextButton_clicked(self):
2407 """
2408 Private slot to find the next item matching the entered criteria.
2409 """
2410 self.__findItem(False)
2411
2412 def __findItem(self, backwards=False, interactive=False):
2413 """
2414 Private slot to find an item matching the entered criteria.
2415
2416 @param backwards flag indicating to search backwards (boolean)
2417 @param interactive flag indicating an interactive search (boolean)
2418 """
2419 self.__findBackwards = backwards
2420
2421 fieldIndex, searchRx, indexIsRole = self.__prepareFieldSearch()
2422 currentIndex = self.logTree.indexOfTopLevelItem(
2423 self.logTree.currentItem())
2424 if backwards:
2425 if interactive:
2426 indexes = range(currentIndex, -1, -1)
2427 else:
2428 indexes = range(currentIndex - 1, -1, -1)
2429 else:
2430 if interactive:
2431 indexes = range(currentIndex, self.logTree.topLevelItemCount())
2432 else:
2433 indexes = range(currentIndex + 1,
2434 self.logTree.topLevelItemCount())
2435
2436 for index in indexes:
2437 topItem = self.logTree.topLevelItem(index)
2438 if indexIsRole:
2439 if fieldIndex == self.__changesRole:
2440 changes = topItem.data(0, self.__changesRole)
2441 txt = "\n".join(
2442 [c["path"] for c in changes] +
2443 [c["copyfrom"] for c in changes]
2444 )
2445 else:
2446 # Find based on complete message text
2447 txt = "\n".join(topItem.data(0, self.__messageRole))
2448 else:
2449 txt = topItem.text(fieldIndex)
2450 if searchRx.search(txt) is not None:
2451 self.logTree.setCurrentItem(self.logTree.topLevelItem(index))
2452 break
2453 else:
2454 E5MessageBox.information(
2455 self,
2456 self.tr("Find Commit"),
2457 self.tr("""'{0}' was not found.""").format(self.rxEdit.text()))
2458
2459 def __revisionClicked(self, url):
2460 """
2461 Private slot to handle the anchorClicked signal of the changeset
2462 details pane.
2463
2464 @param url URL that was clicked
2465 @type QUrl
2466 """
2467 if url.scheme() == "rev":
2468 # a parent or child revision was clicked, show the respective item
2469 rev = url.path()
2470 searchStr = "{0:>7}:".format(rev)
2471 # format must be in sync with item generation format
2472 items = self.logTree.findItems(
2473 searchStr, Qt.MatchFlag.MatchStartsWith, self.RevisionColumn)
2474 if items:
2475 itm = items[0]
2476 if itm.isHidden():
2477 itm.setHidden(False)
2478 self.logTree.setCurrentItem(itm)
2479 else:
2480 # load the next batch and try again
2481 if self.nextButton.isEnabled():
2482 self.__addFinishCallback(
2483 lambda: self.__revisionClicked(url))
2484 self.on_nextButton_clicked()
2485
2486 ###########################################################################
2487 ## Diff handling methods below
2488 ###########################################################################
2489
2490 def __generateDiffs(self, parent=1):
2491 """
2492 Private slot to generate diff outputs for the selected item.
2493
2494 @param parent number of parent to diff against
2495 @type int
2496 """
2497 self.diffEdit.setPlainText(self.tr("Generating differences ..."))
2498 self.diffLabel.setText(self.tr("Differences"))
2499 self.diffSelectLabel.clear()
2500 self.diffHighlighter.regenerateRules()
2501
2502 selectedItems = self.logTree.selectedItems()
2503 if len(selectedItems) == 1:
2504 currentItem = selectedItems[0]
2505 rev2 = currentItem.text(self.RevisionColumn).split(":", 1)[0]
2506 parents = currentItem.data(0, self.__parentsRole)
2507 if len(parents) >= parent:
2508 self.diffLabel.setText(
2509 self.tr("Differences to Parent {0}").format(parent))
2510 rev1 = parents[parent - 1]
2511
2512 self.__diffGenerator.start(self.__filename, [rev1, rev2],
2513 self.__bundle)
2514
2515 if len(parents) > 1:
2516 if parent == 1:
2517 par1 = "&nbsp;1&nbsp;"
2518 else:
2519 par1 = '<a href="diff:1">&nbsp;1&nbsp;</a>'
2520 if parent == 2:
2521 par2 = "&nbsp;2&nbsp;"
2522 else:
2523 par2 = '<a href="diff:2">&nbsp;2&nbsp;</a>'
2524 self.diffSelectLabel.setText(
2525 self.tr('Diff to Parent {0}{1}').format(par1, par2))
2526 elif len(selectedItems) == 2:
2527 rev2 = int(selectedItems[0].text(
2528 self.RevisionColumn).split(":")[0])
2529 rev1 = int(selectedItems[1].text(
2530 self.RevisionColumn).split(":")[0])
2531
2532 self.__diffGenerator.start(self.__filename,
2533 [min(rev1, rev2), max(rev1, rev2)],
2534 self.__bundle)
2535 else:
2536 self.diffEdit.clear()
2537
2538 def __generatorFinished(self):
2539 """
2540 Private slot connected to the finished signal of the diff generator.
2541 """
2542 diff, errors, fileSeparators = self.__diffGenerator.getResult()
2543
2544 if diff:
2545 self.diffEdit.setPlainText("".join(diff))
2546 elif errors:
2547 self.diffEdit.setPlainText("".join(errors))
2548 else:
2549 self.diffEdit.setPlainText(self.tr('There is no difference.'))
2550
2551 self.saveLabel.setVisible(bool(diff))
2552
2553 if self.__diffUpdatesFiles:
2554 for oldFileName, newFileName, lineNumber in fileSeparators:
2555 if oldFileName == newFileName:
2556 fileName = oldFileName
2557 elif oldFileName == "__NULL__":
2558 fileName = newFileName
2559 else:
2560 fileName = oldFileName
2561 item = QTreeWidgetItem(self.filesTree, ["", fileName, ""])
2562 item.setData(0, self.__diffFileLineRole, lineNumber)
2563 self.__resizeColumnsFiles()
2564 self.__resortFiles()
2565 else:
2566 for oldFileName, newFileName, lineNumber in fileSeparators:
2567 for fileName in (oldFileName, newFileName):
2568 if fileName != "__NULL__":
2569 items = self.filesTree.findItems(
2570 fileName, Qt.MatchFlag.MatchExactly, 1)
2571 for item in items:
2572 item.setData(0, self.__diffFileLineRole,
2573 lineNumber)
2574
2575 tc = self.diffEdit.textCursor()
2576 tc.movePosition(QTextCursor.MoveOperation.Start)
2577 self.diffEdit.setTextCursor(tc)
2578 self.diffEdit.ensureCursorVisible()
2579
2580 @pyqtSlot(QTreeWidgetItem, QTreeWidgetItem)
2581 def on_filesTree_currentItemChanged(self, current, previous):
2582 """
2583 Private slot called, when the current item of the files tree changes.
2584
2585 @param current reference to the new current item (QTreeWidgetItem)
2586 @param previous reference to the old current item (QTreeWidgetItem)
2587 """
2588 if current:
2589 para = current.data(0, self.__diffFileLineRole)
2590 if para is not None:
2591 if para == 0:
2592 tc = self.diffEdit.textCursor()
2593 tc.movePosition(QTextCursor.MoveOperation.Start)
2594 self.diffEdit.setTextCursor(tc)
2595 self.diffEdit.ensureCursorVisible()
2596 elif para == -1:
2597 tc = self.diffEdit.textCursor()
2598 tc.movePosition(QTextCursor.MoveOperation.End)
2599 self.diffEdit.setTextCursor(tc)
2600 self.diffEdit.ensureCursorVisible()
2601 else:
2602 # step 1: move cursor to end
2603 tc = self.diffEdit.textCursor()
2604 tc.movePosition(QTextCursor.MoveOperation.End)
2605 self.diffEdit.setTextCursor(tc)
2606 self.diffEdit.ensureCursorVisible()
2607
2608 # step 2: move cursor to desired line
2609 tc = self.diffEdit.textCursor()
2610 delta = tc.blockNumber() - para
2611 tc.movePosition(QTextCursor.MoveOperation.PreviousBlock,
2612 QTextCursor.MoveMode.MoveAnchor, delta)
2613 self.diffEdit.setTextCursor(tc)
2614 self.diffEdit.ensureCursorVisible()
2615
2616 @pyqtSlot(str)
2617 def on_diffSelectLabel_linkActivated(self, link):
2618 """
2619 Private slot to handle the selection of a diff target.
2620
2621 @param link activated link
2622 @type str
2623 """
2624 if ":" in link:
2625 scheme, parent = link.split(":", 1)
2626 if scheme == "diff":
2627 with contextlib.suppress(ValueError):
2628 parent = int(parent)
2629 self.__generateDiffs(parent)
2630
2631 @pyqtSlot(str)
2632 def on_saveLabel_linkActivated(self, link):
2633 """
2634 Private slot to handle the selection of the save link.
2635
2636 @param link activated link
2637 @type str
2638 """
2639 if ":" not in link:
2640 return
2641
2642 scheme, rest = link.split(":", 1)
2643 if scheme != "save" or rest != "me":
2644 return
2645
2646 if self.projectMode:
2647 fname = self.vcs.splitPath(self.__filename)[0]
2648 fname += "/{0}.diff".format(os.path.split(fname)[-1])
2649 else:
2650 dname, fname = self.vcs.splitPath(self.__filename)
2651 if fname != '.':
2652 fname = "{0}.diff".format(self.__filename)
2653 else:
2654 fname = dname
2655
2656 fname, selectedFilter = E5FileDialog.getSaveFileNameAndFilter(
2657 self,
2658 self.tr("Save Diff"),
2659 fname,
2660 self.tr("Patch Files (*.diff)"),
2661 None,
2662 E5FileDialog.Options(E5FileDialog.DontConfirmOverwrite))
2663
2664 if not fname:
2665 return # user aborted
2666
2667 ext = QFileInfo(fname).suffix()
2668 if not ext:
2669 ex = selectedFilter.split("(*")[1].split(")")[0]
2670 if ex:
2671 fname += ex
2672 if QFileInfo(fname).exists():
2673 res = E5MessageBox.yesNo(
2674 self,
2675 self.tr("Save Diff"),
2676 self.tr("<p>The patch file <b>{0}</b> already exists."
2677 " Overwrite it?</p>").format(fname),
2678 icon=E5MessageBox.Warning)
2679 if not res:
2680 return
2681 fname = Utilities.toNativeSeparators(fname)
2682
2683 eol = e5App().getObject("Project").getEolString()
2684 try:
2685 with open(fname, "w", encoding="utf-8", newline="") as f:
2686 f.write(eol.join(self.diffEdit.toPlainText().splitlines()))
2687 except OSError as why:
2688 E5MessageBox.critical(
2689 self, self.tr('Save Diff'),
2690 self.tr(
2691 '<p>The patch file <b>{0}</b> could not be saved.'
2692 '<br>Reason: {1}</p>')
2693 .format(fname, str(why)))
2694
2695 @pyqtSlot(str)
2696 def on_sbsSelectLabel_linkActivated(self, link):
2697 """
2698 Private slot to handle selection of a side-by-side link.
2699
2700 @param link text of the selected link
2701 @type str
2702 """
2703 if ":" in link and self.__filename is not None:
2704 scheme, path = link.split(":", 1)
2705 if scheme == "sbsdiff" and "_" in path:
2706 rev1, rev2 = path.split("_", 1)
2707 self.vcs.hgSbsDiff(self.__filename, revisions=(rev1, rev2))

eric ide

mercurial