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

branch
eric7
changeset 9209
b99e7fd55fd3
parent 8881
54e42bc2437a
child 9221
bf71ee032bb4
equal deleted inserted replaced
9208:3fc8dfeb6ebe 9209:b99e7fd55fd3
1 # -*- coding: utf-8 -*-
2
3 # Copyright (c) 2014 - 2022 Detlev Offenbach <detlev@die-offenbachs.de>
4 #
5
6 """
7 Module implementing a dialog to show the output of the git status command
8 process.
9 """
10
11 import os
12 import tempfile
13 import contextlib
14
15 from PyQt6.QtCore import pyqtSlot, Qt, QProcess, QTimer, QSize
16 from PyQt6.QtGui import QTextCursor
17 from PyQt6.QtWidgets import (
18 QWidget, QDialogButtonBox, QMenu, QHeaderView, QTreeWidgetItem, QLineEdit,
19 QInputDialog
20 )
21
22 from EricWidgets.EricApplication import ericApp
23 from EricWidgets import EricMessageBox
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 Conflict"), 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 EricMessageBox.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 EricMessageBox.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 = ericApp().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 EricMessageBox.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 = ericApp().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 EricMessageBox.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 = ericApp().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 EricMessageBox.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 = ericApp().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 EricMessageBox.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 EricMessageBox.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.vcsRevert(names)
800 self.raise_()
801 self.activateWindow()
802 self.on_refreshButton_clicked()
803
804 project = ericApp().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 EricMessageBox.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.vcsRevert(names)
824 self.on_refreshButton_clicked()
825 self.vcs.checkVCSStatus()
826
827 def __editConflict(self):
828 """
829 Private slot to handle the Edit Conflict 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 ericApp().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 EricMessageBox.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 Side-By-Side 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("Differences Side-by-Side"),
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("Differences Side-by-Side"),
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.vcsSbsDiff(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 ==
946 Qt.ItemFlag.ItemIsUserCheckable) and
947 itm.checkState(self.__toBeCommittedColumn) ==
948 Qt.CheckState.Unchecked
949 ):
950 items.append(itm)
951 return items
952
953 def __getModifiedItems(self):
954 """
955 Private method to retrieve all entries, that have a modified status.
956
957 @return list of all items with a modified status
958 """
959 modifiedItems = []
960 for itm in self.statusList.selectedItems():
961 if (itm.text(self.__statusWorkColumn) in
962 self.modifiedIndicators or
963 itm.text(self.__statusIndexColumn) in
964 self.modifiedIndicators):
965 modifiedItems.append(itm)
966 return modifiedItems
967
968 def __getModifiedOnlyItems(self):
969 """
970 Private method to retrieve all entries, that have a modified status.
971
972 @return list of all items with a modified status
973 """
974 modifiedItems = []
975 for itm in self.statusList.selectedItems():
976 if (itm.text(self.__statusWorkColumn) in
977 self.modifiedOnlyIndicators or
978 itm.text(self.__statusIndexColumn) in
979 self.modifiedOnlyIndicators):
980 modifiedItems.append(itm)
981 return modifiedItems
982
983 def __getUnversionedItems(self):
984 """
985 Private method to retrieve all entries, that have an unversioned
986 status.
987
988 @return list of all items with an unversioned status
989 """
990 unversionedItems = []
991 for itm in self.statusList.selectedItems():
992 if itm.text(self.__statusWorkColumn) in self.unversionedIndicators:
993 unversionedItems.append(itm)
994 return unversionedItems
995
996 def __getStageableItems(self):
997 """
998 Private method to retrieve all entries, that have a stageable
999 status.
1000
1001 @return list of all items with a stageable status
1002 """
1003 stageableItems = []
1004 for itm in self.statusList.selectedItems():
1005 if (
1006 itm.text(self.__statusWorkColumn) in
1007 self.modifiedIndicators + self.unmergedIndicators
1008 ):
1009 stageableItems.append(itm)
1010 return stageableItems
1011
1012 def __getUnstageableItems(self):
1013 """
1014 Private method to retrieve all entries, that have an unstageable
1015 status.
1016
1017 @return list of all items with an unstageable status
1018 """
1019 unstageableItems = []
1020 for itm in self.statusList.selectedItems():
1021 if itm.text(self.__statusIndexColumn) in self.modifiedIndicators:
1022 unstageableItems.append(itm)
1023 return unstageableItems
1024
1025 def __getMissingItems(self):
1026 """
1027 Private method to retrieve all entries, that have a missing status.
1028
1029 @return list of all items with a missing status
1030 """
1031 missingItems = []
1032 for itm in self.statusList.selectedItems():
1033 if itm.text(self.__statusWorkColumn) in self.missingIndicators:
1034 missingItems.append(itm)
1035 return missingItems
1036
1037 def __getConflictingItems(self):
1038 """
1039 Private method to retrieve all entries, that have a conflict status.
1040
1041 @return list of all items with a conflict status
1042 """
1043 conflictingItems = []
1044 for itm in self.statusList.selectedItems():
1045 if itm.data(0, self.ConflictRole):
1046 conflictingItems.append(itm)
1047 return conflictingItems
1048
1049 def __commitSelect(self, selected):
1050 """
1051 Private slot to select or deselect all entries.
1052
1053 @param selected commit selection state to be set (boolean)
1054 """
1055 for index in range(self.statusList.topLevelItemCount()):
1056 itm = self.statusList.topLevelItem(index)
1057 if (
1058 itm.flags() & Qt.ItemFlag.ItemIsUserCheckable ==
1059 Qt.ItemFlag.ItemIsUserCheckable
1060 ):
1061 if selected:
1062 itm.setCheckState(self.__toBeCommittedColumn,
1063 Qt.CheckState.Checked)
1064 else:
1065 itm.setCheckState(self.__toBeCommittedColumn,
1066 Qt.CheckState.Unchecked)
1067
1068 ###########################################################################
1069 ## Diff handling methods below
1070 ###########################################################################
1071
1072 def __generateDiffs(self):
1073 """
1074 Private slot to generate diff outputs for the selected item.
1075 """
1076 self.lDiffEdit.clear()
1077 self.rDiffEdit.clear()
1078 with contextlib.suppress(AttributeError):
1079 self.lDiffHighlighter.regenerateRules()
1080 self.rDiffHighlighter.regenerateRules()
1081
1082 selectedItems = self.statusList.selectedItems()
1083 if len(selectedItems) == 1:
1084 fn = os.path.join(self.dname,
1085 selectedItems[0].text(self.__pathColumn))
1086 self.__diffGenerator.start(fn, diffMode="work2stage2repo")
1087
1088 def __generatorFinished(self):
1089 """
1090 Private slot connected to the finished signal of the diff generator.
1091 """
1092 diff1, diff2 = self.__diffGenerator.getResult()[:2]
1093
1094 if diff1:
1095 self.lDiffParser = GitDiffParser(diff1)
1096 for line in diff1[:]:
1097 if line.startswith("@@ "):
1098 break
1099 else:
1100 diff1.pop(0)
1101 self.lDiffEdit.setPlainText("".join(diff1))
1102 else:
1103 self.lDiffParser = None
1104
1105 if diff2:
1106 self.rDiffParser = GitDiffParser(diff2)
1107 for line in diff2[:]:
1108 if line.startswith("@@ "):
1109 break
1110 else:
1111 diff2.pop(0)
1112 self.rDiffEdit.setPlainText("".join(diff2))
1113 else:
1114 self.rDiffParser = None
1115
1116 for diffEdit in [self.lDiffEdit, self.rDiffEdit]:
1117 tc = diffEdit.textCursor()
1118 tc.movePosition(QTextCursor.MoveOperation.Start)
1119 diffEdit.setTextCursor(tc)
1120 diffEdit.ensureCursorVisible()
1121
1122 def __showLDiffContextMenu(self, coord):
1123 """
1124 Private slot to show the context menu of the status list.
1125
1126 @param coord position of the mouse pointer (QPoint)
1127 """
1128 if bool(self.lDiffEdit.toPlainText()):
1129 cursor = self.lDiffEdit.textCursor()
1130 if cursor.hasSelection():
1131 self.__stageLinesAct.setEnabled(True)
1132 self.__revertLinesAct.setEnabled(True)
1133 self.__stageHunkAct.setEnabled(False)
1134 self.__revertHunkAct.setEnabled(False)
1135 else:
1136 self.__stageLinesAct.setEnabled(False)
1137 self.__revertLinesAct.setEnabled(False)
1138 self.__stageHunkAct.setEnabled(True)
1139 self.__revertHunkAct.setEnabled(True)
1140
1141 cursor = self.lDiffEdit.cursorForPosition(coord)
1142 self.lDiffEdit.setTextCursor(cursor)
1143
1144 self.__lDiffMenu.popup(self.lDiffEdit.mapToGlobal(coord))
1145
1146 def __showRDiffContextMenu(self, coord):
1147 """
1148 Private slot to show the context menu of the status list.
1149
1150 @param coord position of the mouse pointer (QPoint)
1151 """
1152 if bool(self.rDiffEdit.toPlainText()):
1153 cursor = self.rDiffEdit.textCursor()
1154 if cursor.hasSelection():
1155 self.__unstageLinesAct.setEnabled(True)
1156 self.__unstageHunkAct.setEnabled(False)
1157 else:
1158 self.__unstageLinesAct.setEnabled(False)
1159 self.__unstageHunkAct.setEnabled(True)
1160
1161 cursor = self.rDiffEdit.cursorForPosition(coord)
1162 self.rDiffEdit.setTextCursor(cursor)
1163
1164 self.__rDiffMenu.popup(self.rDiffEdit.mapToGlobal(coord))
1165
1166 def __stageHunkOrLines(self):
1167 """
1168 Private method to stage the selected lines or hunk.
1169 """
1170 cursor = self.lDiffEdit.textCursor()
1171 startIndex, endIndex = self.__selectedLinesIndexes(self.lDiffEdit)
1172 patch = (
1173 self.lDiffParser.createLinesPatch(startIndex, endIndex)
1174 if cursor.hasSelection() else
1175 self.lDiffParser.createHunkPatch(startIndex)
1176 )
1177 if patch:
1178 patchFile = self.__tmpPatchFileName()
1179 try:
1180 with open(patchFile, "w") as f:
1181 f.write(patch)
1182 self.vcs.gitApply(self.dname, patchFile, cached=True,
1183 noDialog=True)
1184 self.on_refreshButton_clicked()
1185 finally:
1186 os.remove(patchFile)
1187
1188 def __unstageHunkOrLines(self):
1189 """
1190 Private method to unstage the selected lines or hunk.
1191 """
1192 cursor = self.rDiffEdit.textCursor()
1193 startIndex, endIndex = self.__selectedLinesIndexes(self.rDiffEdit)
1194 patch = (
1195 self.rDiffParser.createLinesPatch(startIndex, endIndex,
1196 reverse=True)
1197 if cursor.hasSelection() else
1198 self.rDiffParser.createHunkPatch(startIndex)
1199 )
1200 if patch:
1201 patchFile = self.__tmpPatchFileName()
1202 try:
1203 with open(patchFile, "w") as f:
1204 f.write(patch)
1205 self.vcs.gitApply(self.dname, patchFile, cached=True,
1206 reverse=True, noDialog=True)
1207 self.on_refreshButton_clicked()
1208 finally:
1209 os.remove(patchFile)
1210
1211 def __revertHunkOrLines(self):
1212 """
1213 Private method to revert the selected lines or hunk.
1214 """
1215 cursor = self.lDiffEdit.textCursor()
1216 startIndex, endIndex = self.__selectedLinesIndexes(self.lDiffEdit)
1217 title = (
1218 self.tr("Revert selected lines")
1219 if cursor.hasSelection() else
1220 self.tr("Revert hunk")
1221 )
1222 res = EricMessageBox.yesNo(
1223 self,
1224 title,
1225 self.tr("""Are you sure you want to revert the selected"""
1226 """ changes?"""))
1227 if res:
1228 if cursor.hasSelection():
1229 patch = self.lDiffParser.createLinesPatch(startIndex, endIndex,
1230 reverse=True)
1231 else:
1232 patch = self.lDiffParser.createHunkPatch(startIndex)
1233 if patch:
1234 patchFile = self.__tmpPatchFileName()
1235 try:
1236 with open(patchFile, "w") as f:
1237 f.write(patch)
1238 self.vcs.gitApply(self.dname, patchFile, reverse=True,
1239 noDialog=True)
1240 self.on_refreshButton_clicked()
1241 finally:
1242 os.remove(patchFile)
1243
1244 def __selectedLinesIndexes(self, diffEdit):
1245 """
1246 Private method to extract the indexes of the selected lines.
1247
1248 @param diffEdit reference to the edit widget (QTextEdit)
1249 @return tuple of start and end indexes (integer, integer)
1250 """
1251 cursor = diffEdit.textCursor()
1252 selectionStart = cursor.selectionStart()
1253 selectionEnd = cursor.selectionEnd()
1254
1255 startIndex = -1
1256
1257 lineStart = 0
1258 for lineIdx, line in enumerate(diffEdit.toPlainText().splitlines()):
1259 lineEnd = lineStart + len(line)
1260 if lineStart <= selectionStart <= lineEnd:
1261 startIndex = lineIdx
1262 if lineStart <= selectionEnd <= lineEnd:
1263 endIndex = lineIdx
1264 break
1265 lineStart = lineEnd + 1
1266
1267 return startIndex, endIndex
1268
1269 def __tmpPatchFileName(self):
1270 """
1271 Private method to generate a temporary patch file.
1272
1273 @return name of the temporary file (string)
1274 """
1275 prefix = 'eric-git-{0}-'.format(os.getpid())
1276 suffix = '-patch'
1277 fd, path = tempfile.mkstemp(suffix, prefix)
1278 os.close(fd)
1279 return path
1280
1281 def __refreshDiff(self):
1282 """
1283 Private method to refresh the diff output after a refresh.
1284 """
1285 if self.__selectedName:
1286 for index in range(self.statusList.topLevelItemCount()):
1287 itm = self.statusList.topLevelItem(index)
1288 if itm.text(self.__pathColumn) == self.__selectedName:
1289 itm.setSelected(True)
1290 break
1291
1292 self.__selectedName = ""

eric ide

mercurial