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