1# -*- coding: utf-8 -*-
2
3# Copyright (c) 2014 - 2021 Detlev Offenbach <detlev@die-offenbachs.de>
4#
5
6"""
7Module implementing a dialog to show the output of the git status command
8process.
9"""
10
11import os
12import tempfile
13import contextlib
14
15from PyQt5.QtCore import pyqtSlot, Qt, QProcess, QTimer, QSize
16from PyQt5.QtGui import QTextCursor
17from PyQt5.QtWidgets import (
18    QWidget, QDialogButtonBox, QMenu, QHeaderView, QTreeWidgetItem, QLineEdit,
19    QInputDialog
20)
21
22from E5Gui.E5Application import e5App
23from E5Gui import E5MessageBox
24
25from Globals import strToQByteArray
26
27from .Ui_GitStatusDialog import Ui_GitStatusDialog
28
29from .GitDiffHighlighter import GitDiffHighlighter
30from .GitDiffGenerator import GitDiffGenerator
31from .GitDiffParser import GitDiffParser
32
33import Preferences
34import UI.PixmapCache
35import Utilities
36
37
38class 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 = ""
1289