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

Wed, 04 Oct 2023 17:50:59 +0200

author
Detlev Offenbach <detlev@die-offenbachs.de>
date
Wed, 04 Oct 2023 17:50:59 +0200
branch
eric7
changeset 10217
7888177e7463
parent 10069
435cc5875135
child 10438
4cd7e5a8b3cf
permissions
-rw-r--r--

Fixed in issue with several editable combo box selectors, that the value could not be changed if the edited text differed by case only. This was caused by the standard QComboBox completer.

# -*- coding: utf-8 -*-

# Copyright (c) 2022 - 2023 Detlev Offenbach <detlev@die-offenbachs.de>
#

"""
Module implementing a dialog to offer the worktree management functionality.
"""

import os

from PyQt6.QtCore import QDateTime, QProcess, QSize, Qt, QTime, QTimer, pyqtSlot
from PyQt6.QtWidgets import (
    QAbstractButton,
    QDialog,
    QDialogButtonBox,
    QHeaderView,
    QInputDialog,
    QLineEdit,
    QMenu,
    QTreeWidgetItem,
    QWidget,
)

from eric7 import Preferences
from eric7.EricGui import EricPixmapCache
from eric7.EricWidgets import EricMessageBox, EricPathPickerDialog

from .GitDialog import GitDialog
from .Ui_GitWorktreeDialog import Ui_GitWorktreeDialog


class GitWorktreeDialog(QWidget, Ui_GitWorktreeDialog):
    """
    Class implementing a dialog to offer the worktree management functionality.
    """

    StatusRole = Qt.ItemDataRole.UserRole

    def __init__(self, vcs, parent=None):
        """
        Constructor

        @param vcs reference to the vcs object
        @type Git
        @param parent reference to the parent widget (defaults to None)
        @type QWidget (optional)
        """
        super().__init__(parent)
        self.setupUi(self)

        self.__nameColumn = 0
        self.__pathColumn = 1
        self.__commitColumn = 2
        self.__branchColumn = 3

        self.worktreeList.header().setSortIndicator(0, Qt.SortOrder.AscendingOrder)

        self.__refreshButton = self.buttonBox.addButton(
            self.tr("Refresh"), QDialogButtonBox.ButtonRole.ActionRole
        )
        self.__refreshButton.setToolTip(self.tr("Press to refresh the status display"))
        self.__refreshButton.setEnabled(False)
        self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setEnabled(False)
        self.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).setDefault(True)

        self.__vcs = vcs
        self.__process = QProcess()
        self.__process.finished.connect(self.__procFinished)
        self.__process.readyReadStandardOutput.connect(self.__readStdout)
        self.__process.readyReadStandardError.connect(self.__readStderr)

        self.__initActionsMenu()

    def __initActionsMenu(self):
        """
        Private method to initialize the actions menu.
        """
        self.__actionsMenu = QMenu()
        self.__actionsMenu.setTearOffEnabled(True)
        self.__actionsMenu.setToolTipsVisible(True)
        self.__actionsMenu.aboutToShow.connect(self.__showActionsMenu)

        self.__addAct = self.__actionsMenu.addAction(
            self.tr("Add..."), self.__worktreeAdd
        )
        self.__addAct.setToolTip(self.tr("Add a new linked worktree"))
        self.__actionsMenu.addSeparator()
        self.__lockAct = self.__actionsMenu.addAction(
            self.tr("Lock..."), self.__worktreeLock
        )
        self.__lockAct.setToolTip(self.tr("Lock the selected worktree"))
        self.__unlockAct = self.__actionsMenu.addAction(
            self.tr("Unlock"), self.__worktreeUnlock
        )
        self.__unlockAct.setToolTip(self.tr("Unlock the selected worktree"))
        self.__actionsMenu.addSeparator()
        self.__moveAct = self.__actionsMenu.addAction(
            self.tr("Move..."), self.__worktreeMove
        )
        self.__moveAct.setToolTip(
            self.tr("Move the selected worktree to a new location")
        )
        self.__actionsMenu.addSeparator()
        self.__removeAct = self.__actionsMenu.addAction(
            self.tr("Remove"), self.__worktreeRemove
        )
        self.__removeAct.setToolTip(self.tr("Remove the selected worktree"))
        self.__removeForcedAct = self.__actionsMenu.addAction(
            self.tr("Forced Remove"), self.__worktreeRemoveForced
        )
        self.__removeForcedAct.setToolTip(
            self.tr("Remove the selected worktree forcefully")
        )
        self.__actionsMenu.addSeparator()
        self.__pruneAct = self.__actionsMenu.addAction(
            self.tr("Prune..."), self.__worktreePrune
        )
        self.__pruneAct.setToolTip(self.tr("Prune worktree information"))
        self.__actionsMenu.addSeparator()
        self.__repairAct = self.__actionsMenu.addAction(
            self.tr("Repair"), self.__worktreeRepair
        )
        self.__repairAct.setToolTip(self.tr("Repair worktree administrative files"))
        self.__repairMultipleAct = self.__actionsMenu.addAction(
            self.tr("Repair Multiple"), self.__worktreeRepairMultiple
        )
        self.__repairMultipleAct.setToolTip(
            self.tr("Repair administrative files of multiple worktrees")
        )

        self.actionsButton.setIcon(EricPixmapCache.getIcon("actionsToolButton"))
        self.actionsButton.setMenu(self.__actionsMenu)

    def closeEvent(self, e):
        """
        Protected slot implementing a close event handler.

        @param e close event (QCloseEvent)
        """
        if (
            self.__process is not None
            and self.__process.state() != QProcess.ProcessState.NotRunning
        ):
            self.__process.terminate()
            QTimer.singleShot(2000, self.__process.kill)
            self.__process.waitForFinished(3000)

        self.__vcs.getPlugin().setPreferences(
            "WorktreeDialogGeometry", self.saveGeometry()
        )

        e.accept()

    def show(self):
        """
        Public slot to show the dialog.
        """
        super().show()

        geom = self.__vcs.getPlugin().getPreferences("WorktreeDialogGeometry")
        if geom.isEmpty():
            s = QSize(900, 600)
            self.resize(s)
        else:
            self.restoreGeometry(geom)

    def __resort(self):
        """
        Private method to resort the tree.
        """
        self.worktreeList.sortItems(
            self.worktreeList.sortColumn(),
            self.worktreeList.header().sortIndicatorOrder(),
        )

    def __resizeColumns(self):
        """
        Private method to resize the list columns.
        """
        self.worktreeList.header().resizeSections(
            QHeaderView.ResizeMode.ResizeToContents
        )
        self.worktreeList.header().setStretchLastSection(True)

    def __generateItem(self, dataLines):
        """
        Private method to generate a worktree entry with the given data.

        @param dataLines lines extracted from the git worktree list output
            with porcelain format
        @type list of str
        """
        checkoutPath = worktreeName = commit = branch = status = ""
        iconName = tooltip = ""
        for line in dataLines:
            if " " in line:
                option, value = line.split(None, 1)
            else:
                option, value = line, ""

            if option == "worktree":
                checkoutPath = value
                worktreeName = os.path.basename(value)
            elif option == "HEAD":
                commit = value[: self.__commitIdLength]
            elif option == "branch":
                branch = value.rsplit("/", 1)[-1]
            elif option == "bare":
                branch = self.tr("(bare)")
            elif option == "detached":
                branch = self.tr("(detached HEAD)")
            elif option == "prunable":
                iconName = "trash"
                tooltip = value
                status = option
            elif option == "locked":
                iconName = "locked"
                tooltip = value
                status = option

        itm = QTreeWidgetItem(
            self.worktreeList, [worktreeName, checkoutPath, commit, branch]
        )
        if iconName:
            itm.setIcon(0, EricPixmapCache.getIcon(iconName))
            if tooltip:
                itm.setToolTip(0, tooltip)

        if self.worktreeList.topLevelItemCount() == 1:
            # the first item is the main worktree
            status = "main"
            font = itm.font(0)
            font.setBold(True)
            if checkoutPath == self.__projectDir:
                # it is the current project as well
                status = "main+current"
                font.setItalic(True)
            for col in range(self.worktreeList.columnCount()):
                itm.setFont(col, font)
        elif checkoutPath == self.__projectDir:
            # it is the current project
            if not status:
                status = "current"
            elif status == "locked":
                status = "locked+current"
            font = itm.font(0)
            font.setItalic(True)
            for col in range(self.worktreeList.columnCount()):
                itm.setFont(col, font)
        itm.setData(0, GitWorktreeDialog.StatusRole, status)

    def start(self, projectDir):
        """
        Public slot to start the git worktree list command.

        @param projectDir name of the project directory
        @type str
        """
        self.errorGroup.hide()
        self.worktreeList.clear()

        self.__ioEncoding = Preferences.getSystem("IOEncoding")

        args = self.__vcs.initCommand("worktree")
        args += ["list", "--porcelain"]
        if self.expireCheckBox.isChecked():
            args += [
                "--expire",
                self.expireDateTimeEdit.dateTime().toString(Qt.DateFormat.ISODate),
            ]

        self.__projectDir = projectDir

        # find the root of the repo
        self.__repodir = self.__vcs.findRepoRoot(projectDir)
        if not self.__repodir:
            return

        self.__outputLines = []
        self.__commitIdLength = self.__vcs.getPlugin().getPreferences("CommitIdLength")

        self.__process.kill()
        self.__process.setWorkingDirectory(self.__repodir)

        self.__process.start("git", args)
        procStarted = self.__process.waitForStarted(5000)
        if not procStarted:
            EricMessageBox.critical(
                self,
                self.tr("Process Generation Error"),
                self.tr(
                    "The process {0} could not be started. "
                    "Ensure, that it is in the search path."
                ).format("git"),
            )
        else:
            self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setEnabled(
                False
            )
            self.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).setEnabled(
                True
            )
            self.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).setDefault(
                True
            )

            self.__refreshButton.setEnabled(False)

    def __finish(self):
        """
        Private slot called when the process finished or the user pressed
        the button.
        """
        if (
            self.__process is not None
            and self.__process.state() != QProcess.ProcessState.NotRunning
        ):
            self.__process.terminate()
            QTimer.singleShot(2000, self.__process.kill)
            self.__process.waitForFinished(3000)

        self.__refreshButton.setEnabled(True)

        self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setEnabled(True)
        self.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).setEnabled(False)
        self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setDefault(True)
        self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setFocus(
            Qt.FocusReason.OtherFocusReason
        )

        self.__resort()
        self.__resizeColumns()

    @pyqtSlot(QAbstractButton)
    def on_buttonBox_clicked(self, button):
        """
        Private slot called by a button of the button box clicked.

        @param button button that was clicked
        @type QAbstractButton
        """
        if button == self.buttonBox.button(QDialogButtonBox.StandardButton.Close):
            self.close()
        elif button == self.buttonBox.button(QDialogButtonBox.StandardButton.Cancel):
            self.__finish()
        elif button == self.__refreshButton:
            self.__refreshButtonClicked()

    @pyqtSlot()
    def __refreshButtonClicked(self):
        """
        Private slot to refresh the worktree display.
        """
        self.start(self.__projectDir)

    @pyqtSlot(int, QProcess.ExitStatus)
    def __procFinished(self, exitCode, exitStatus):
        """
        Private slot connected to the finished signal.

        @param exitCode exit code of the process (integer)
        @param exitStatus exit status of the process (QProcess.ExitStatus)
        """
        self.__finish()

    def __readStdout(self):
        """
        Private slot to handle the readyReadStandardOutput signal.

        It reads the output of the process, formats it and inserts it into
        the contents pane.
        """
        if self.__process is not None:
            self.__process.setReadChannel(QProcess.ProcessChannel.StandardOutput)

            while self.__process.canReadLine():
                line = str(
                    self.__process.readLine(), self.__ioEncoding, "replace"
                ).strip()
                if line:
                    self.__outputLines.append(line)
                else:
                    self.__generateItem(self.__outputLines)
                    self.__outputLines = []

    def __readStderr(self):
        """
        Private slot to handle the readyReadStandardError signal.

        It reads the error output of the process and inserts it into the
        error pane.
        """
        if self.__process is not None:
            s = str(self.__process.readAllStandardError(), self.__ioEncoding, "replace")
            self.errorGroup.show()
            self.errors.insertPlainText(s)
            self.errors.ensureCursorVisible()

    @pyqtSlot(bool)
    def on_expireCheckBox_toggled(self, checked):
        """
        Private slot to handle a change of the expire checkbox.

        @param checked state of the checkbox
        @type bool
        """
        if checked:
            now = QDateTime.currentDateTime()
            self.expireDateTimeEdit.setMaximumDateTime(now)
            self.expireDateTimeEdit.setMinimumDate(now.date().addDays(-2 * 365))
            self.expireDateTimeEdit.setMinimumTime(QTime(0, 0, 0))
            self.expireDateTimeEdit.setDateTime(now)
        else:
            self.__refreshButtonClicked()

    @pyqtSlot(QDateTime)
    def on_expireDateTimeEdit_dateTimeChanged(self, dateTime):
        """
        Private slot to handle a change of the expire date and time.

        @param dateTime DESCRIPTION
        @type QDateTime
        """
        self.__refreshButtonClicked()

    ###########################################################################
    ## Menu handling methods
    ###########################################################################

    def __showActionsMenu(self):
        """
        Private slot to prepare the actions button menu before it is shown.
        """
        prunableWorktrees = []
        for row in range(self.worktreeList.topLevelItemCount()):
            itm = self.worktreeList.topLevelItem(row)
            status = itm.data(0, GitWorktreeDialog.StatusRole)
            if status == "prunable":
                prunableWorktrees.append(itm.text(self.__pathColumn))

        selectedItems = self.worktreeList.selectedItems()
        enable = bool(selectedItems)
        status = (
            selectedItems[0].data(0, GitWorktreeDialog.StatusRole)
            if selectedItems
            else ""
        )

        self.__lockAct.setEnabled(
            enable
            and status not in ("locked", "locked+current", "main", "main+current")
        )
        self.__unlockAct.setEnabled(enable and status in ("locked", "locked+current"))
        self.__moveAct.setEnabled(
            enable
            and status
            not in (
                "prunable",
                "locked",
                "main",
                "main+current",
                "current",
                "locked+current",
            )
        )
        self.__removeAct.setEnabled(
            enable
            and status
            not in ("locked", "main", "main+current", "current", "locked+current")
        )
        self.__removeForcedAct.setEnabled(
            enable
            and status not in ("main", "main+current", "current", "locked+current")
        )
        self.__pruneAct.setEnabled(bool(prunableWorktrees))

    @pyqtSlot()
    def __worktreeAdd(self):
        """
        Private slot to add a linked worktree.
        """
        from .GitWorktreeAddDialog import GitWorktreeAddDialog

        # find current worktree and take its parent path as the parent directory
        for row in range(self.worktreeList.topLevelItemCount()):
            itm = self.worktreeList.topLevelItem(row)
            if "current" in itm.data(0, GitWorktreeDialog.StatusRole):
                parentDirectory = os.path.dirname(itm.text(self.__pathColumn))
                break
        else:
            parentDirectory = ""

        dlg = GitWorktreeAddDialog(
            parentDirectory,
            self.__vcs.gitGetTagsList(self.__repodir),
            self.__vcs.gitGetBranchesList(self.__repodir),
        )
        if dlg.exec() == QDialog.DialogCode.Accepted:
            params = dlg.getParameters()
            args = ["worktree", "add"]
            if params["force"]:
                args.append("--force")
            if params["detach"]:
                args.append("--detach")
            if params["lock"]:
                args.append("--lock")
                if params["lock_reason"]:
                    args += ["--reason", params["lock_reason"]]
            if params["branch"]:
                args += ["-B" if params["force_branch"] else "-b", params["branch"]]
            args.append(params["path"])
            if params["commit"]:
                args.append(params["commit"])

        dlg = GitDialog(self.tr("Add Worktree"), self.__vcs)
        started = dlg.startProcess(args, workingDir=self.__repodir)
        if started:
            dlg.exec()

            self.__refreshButtonClicked()

    @pyqtSlot()
    def __worktreeLock(self):
        """
        Private slot to lock a worktree.
        """
        worktree = self.worktreeList.selectedItems()[0].text(self.__pathColumn)
        if not worktree:
            return

        reason, ok = QInputDialog.getText(
            self,
            self.tr("Lock Worktree"),
            self.tr("Enter a reason for the lock:"),
            QLineEdit.EchoMode.Normal,
        )
        if not ok:
            return

        args = ["worktree", "lock"]
        if reason:
            args += ["--reason", reason]
        args.append(worktree)

        proc = QProcess()
        ok = self.__vcs.startSynchronizedProcess(proc, "git", args, self.__repodir)
        if not ok:
            err = str(proc.readAllStandardError(), self.__ioEncoding, "replace")
            EricMessageBox.critical(
                self,
                self.tr("Lock Worktree"),
                self.tr(
                    "<p>Locking the selected worktree failed.</p><p>{0}</p>"
                ).format(err),
            )

        self.__refreshButtonClicked()

    @pyqtSlot()
    def __worktreeUnlock(self):
        """
        Private slot to unlock a worktree.
        """
        worktree = self.worktreeList.selectedItems()[0].text(self.__pathColumn)
        if not worktree:
            return

        args = ["worktree", "unlock", worktree]

        proc = QProcess()
        ok = self.__vcs.startSynchronizedProcess(proc, "git", args, self.__repodir)
        if not ok:
            err = str(proc.readAllStandardError(), self.__ioEncoding, "replace")
            EricMessageBox.critical(
                self,
                self.tr("Unlock Worktree"),
                self.tr(
                    "<p>Unlocking the selected worktree failed.</p><p>{0}</p>"
                ).format(err),
            )

        self.__refreshButtonClicked()

    @pyqtSlot()
    def __worktreeMove(self):
        """
        Private slot to move a worktree to a new location.
        """
        worktree = self.worktreeList.selectedItems()[0].text(self.__pathColumn)
        if not worktree:
            return

        newPath, ok = EricPathPickerDialog.getStrPath(
            self,
            self.tr("Move Worktree"),
            self.tr("Enter the new path for the worktree:"),
            mode=EricPathPickerDialog.EricPathPickerModes.DIRECTORY_MODE,
            strPath=worktree,
            defaultDirectory=os.path.dirname(worktree),
        )
        if not ok:
            return

        args = ["worktree", "move", worktree, newPath]

        proc = QProcess()
        ok = self.__vcs.startSynchronizedProcess(proc, "git", args, self.__repodir)
        if not ok:
            err = str(proc.readAllStandardError(), self.__ioEncoding, "replace")
            EricMessageBox.critical(
                self,
                self.tr("Move Worktree"),
                self.tr("<p>Moving the selected worktree failed.</p><p>{0}</p>").format(
                    err
                ),
            )

        self.__refreshButtonClicked()

    @pyqtSlot()
    def __worktreeRemove(self, force=False):
        """
        Private slot to remove a linked worktree.

        @param force flag indicating a forceful remove (defaults to False)
        @type bool (optional
        """
        worktree = self.worktreeList.selectedItems()[0].text(self.__pathColumn)
        if not worktree:
            return

        title = (
            self.tr("Remove Worktree")
            if force
            else self.tr("Remove Worktree Forcefully")
        )

        ok = EricMessageBox.yesNo(
            self,
            title,
            self.tr(
                "<p>Do you really want to remove the worktree <b>{0}</b>?</p>"
            ).format(worktree),
        )
        if not ok:
            return

        args = ["worktree", "remove"]
        if force:
            args.append("--force")
            if (
                self.worktreeList.selectedItems()[0].data(
                    0, GitWorktreeDialog.StatusRole
                )
                == "locked"
            ):
                # a second '--force' is needed
                args.append("--force")
        args.append(worktree)

        proc = QProcess()
        ok = self.__vcs.startSynchronizedProcess(proc, "git", args, self.__repodir)
        if not ok:
            err = str(proc.readAllStandardError(), self.__ioEncoding, "replace")
            EricMessageBox.critical(
                self,
                title,
                self.tr(
                    "<p>Removing the selected worktree failed.</p><p>{0}</p>"
                ).format(err),
            )

        self.__refreshButtonClicked()

    @pyqtSlot()
    def __worktreeRemoveForced(self):
        """
        Private slot to remove a linked worktree forcefully.
        """
        self.__worktreeRemove(force=True)

    @pyqtSlot()
    def __worktreePrune(self):
        """
        Private slot to prune worktree information.
        """
        from eric7.UI.DeleteFilesConfirmationDialog import DeleteFilesConfirmationDialog

        prunableWorktrees = []
        for row in range(self.worktreeList.topLevelItemCount()):
            itm = self.worktreeList.topLevelItem(row)
            status = itm.data(0, GitWorktreeDialog.StatusRole)
            if status == "prunable":
                prunableWorktrees.append(itm.text(self.__pathColumn))

        if prunableWorktrees:
            dlg = DeleteFilesConfirmationDialog(
                self,
                self.tr("Prune Worktree Information"),
                self.tr(
                    "Do you really want to prune the information of these worktrees?"
                ),
                prunableWorktrees,
            )
            if dlg.exec() == QDialog.DialogCode.Accepted:
                args = ["worktree", "prune"]
                if self.expireCheckBox.isChecked():
                    args += [
                        "--expire",
                        self.expireDateTimeEdit.dateTime().toString(
                            Qt.DateFormat.ISODate
                        ),
                    ]

                proc = QProcess()
                ok = self.__vcs.startSynchronizedProcess(
                    proc, "git", args, self.__repodir
                )
                if not ok:
                    err = str(proc.readAllStandardError(), self.__ioEncoding, "replace")
                    EricMessageBox.critical(
                        self,
                        self.tr("Prune Worktree Information"),
                        self.tr(
                            "<p>Pruning of the worktree information failed.</p>"
                            "<p>{0}</p>"
                        ).format(err),
                    )

                self.__refreshButtonClicked()

    @pyqtSlot()
    def __worktreeRepair(self, worktreePaths=None):
        """
        Private slot to repair worktree administrative files.

        @param worktreePaths list of worktree paths to be repaired (defaults to None)
        @type list of str (optional)
        """
        args = ["worktree", "repair"]
        if worktreePaths:
            args += worktreePaths

        proc = QProcess()
        ok = self.__vcs.startSynchronizedProcess(proc, "git", args, self.__repodir)
        if ok:
            out = str(proc.readAllStandardError(), self.__ioEncoding, "replace")
            EricMessageBox.information(
                self,
                self.tr("Repair Worktree"),
                self.tr(
                    "<p>Repairing of the worktree administrative files succeeded.</p>"
                    "<p>{0}</p>"
                ).format(out),
            )

        else:
            err = str(proc.readAllStandardError(), self.__ioEncoding, "replace")
            EricMessageBox.critical(
                self,
                self.tr("Repair Worktree"),
                self.tr(
                    "<p>Repairing of the worktree administrative files failed.</p>"
                    "<p>{0}</p>"
                ).format(err),
            )

        self.__refreshButtonClicked()

    @pyqtSlot()
    def __worktreeRepairMultiple(self):
        """
        Private slot to repair worktree administrative files for multiple worktree
        paths.
        """
        from .GitWorktreePathsDialog import GitWorktreePathsDialog

        # find current worktree and take its parent path as the parent directory
        for row in range(self.worktreeList.topLevelItemCount()):
            itm = self.worktreeList.topLevelItem(row)
            if "current" in itm.data(0, GitWorktreeDialog.StatusRole):
                parentDirectory = os.path.dirname(itm.text(self.__pathColumn))
                break
        else:
            parentDirectory = ""

        dlg = GitWorktreePathsDialog(parentDirectory, self)
        if dlg.exec() == QDialog.DialogCode.Accepted:
            paths = dlg.getPathsList()

            if paths:
                self.__worktreeRepair(worktreePaths=paths)

eric ide

mercurial