eric7/Plugins/VcsPlugins/vcsGit/GitStatusDialog.py

branch
eric7
changeset 8312
800c432b34c8
parent 8259
2bbec88047dd
child 8318
962bce857696
equal deleted inserted replaced
8311:4e8b98454baa 8312:800c432b34c8
1 # -*- coding: utf-8 -*-
2
3 # Copyright (c) 2014 - 2021 Detlev Offenbach <detlev@die-offenbachs.de>
4 #
5
6 """
7 Module implementing a dialog to show the output of the git status command
8 process.
9 """
10
11 import os
12 import tempfile
13 import contextlib
14
15 from PyQt5.QtCore import pyqtSlot, Qt, QProcess, QTimer, QSize
16 from PyQt5.QtGui import QTextCursor
17 from PyQt5.QtWidgets import (
18 QWidget, QDialogButtonBox, QMenu, QHeaderView, QTreeWidgetItem, QLineEdit,
19 QInputDialog
20 )
21
22 from E5Gui.E5Application import e5App
23 from E5Gui import E5MessageBox
24
25 from Globals import strToQByteArray
26
27 from .Ui_GitStatusDialog import Ui_GitStatusDialog
28
29 from .GitDiffHighlighter import GitDiffHighlighter
30 from .GitDiffGenerator import GitDiffGenerator
31 from .GitDiffParser import GitDiffParser
32
33 import Preferences
34 import UI.PixmapCache
35 import Utilities
36
37
38 class GitStatusDialog(QWidget, Ui_GitStatusDialog):
39 """
40 Class implementing a dialog to show the output of the git status command
41 process.
42 """
43 ConflictStates = ["AA", "AU", "DD", "DU", "UA", "UD", "UU"]
44
45 ConflictRole = Qt.ItemDataRole.UserRole
46
47 def __init__(self, vcs, parent=None):
48 """
49 Constructor
50
51 @param vcs reference to the vcs object
52 @param parent parent widget (QWidget)
53 """
54 super().__init__(parent)
55 self.setupUi(self)
56
57 self.__toBeCommittedColumn = 0
58 self.__statusWorkColumn = 1
59 self.__statusIndexColumn = 2
60 self.__pathColumn = 3
61 self.__lastColumn = self.statusList.columnCount()
62
63 self.refreshButton = self.buttonBox.addButton(
64 self.tr("Refresh"), QDialogButtonBox.ButtonRole.ActionRole)
65 self.refreshButton.setToolTip(
66 self.tr("Press to refresh the status display"))
67 self.refreshButton.setEnabled(False)
68 self.buttonBox.button(
69 QDialogButtonBox.StandardButton.Close).setEnabled(False)
70 self.buttonBox.button(
71 QDialogButtonBox.StandardButton.Cancel).setDefault(True)
72
73 self.diff = None
74 self.vcs = vcs
75 self.vcs.committed.connect(self.__committed)
76 self.process = QProcess()
77 self.process.finished.connect(self.__procFinished)
78 self.process.readyReadStandardOutput.connect(self.__readStdout)
79 self.process.readyReadStandardError.connect(self.__readStderr)
80
81 self.errorGroup.hide()
82 self.inputGroup.hide()
83
84 self.vDiffSplitter.setStretchFactor(0, 2)
85 self.vDiffSplitter.setStretchFactor(0, 2)
86 self.vDiffSplitter.setSizes([400, 400])
87 self.__hDiffSplitterState = None
88 self.__vDiffSplitterState = None
89
90 self.statusList.headerItem().setText(self.__lastColumn, "")
91 self.statusList.header().setSortIndicator(
92 self.__pathColumn, Qt.SortOrder.AscendingOrder)
93
94 font = Preferences.getEditorOtherFonts("MonospacedFont")
95 self.lDiffEdit.document().setDefaultFont(font)
96 self.rDiffEdit.document().setDefaultFont(font)
97 self.lDiffEdit.customContextMenuRequested.connect(
98 self.__showLDiffContextMenu)
99 self.rDiffEdit.customContextMenuRequested.connect(
100 self.__showRDiffContextMenu)
101
102 self.__lDiffMenu = QMenu()
103 self.__stageLinesAct = self.__lDiffMenu.addAction(
104 UI.PixmapCache.getIcon("vcsAdd"),
105 self.tr("Stage Selected Lines"),
106 self.__stageHunkOrLines)
107 self.__revertLinesAct = self.__lDiffMenu.addAction(
108 UI.PixmapCache.getIcon("vcsRevert"),
109 self.tr("Revert Selected Lines"),
110 self.__revertHunkOrLines)
111 self.__stageHunkAct = self.__lDiffMenu.addAction(
112 UI.PixmapCache.getIcon("vcsAdd"),
113 self.tr("Stage Hunk"),
114 self.__stageHunkOrLines)
115 self.__revertHunkAct = self.__lDiffMenu.addAction(
116 UI.PixmapCache.getIcon("vcsRevert"),
117 self.tr("Revert Hunk"),
118 self.__revertHunkOrLines)
119
120 self.__rDiffMenu = QMenu()
121 self.__unstageLinesAct = self.__rDiffMenu.addAction(
122 UI.PixmapCache.getIcon("vcsRemove"),
123 self.tr("Unstage Selected Lines"),
124 self.__unstageHunkOrLines)
125 self.__unstageHunkAct = self.__rDiffMenu.addAction(
126 UI.PixmapCache.getIcon("vcsRemove"),
127 self.tr("Unstage Hunk"),
128 self.__unstageHunkOrLines)
129
130 self.lDiffHighlighter = GitDiffHighlighter(self.lDiffEdit.document())
131 self.rDiffHighlighter = GitDiffHighlighter(self.rDiffEdit.document())
132
133 self.lDiffParser = None
134 self.rDiffParser = None
135
136 self.__selectedName = ""
137
138 self.__diffGenerator = GitDiffGenerator(vcs, self)
139 self.__diffGenerator.finished.connect(self.__generatorFinished)
140
141 self.modifiedIndicators = [
142 self.tr('added'),
143 self.tr('copied'),
144 self.tr('deleted'),
145 self.tr('modified'),
146 self.tr('renamed'),
147 ]
148 self.modifiedOnlyIndicators = [
149 self.tr('modified'),
150 ]
151
152 self.unversionedIndicators = [
153 self.tr('not tracked'),
154 ]
155
156 self.missingIndicators = [
157 self.tr('deleted'),
158 ]
159
160 self.unmergedIndicators = [
161 self.tr('unmerged'),
162 ]
163
164 self.status = {
165 ' ': self.tr("unmodified"),
166 'A': self.tr('added'),
167 'C': self.tr('copied'),
168 'D': self.tr('deleted'),
169 'M': self.tr('modified'),
170 'R': self.tr('renamed'),
171 'U': self.tr('unmerged'),
172 '?': self.tr('not tracked'),
173 '!': self.tr('ignored'),
174 }
175
176 self.__ioEncoding = Preferences.getSystem("IOEncoding")
177
178 self.__initActionsMenu()
179
180 def __initActionsMenu(self):
181 """
182 Private method to initialize the actions menu.
183 """
184 self.__actionsMenu = QMenu()
185 self.__actionsMenu.setTearOffEnabled(True)
186 self.__actionsMenu.setToolTipsVisible(True)
187 self.__actionsMenu.aboutToShow.connect(self.__showActionsMenu)
188
189 self.__commitAct = self.__actionsMenu.addAction(
190 self.tr("Commit"), self.__commit)
191 self.__commitAct.setToolTip(self.tr("Commit the selected changes"))
192 self.__amendAct = self.__actionsMenu.addAction(
193 self.tr("Amend"), self.__amend)
194 self.__amendAct.setToolTip(self.tr(
195 "Amend the latest commit with the selected changes"))
196 self.__commitSelectAct = self.__actionsMenu.addAction(
197 self.tr("Select all for commit"), self.__commitSelectAll)
198 self.__commitDeselectAct = self.__actionsMenu.addAction(
199 self.tr("Unselect all from commit"), self.__commitDeselectAll)
200
201 self.__actionsMenu.addSeparator()
202 self.__addAct = self.__actionsMenu.addAction(
203 self.tr("Add"), self.__add)
204 self.__addAct.setToolTip(self.tr("Add the selected files"))
205 self.__stageAct = self.__actionsMenu.addAction(
206 self.tr("Stage changes"), self.__stage)
207 self.__stageAct.setToolTip(self.tr(
208 "Stages all changes of the selected files"))
209 self.__unstageAct = self.__actionsMenu.addAction(
210 self.tr("Unstage changes"), self.__unstage)
211 self.__unstageAct.setToolTip(self.tr(
212 "Unstages all changes of the selected files"))
213
214 self.__actionsMenu.addSeparator()
215
216 self.__diffAct = self.__actionsMenu.addAction(
217 self.tr("Differences"), self.__diff)
218 self.__diffAct.setToolTip(self.tr(
219 "Shows the differences of the selected entry in a"
220 " separate dialog"))
221 self.__sbsDiffAct = self.__actionsMenu.addAction(
222 self.tr("Differences Side-By-Side"), self.__sbsDiff)
223 self.__sbsDiffAct.setToolTip(self.tr(
224 "Shows the differences of the selected entry side-by-side in"
225 " a separate dialog"))
226
227 self.__actionsMenu.addSeparator()
228
229 self.__revertAct = self.__actionsMenu.addAction(
230 self.tr("Revert"), self.__revert)
231 self.__revertAct.setToolTip(self.tr(
232 "Reverts the changes of the selected files"))
233
234 self.__actionsMenu.addSeparator()
235
236 self.__forgetAct = self.__actionsMenu.addAction(
237 self.tr("Forget missing"), self.__forget)
238 self.__forgetAct.setToolTip(self.tr(
239 "Forgets about the selected missing files"))
240 self.__restoreAct = self.__actionsMenu.addAction(
241 self.tr("Restore missing"), self.__restoreMissing)
242 self.__restoreAct.setToolTip(self.tr(
243 "Restores the selected missing files"))
244
245 self.__actionsMenu.addSeparator()
246
247 self.__editAct = self.__actionsMenu.addAction(
248 self.tr("Edit file"), self.__editConflict)
249 self.__editAct.setToolTip(self.tr(
250 "Edit the selected conflicting file"))
251
252 self.__actionsMenu.addSeparator()
253
254 act = self.__actionsMenu.addAction(
255 self.tr("Adjust column sizes"), self.__resizeColumns)
256 act.setToolTip(self.tr(
257 "Adjusts the width of all columns to their contents"))
258
259 self.actionsButton.setIcon(
260 UI.PixmapCache.getIcon("actionsToolButton"))
261 self.actionsButton.setMenu(self.__actionsMenu)
262
263 def closeEvent(self, e):
264 """
265 Protected slot implementing a close event handler.
266
267 @param e close event (QCloseEvent)
268 """
269 if (
270 self.process is not None and
271 self.process.state() != QProcess.ProcessState.NotRunning
272 ):
273 self.process.terminate()
274 QTimer.singleShot(2000, self.process.kill)
275 self.process.waitForFinished(3000)
276
277 self.vcs.getPlugin().setPreferences(
278 "StatusDialogGeometry", self.saveGeometry())
279 self.vcs.getPlugin().setPreferences(
280 "StatusDialogSplitterStates", [
281 self.vDiffSplitter.saveState(),
282 self.hDiffSplitter.saveState()
283 ]
284 )
285
286 e.accept()
287
288 def show(self):
289 """
290 Public slot to show the dialog.
291 """
292 super().show()
293
294 geom = self.vcs.getPlugin().getPreferences(
295 "StatusDialogGeometry")
296 if geom.isEmpty():
297 s = QSize(900, 600)
298 self.resize(s)
299 else:
300 self.restoreGeometry(geom)
301
302 states = self.vcs.getPlugin().getPreferences(
303 "StatusDialogSplitterStates")
304 if len(states) == 2:
305 # we have two splitters
306 self.vDiffSplitter.restoreState(states[0])
307 self.hDiffSplitter.restoreState(states[1])
308
309 def __resort(self):
310 """
311 Private method to resort the tree.
312 """
313 self.statusList.sortItems(
314 self.statusList.sortColumn(),
315 self.statusList.header().sortIndicatorOrder())
316
317 def __resizeColumns(self):
318 """
319 Private method to resize the list columns.
320 """
321 self.statusList.header().resizeSections(
322 QHeaderView.ResizeMode.ResizeToContents)
323 self.statusList.header().setStretchLastSection(True)
324
325 def __generateItem(self, status, path):
326 """
327 Private method to generate a status item in the status list.
328
329 @param status status indicator (string)
330 @param path path of the file or directory (string)
331 """
332 statusWorkText = self.status[status[1]]
333 statusIndexText = self.status[status[0]]
334 itm = QTreeWidgetItem(self.statusList, [
335 "",
336 statusWorkText,
337 statusIndexText,
338 path,
339 ])
340
341 itm.setTextAlignment(self.__statusWorkColumn,
342 Qt.AlignmentFlag.AlignHCenter)
343 itm.setTextAlignment(self.__statusIndexColumn,
344 Qt.AlignmentFlag.AlignHCenter)
345 itm.setTextAlignment(self.__pathColumn,
346 Qt.AlignmentFlag.AlignLeft)
347
348 if (
349 status not in self.ConflictStates + ["??", "!!"] and
350 statusIndexText in self.modifiedIndicators
351 ):
352 itm.setFlags(itm.flags() | Qt.ItemFlag.ItemIsUserCheckable)
353 itm.setCheckState(self.__toBeCommittedColumn,
354 Qt.CheckState.Checked)
355 else:
356 itm.setFlags(itm.flags() & ~Qt.ItemFlag.ItemIsUserCheckable)
357
358 if statusWorkText not in self.__statusFilters:
359 self.__statusFilters.append(statusWorkText)
360 if statusIndexText not in self.__statusFilters:
361 self.__statusFilters.append(statusIndexText)
362
363 if status in self.ConflictStates:
364 itm.setIcon(self.__statusWorkColumn,
365 UI.PixmapCache.getIcon(
366 os.path.join("VcsPlugins", "vcsGit", "icons",
367 "conflict.svg")))
368 itm.setData(0, self.ConflictRole, status in self.ConflictStates)
369
370 def start(self, fn):
371 """
372 Public slot to start the git status command.
373
374 @param fn filename(s)/directoryname(s) to show the status of
375 (string or list of strings)
376 """
377 self.errorGroup.hide()
378 self.intercept = False
379 self.args = fn
380
381 self.__ioEncoding = Preferences.getSystem("IOEncoding")
382
383 self.statusFilterCombo.clear()
384 self.__statusFilters = []
385 self.statusList.clear()
386
387 self.setWindowTitle(self.tr('Git Status'))
388
389 args = self.vcs.initCommand("status")
390 args.append('--porcelain')
391 args.append("--")
392 if isinstance(fn, list):
393 self.dname, fnames = self.vcs.splitPathList(fn)
394 self.vcs.addArguments(args, fn)
395 else:
396 self.dname, fname = self.vcs.splitPath(fn)
397 args.append(fn)
398
399 # find the root of the repo
400 self.__repodir = self.dname
401 while not os.path.isdir(
402 os.path.join(self.__repodir, self.vcs.adminDir)):
403 self.__repodir = os.path.dirname(self.__repodir)
404 if os.path.splitdrive(self.__repodir)[1] == os.sep:
405 return
406
407 self.process.kill()
408 self.process.setWorkingDirectory(self.__repodir)
409
410 self.process.start('git', args)
411 procStarted = self.process.waitForStarted(5000)
412 if not procStarted:
413 self.inputGroup.setEnabled(False)
414 self.inputGroup.hide()
415 E5MessageBox.critical(
416 self,
417 self.tr('Process Generation Error'),
418 self.tr(
419 'The process {0} could not be started. '
420 'Ensure, that it is in the search path.'
421 ).format('git'))
422 else:
423 self.buttonBox.button(
424 QDialogButtonBox.StandardButton.Close).setEnabled(False)
425 self.buttonBox.button(
426 QDialogButtonBox.StandardButton.Cancel).setEnabled(True)
427 self.buttonBox.button(
428 QDialogButtonBox.StandardButton.Cancel).setDefault(True)
429
430 self.refreshButton.setEnabled(False)
431
432 def __finish(self):
433 """
434 Private slot called when the process finished or the user pressed
435 the button.
436 """
437 if (
438 self.process is not None and
439 self.process.state() != QProcess.ProcessState.NotRunning
440 ):
441 self.process.terminate()
442 QTimer.singleShot(2000, self.process.kill)
443 self.process.waitForFinished(3000)
444
445 self.inputGroup.setEnabled(False)
446 self.inputGroup.hide()
447 self.refreshButton.setEnabled(True)
448
449 self.buttonBox.button(
450 QDialogButtonBox.StandardButton.Close).setEnabled(True)
451 self.buttonBox.button(
452 QDialogButtonBox.StandardButton.Cancel).setEnabled(False)
453 self.buttonBox.button(
454 QDialogButtonBox.StandardButton.Close).setDefault(True)
455 self.buttonBox.button(
456 QDialogButtonBox.StandardButton.Close).setFocus(
457 Qt.FocusReason.OtherFocusReason)
458
459 self.__statusFilters.sort()
460 self.__statusFilters.insert(0, "<{0}>".format(self.tr("all")))
461 self.statusFilterCombo.addItems(self.__statusFilters)
462
463 self.__resort()
464 self.__resizeColumns()
465
466 self.__refreshDiff()
467
468 def on_buttonBox_clicked(self, button):
469 """
470 Private slot called by a button of the button box clicked.
471
472 @param button button that was clicked (QAbstractButton)
473 """
474 if button == self.buttonBox.button(
475 QDialogButtonBox.StandardButton.Close
476 ):
477 self.close()
478 elif button == self.buttonBox.button(
479 QDialogButtonBox.StandardButton.Cancel
480 ):
481 self.__finish()
482 elif button == self.refreshButton:
483 self.on_refreshButton_clicked()
484
485 def __procFinished(self, exitCode, exitStatus):
486 """
487 Private slot connected to the finished signal.
488
489 @param exitCode exit code of the process (integer)
490 @param exitStatus exit status of the process (QProcess.ExitStatus)
491 """
492 self.__finish()
493
494 def __readStdout(self):
495 """
496 Private slot to handle the readyReadStandardOutput signal.
497
498 It reads the output of the process, formats it and inserts it into
499 the contents pane.
500 """
501 if self.process is not None:
502 self.process.setReadChannel(QProcess.ProcessChannel.StandardOutput)
503
504 while self.process.canReadLine():
505 line = str(self.process.readLine(), self.__ioEncoding,
506 'replace')
507
508 status = line[:2]
509 path = line[3:].strip().split(" -> ")[-1].strip('"')
510 self.__generateItem(status, path)
511
512 def __readStderr(self):
513 """
514 Private slot to handle the readyReadStandardError signal.
515
516 It reads the error output of the process and inserts it into the
517 error pane.
518 """
519 if self.process is not None:
520 s = str(self.process.readAllStandardError(),
521 self.__ioEncoding, 'replace')
522 self.errorGroup.show()
523 self.errors.insertPlainText(s)
524 self.errors.ensureCursorVisible()
525
526 # show input in case the process asked for some input
527 self.inputGroup.setEnabled(True)
528 self.inputGroup.show()
529
530 def on_passwordCheckBox_toggled(self, isOn):
531 """
532 Private slot to handle the password checkbox toggled.
533
534 @param isOn flag indicating the status of the check box (boolean)
535 """
536 if isOn:
537 self.input.setEchoMode(QLineEdit.EchoMode.Password)
538 else:
539 self.input.setEchoMode(QLineEdit.EchoMode.Normal)
540
541 @pyqtSlot()
542 def on_sendButton_clicked(self):
543 """
544 Private slot to send the input to the git process.
545 """
546 inputTxt = self.input.text()
547 inputTxt += os.linesep
548
549 if self.passwordCheckBox.isChecked():
550 self.errors.insertPlainText(os.linesep)
551 self.errors.ensureCursorVisible()
552 else:
553 self.errors.insertPlainText(inputTxt)
554 self.errors.ensureCursorVisible()
555
556 self.process.write(strToQByteArray(inputTxt))
557
558 self.passwordCheckBox.setChecked(False)
559 self.input.clear()
560
561 def on_input_returnPressed(self):
562 """
563 Private slot to handle the press of the return key in the input field.
564 """
565 self.intercept = True
566 self.on_sendButton_clicked()
567
568 def keyPressEvent(self, evt):
569 """
570 Protected slot to handle a key press event.
571
572 @param evt the key press event (QKeyEvent)
573 """
574 if self.intercept:
575 self.intercept = False
576 evt.accept()
577 return
578 super().keyPressEvent(evt)
579
580 @pyqtSlot()
581 def on_refreshButton_clicked(self):
582 """
583 Private slot to refresh the status display.
584 """
585 selectedItems = self.statusList.selectedItems()
586 if len(selectedItems) == 1:
587 self.__selectedName = selectedItems[0].text(self.__pathColumn)
588 else:
589 self.__selectedName = ""
590
591 self.start(self.args)
592
593 @pyqtSlot(int)
594 def on_statusFilterCombo_activated(self, index):
595 """
596 Private slot to react to the selection of a status filter.
597
598 @param index index of the selected entry
599 @type int
600 """
601 txt = self.statusFilterCombo.itemText(index)
602 if txt == "<{0}>".format(self.tr("all")):
603 for topIndex in range(self.statusList.topLevelItemCount()):
604 topItem = self.statusList.topLevelItem(topIndex)
605 topItem.setHidden(False)
606 else:
607 for topIndex in range(self.statusList.topLevelItemCount()):
608 topItem = self.statusList.topLevelItem(topIndex)
609 topItem.setHidden(
610 topItem.text(self.__statusWorkColumn) != txt and
611 topItem.text(self.__statusIndexColumn) != txt
612 )
613
614 @pyqtSlot()
615 def on_statusList_itemSelectionChanged(self):
616 """
617 Private slot to act upon changes of selected items.
618 """
619 self.__generateDiffs()
620
621 ###########################################################################
622 ## Menu handling methods
623 ###########################################################################
624
625 def __showActionsMenu(self):
626 """
627 Private slot to prepare the actions button menu before it is shown.
628 """
629 modified = len(self.__getModifiedItems())
630 modifiedOnly = len(self.__getModifiedOnlyItems())
631 unversioned = len(self.__getUnversionedItems())
632 missing = len(self.__getMissingItems())
633 commitable = len(self.__getCommitableItems())
634 commitableUnselected = len(self.__getCommitableUnselectedItems())
635 stageable = len(self.__getStageableItems())
636 unstageable = len(self.__getUnstageableItems())
637 conflicting = len(self.__getConflictingItems())
638
639 self.__commitAct.setEnabled(commitable)
640 self.__amendAct.setEnabled(commitable)
641 self.__commitSelectAct.setEnabled(commitableUnselected)
642 self.__commitDeselectAct.setEnabled(commitable)
643 self.__addAct.setEnabled(unversioned)
644 self.__stageAct.setEnabled(stageable)
645 self.__unstageAct.setEnabled(unstageable)
646 self.__diffAct.setEnabled(modified)
647 self.__sbsDiffAct.setEnabled(modifiedOnly == 1)
648 self.__revertAct.setEnabled(stageable)
649 self.__forgetAct.setEnabled(missing)
650 self.__restoreAct.setEnabled(missing)
651 self.__editAct.setEnabled(conflicting == 1)
652
653 def __amend(self):
654 """
655 Private slot to handle the Amend context menu entry.
656 """
657 self.__commit(amend=True)
658
659 def __commit(self, amend=False):
660 """
661 Private slot to handle the Commit context menu entry.
662
663 @param amend flag indicating to perform an amend operation (boolean)
664 """
665 names = [os.path.join(self.dname, itm.text(self.__pathColumn))
666 for itm in self.__getCommitableItems()]
667 if not names:
668 E5MessageBox.information(
669 self,
670 self.tr("Commit"),
671 self.tr("""There are no entries selected to be"""
672 """ committed."""))
673 return
674
675 if Preferences.getVCS("AutoSaveFiles"):
676 vm = e5App().getObject("ViewManager")
677 for name in names:
678 vm.saveEditor(name)
679 self.vcs.vcsCommit(names, commitAll=False, amend=amend)
680 # staged changes
681
682 def __committed(self):
683 """
684 Private slot called after the commit has finished.
685 """
686 if self.isVisible():
687 self.on_refreshButton_clicked()
688 self.vcs.checkVCSStatus()
689
690 def __commitSelectAll(self):
691 """
692 Private slot to select all entries for commit.
693 """
694 self.__commitSelect(True)
695
696 def __commitDeselectAll(self):
697 """
698 Private slot to deselect all entries from commit.
699 """
700 self.__commitSelect(False)
701
702 def __add(self):
703 """
704 Private slot to handle the Add context menu entry.
705 """
706 names = [os.path.join(self.dname, itm.text(self.__pathColumn))
707 for itm in self.__getUnversionedItems()]
708 if not names:
709 E5MessageBox.information(
710 self,
711 self.tr("Add"),
712 self.tr("""There are no unversioned entries"""
713 """ available/selected."""))
714 return
715
716 self.vcs.vcsAdd(names)
717 self.on_refreshButton_clicked()
718
719 project = e5App().getObject("Project")
720 for name in names:
721 project.getModel().updateVCSStatus(name)
722 self.vcs.checkVCSStatus()
723
724 def __stage(self):
725 """
726 Private slot to handle the Stage context menu entry.
727 """
728 names = [os.path.join(self.dname, itm.text(self.__pathColumn))
729 for itm in self.__getStageableItems()]
730 if not names:
731 E5MessageBox.information(
732 self,
733 self.tr("Stage"),
734 self.tr("""There are no stageable entries"""
735 """ available/selected."""))
736 return
737
738 self.vcs.vcsAdd(names)
739 self.on_refreshButton_clicked()
740
741 project = e5App().getObject("Project")
742 for name in names:
743 project.getModel().updateVCSStatus(name)
744 self.vcs.checkVCSStatus()
745
746 def __unstage(self):
747 """
748 Private slot to handle the Unstage context menu entry.
749 """
750 names = [os.path.join(self.dname, itm.text(self.__pathColumn))
751 for itm in self.__getUnstageableItems()]
752 if not names:
753 E5MessageBox.information(
754 self,
755 self.tr("Unstage"),
756 self.tr("""There are no unstageable entries"""
757 """ available/selected."""))
758 return
759
760 self.vcs.gitUnstage(names)
761 self.on_refreshButton_clicked()
762
763 project = e5App().getObject("Project")
764 for name in names:
765 project.getModel().updateVCSStatus(name)
766 self.vcs.checkVCSStatus()
767
768 def __forget(self):
769 """
770 Private slot to handle the Forget Missing context menu entry.
771 """
772 names = [os.path.join(self.dname, itm.text(self.__pathColumn))
773 for itm in self.__getMissingItems()]
774 if not names:
775 E5MessageBox.information(
776 self,
777 self.tr("Forget Missing"),
778 self.tr("""There are no missing entries"""
779 """ available/selected."""))
780 return
781
782 self.vcs.vcsRemove(names, stageOnly=True)
783 self.on_refreshButton_clicked()
784
785 def __revert(self):
786 """
787 Private slot to handle the Revert context menu entry.
788 """
789 names = [os.path.join(self.dname, itm.text(self.__pathColumn))
790 for itm in self.__getStageableItems()]
791 if not names:
792 E5MessageBox.information(
793 self,
794 self.tr("Revert"),
795 self.tr("""There are no uncommitted, unstaged changes"""
796 """ available/selected."""))
797 return
798
799 self.vcs.gitRevert(names)
800 self.raise_()
801 self.activateWindow()
802 self.on_refreshButton_clicked()
803
804 project = e5App().getObject("Project")
805 for name in names:
806 project.getModel().updateVCSStatus(name)
807 self.vcs.checkVCSStatus()
808
809 def __restoreMissing(self):
810 """
811 Private slot to handle the Restore Missing context menu entry.
812 """
813 names = [os.path.join(self.dname, itm.text(self.__pathColumn))
814 for itm in self.__getMissingItems()]
815 if not names:
816 E5MessageBox.information(
817 self,
818 self.tr("Restore Missing"),
819 self.tr("""There are no missing entries"""
820 """ available/selected."""))
821 return
822
823 self.vcs.gitRevert(names)
824 self.on_refreshButton_clicked()
825 self.vcs.checkVCSStatus()
826
827 def __editConflict(self):
828 """
829 Private slot to handle the Edit file context menu entry.
830 """
831 itm = self.__getConflictingItems()[0]
832 filename = os.path.join(self.__repodir, itm.text(self.__pathColumn))
833 if Utilities.MimeTypes.isTextFile(filename):
834 e5App().getObject("ViewManager").getEditor(filename)
835
836 def __diff(self):
837 """
838 Private slot to handle the Diff context menu entry.
839 """
840 namesW = [os.path.join(self.dname, itm.text(self.__pathColumn))
841 for itm in self.__getStageableItems()]
842 namesS = [os.path.join(self.dname, itm.text(self.__pathColumn))
843 for itm in self.__getUnstageableItems()]
844 if not namesW and not namesS:
845 E5MessageBox.information(
846 self,
847 self.tr("Differences"),
848 self.tr("""There are no uncommitted changes"""
849 """ available/selected."""))
850 return
851
852 diffMode = "work2stage2repo"
853 names = namesW + namesS
854
855 if self.diff is None:
856 from .GitDiffDialog import GitDiffDialog
857 self.diff = GitDiffDialog(self.vcs)
858 self.diff.show()
859 self.diff.start(names, diffMode=diffMode, refreshable=True)
860
861 def __sbsDiff(self):
862 """
863 Private slot to handle the Diff context menu entry.
864 """
865 itm = self.__getModifiedOnlyItems()[0]
866 workModified = (itm.text(self.__statusWorkColumn) in
867 self.modifiedOnlyIndicators)
868 stageModified = (itm.text(self.__statusIndexColumn) in
869 self.modifiedOnlyIndicators)
870 names = [os.path.join(self.dname, itm.text(self.__pathColumn))]
871
872 if workModified and stageModified:
873 # select from all three variants
874 messages = [
875 self.tr("Working Tree to Staging Area"),
876 self.tr("Staging Area to HEAD Commit"),
877 self.tr("Working Tree to HEAD Commit"),
878 ]
879 result, ok = QInputDialog.getItem(
880 None,
881 self.tr("Side-by-Side Difference"),
882 self.tr("Select the compare method."),
883 messages,
884 0, False)
885 if not ok:
886 return
887
888 if result == messages[0]:
889 revisions = ["", ""]
890 elif result == messages[1]:
891 revisions = ["HEAD", "Stage"]
892 else:
893 revisions = ["HEAD", ""]
894 elif workModified:
895 # select from work variants
896 messages = [
897 self.tr("Working Tree to Staging Area"),
898 self.tr("Working Tree to HEAD Commit"),
899 ]
900 result, ok = QInputDialog.getItem(
901 None,
902 self.tr("Side-by-Side Difference"),
903 self.tr("Select the compare method."),
904 messages,
905 0, False)
906 if not ok:
907 return
908
909 if result == messages[0]:
910 revisions = ["", ""]
911 else:
912 revisions = ["HEAD", ""]
913 else:
914 revisions = ["HEAD", "Stage"]
915
916 self.vcs.gitSbsDiff(names[0], revisions=revisions)
917
918 def __getCommitableItems(self):
919 """
920 Private method to retrieve all entries the user wants to commit.
921
922 @return list of all items, the user has checked
923 """
924 commitableItems = []
925 for index in range(self.statusList.topLevelItemCount()):
926 itm = self.statusList.topLevelItem(index)
927 if (
928 itm.checkState(self.__toBeCommittedColumn) ==
929 Qt.CheckState.Checked
930 ):
931 commitableItems.append(itm)
932 return commitableItems
933
934 def __getCommitableUnselectedItems(self):
935 """
936 Private method to retrieve all entries the user may commit but hasn't
937 selected.
938
939 @return list of all items, the user has not checked
940 """
941 items = []
942 for index in range(self.statusList.topLevelItemCount()):
943 itm = self.statusList.topLevelItem(index)
944 if (
945 itm.flags() & Qt.ItemFlag.ItemIsUserCheckable and
946 itm.checkState(self.__toBeCommittedColumn) ==
947 Qt.CheckState.Unchecked
948 ):
949 items.append(itm)
950 return items
951
952 def __getModifiedItems(self):
953 """
954 Private method to retrieve all entries, that have a modified status.
955
956 @return list of all items with a modified status
957 """
958 modifiedItems = []
959 for itm in self.statusList.selectedItems():
960 if (itm.text(self.__statusWorkColumn) in
961 self.modifiedIndicators or
962 itm.text(self.__statusIndexColumn) in
963 self.modifiedIndicators):
964 modifiedItems.append(itm)
965 return modifiedItems
966
967 def __getModifiedOnlyItems(self):
968 """
969 Private method to retrieve all entries, that have a modified status.
970
971 @return list of all items with a modified status
972 """
973 modifiedItems = []
974 for itm in self.statusList.selectedItems():
975 if (itm.text(self.__statusWorkColumn) in
976 self.modifiedOnlyIndicators or
977 itm.text(self.__statusIndexColumn) in
978 self.modifiedOnlyIndicators):
979 modifiedItems.append(itm)
980 return modifiedItems
981
982 def __getUnversionedItems(self):
983 """
984 Private method to retrieve all entries, that have an unversioned
985 status.
986
987 @return list of all items with an unversioned status
988 """
989 unversionedItems = []
990 for itm in self.statusList.selectedItems():
991 if itm.text(self.__statusWorkColumn) in self.unversionedIndicators:
992 unversionedItems.append(itm)
993 return unversionedItems
994
995 def __getStageableItems(self):
996 """
997 Private method to retrieve all entries, that have a stageable
998 status.
999
1000 @return list of all items with a stageable status
1001 """
1002 stageableItems = []
1003 for itm in self.statusList.selectedItems():
1004 if (
1005 itm.text(self.__statusWorkColumn) in
1006 self.modifiedIndicators + self.unmergedIndicators
1007 ):
1008 stageableItems.append(itm)
1009 return stageableItems
1010
1011 def __getUnstageableItems(self):
1012 """
1013 Private method to retrieve all entries, that have an unstageable
1014 status.
1015
1016 @return list of all items with an unstageable status
1017 """
1018 unstageableItems = []
1019 for itm in self.statusList.selectedItems():
1020 if itm.text(self.__statusIndexColumn) in self.modifiedIndicators:
1021 unstageableItems.append(itm)
1022 return unstageableItems
1023
1024 def __getMissingItems(self):
1025 """
1026 Private method to retrieve all entries, that have a missing status.
1027
1028 @return list of all items with a missing status
1029 """
1030 missingItems = []
1031 for itm in self.statusList.selectedItems():
1032 if itm.text(self.__statusWorkColumn) in self.missingIndicators:
1033 missingItems.append(itm)
1034 return missingItems
1035
1036 def __getConflictingItems(self):
1037 """
1038 Private method to retrieve all entries, that have a conflict status.
1039
1040 @return list of all items with a conflict status
1041 """
1042 conflictingItems = []
1043 for itm in self.statusList.selectedItems():
1044 if itm.data(0, self.ConflictRole):
1045 conflictingItems.append(itm)
1046 return conflictingItems
1047
1048 def __commitSelect(self, selected):
1049 """
1050 Private slot to select or deselect all entries.
1051
1052 @param selected commit selection state to be set (boolean)
1053 """
1054 for index in range(self.statusList.topLevelItemCount()):
1055 itm = self.statusList.topLevelItem(index)
1056 if itm.flags() & Qt.ItemFlag.ItemIsUserCheckable:
1057 if selected:
1058 itm.setCheckState(self.__toBeCommittedColumn,
1059 Qt.CheckState.Checked)
1060 else:
1061 itm.setCheckState(self.__toBeCommittedColumn,
1062 Qt.CheckState.Unchecked)
1063
1064 ###########################################################################
1065 ## Diff handling methods below
1066 ###########################################################################
1067
1068 def __generateDiffs(self):
1069 """
1070 Private slot to generate diff outputs for the selected item.
1071 """
1072 self.lDiffEdit.clear()
1073 self.rDiffEdit.clear()
1074 with contextlib.suppress(AttributeError):
1075 self.lDiffHighlighter.regenerateRules()
1076 self.rDiffHighlighter.regenerateRules()
1077
1078 selectedItems = self.statusList.selectedItems()
1079 if len(selectedItems) == 1:
1080 fn = os.path.join(self.dname,
1081 selectedItems[0].text(self.__pathColumn))
1082 self.__diffGenerator.start(fn, diffMode="work2stage2repo")
1083
1084 def __generatorFinished(self):
1085 """
1086 Private slot connected to the finished signal of the diff generator.
1087 """
1088 diff1, diff2 = self.__diffGenerator.getResult()[:2]
1089
1090 if diff1:
1091 self.lDiffParser = GitDiffParser(diff1)
1092 for line in diff1[:]:
1093 if line.startswith("@@ "):
1094 break
1095 else:
1096 diff1.pop(0)
1097 self.lDiffEdit.setPlainText("".join(diff1))
1098 else:
1099 self.lDiffParser = None
1100
1101 if diff2:
1102 self.rDiffParser = GitDiffParser(diff2)
1103 for line in diff2[:]:
1104 if line.startswith("@@ "):
1105 break
1106 else:
1107 diff2.pop(0)
1108 self.rDiffEdit.setPlainText("".join(diff2))
1109 else:
1110 self.rDiffParser = None
1111
1112 for diffEdit in [self.lDiffEdit, self.rDiffEdit]:
1113 tc = diffEdit.textCursor()
1114 tc.movePosition(QTextCursor.MoveOperation.Start)
1115 diffEdit.setTextCursor(tc)
1116 diffEdit.ensureCursorVisible()
1117
1118 def __showLDiffContextMenu(self, coord):
1119 """
1120 Private slot to show the context menu of the status list.
1121
1122 @param coord position of the mouse pointer (QPoint)
1123 """
1124 if bool(self.lDiffEdit.toPlainText()):
1125 cursor = self.lDiffEdit.textCursor()
1126 if cursor.hasSelection():
1127 self.__stageLinesAct.setEnabled(True)
1128 self.__revertLinesAct.setEnabled(True)
1129 self.__stageHunkAct.setEnabled(False)
1130 self.__revertHunkAct.setEnabled(False)
1131 else:
1132 self.__stageLinesAct.setEnabled(False)
1133 self.__revertLinesAct.setEnabled(False)
1134 self.__stageHunkAct.setEnabled(True)
1135 self.__revertHunkAct.setEnabled(True)
1136
1137 cursor = self.lDiffEdit.cursorForPosition(coord)
1138 self.lDiffEdit.setTextCursor(cursor)
1139
1140 self.__lDiffMenu.popup(self.lDiffEdit.mapToGlobal(coord))
1141
1142 def __showRDiffContextMenu(self, coord):
1143 """
1144 Private slot to show the context menu of the status list.
1145
1146 @param coord position of the mouse pointer (QPoint)
1147 """
1148 if bool(self.rDiffEdit.toPlainText()):
1149 cursor = self.rDiffEdit.textCursor()
1150 if cursor.hasSelection():
1151 self.__unstageLinesAct.setEnabled(True)
1152 self.__unstageHunkAct.setEnabled(False)
1153 else:
1154 self.__unstageLinesAct.setEnabled(False)
1155 self.__unstageHunkAct.setEnabled(True)
1156
1157 cursor = self.rDiffEdit.cursorForPosition(coord)
1158 self.rDiffEdit.setTextCursor(cursor)
1159
1160 self.__rDiffMenu.popup(self.rDiffEdit.mapToGlobal(coord))
1161
1162 def __stageHunkOrLines(self):
1163 """
1164 Private method to stage the selected lines or hunk.
1165 """
1166 cursor = self.lDiffEdit.textCursor()
1167 startIndex, endIndex = self.__selectedLinesIndexes(self.lDiffEdit)
1168 patch = (
1169 self.lDiffParser.createLinesPatch(startIndex, endIndex)
1170 if cursor.hasSelection() else
1171 self.lDiffParser.createHunkPatch(startIndex)
1172 )
1173 if patch:
1174 patchFile = self.__tmpPatchFileName()
1175 try:
1176 with open(patchFile, "w") as f:
1177 f.write(patch)
1178 self.vcs.gitApply(self.dname, patchFile, cached=True,
1179 noDialog=True)
1180 self.on_refreshButton_clicked()
1181 finally:
1182 os.remove(patchFile)
1183
1184 def __unstageHunkOrLines(self):
1185 """
1186 Private method to unstage the selected lines or hunk.
1187 """
1188 cursor = self.rDiffEdit.textCursor()
1189 startIndex, endIndex = self.__selectedLinesIndexes(self.rDiffEdit)
1190 patch = (
1191 self.rDiffParser.createLinesPatch(startIndex, endIndex,
1192 reverse=True)
1193 if cursor.hasSelection() else
1194 self.rDiffParser.createHunkPatch(startIndex)
1195 )
1196 if patch:
1197 patchFile = self.__tmpPatchFileName()
1198 try:
1199 with open(patchFile, "w") as f:
1200 f.write(patch)
1201 self.vcs.gitApply(self.dname, patchFile, cached=True,
1202 reverse=True, noDialog=True)
1203 self.on_refreshButton_clicked()
1204 finally:
1205 os.remove(patchFile)
1206
1207 def __revertHunkOrLines(self):
1208 """
1209 Private method to revert the selected lines or hunk.
1210 """
1211 cursor = self.lDiffEdit.textCursor()
1212 startIndex, endIndex = self.__selectedLinesIndexes(self.lDiffEdit)
1213 title = (
1214 self.tr("Revert selected lines")
1215 if cursor.hasSelection() else
1216 self.tr("Revert hunk")
1217 )
1218 res = E5MessageBox.yesNo(
1219 self,
1220 title,
1221 self.tr("""Are you sure you want to revert the selected"""
1222 """ changes?"""))
1223 if res:
1224 if cursor.hasSelection():
1225 patch = self.lDiffParser.createLinesPatch(startIndex, endIndex,
1226 reverse=True)
1227 else:
1228 patch = self.lDiffParser.createHunkPatch(startIndex)
1229 if patch:
1230 patchFile = self.__tmpPatchFileName()
1231 try:
1232 with open(patchFile, "w") as f:
1233 f.write(patch)
1234 self.vcs.gitApply(self.dname, patchFile, reverse=True,
1235 noDialog=True)
1236 self.on_refreshButton_clicked()
1237 finally:
1238 os.remove(patchFile)
1239
1240 def __selectedLinesIndexes(self, diffEdit):
1241 """
1242 Private method to extract the indexes of the selected lines.
1243
1244 @param diffEdit reference to the edit widget (QTextEdit)
1245 @return tuple of start and end indexes (integer, integer)
1246 """
1247 cursor = diffEdit.textCursor()
1248 selectionStart = cursor.selectionStart()
1249 selectionEnd = cursor.selectionEnd()
1250
1251 startIndex = -1
1252
1253 lineStart = 0
1254 for lineIdx, line in enumerate(diffEdit.toPlainText().splitlines()):
1255 lineEnd = lineStart + len(line)
1256 if lineStart <= selectionStart <= lineEnd:
1257 startIndex = lineIdx
1258 if lineStart <= selectionEnd <= lineEnd:
1259 endIndex = lineIdx
1260 break
1261 lineStart = lineEnd + 1
1262
1263 return startIndex, endIndex
1264
1265 def __tmpPatchFileName(self):
1266 """
1267 Private method to generate a temporary patch file.
1268
1269 @return name of the temporary file (string)
1270 """
1271 prefix = 'eric-git-{0}-'.format(os.getpid())
1272 suffix = '-patch'
1273 fd, path = tempfile.mkstemp(suffix, prefix)
1274 os.close(fd)
1275 return path
1276
1277 def __refreshDiff(self):
1278 """
1279 Private method to refresh the diff output after a refresh.
1280 """
1281 if self.__selectedName:
1282 for index in range(self.statusList.topLevelItemCount()):
1283 itm = self.statusList.topLevelItem(index)
1284 if itm.text(self.__pathColumn) == self.__selectedName:
1285 itm.setSelected(True)
1286 break
1287
1288 self.__selectedName = ""

eric ide

mercurial