Wed, 04 Oct 2023 17:50:59 +0200
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)