1# mq.py - TortoiseHg MQ widget
2#
3# Copyright 2011 Steve Borho <steve@borho.org>
4#
5# This software may be used and distributed according to the terms of the
6# GNU General Public License version 2 or any later version.
7
8from __future__ import absolute_import
9
10import os
11import re
12
13from .qtcore import (
14    QAbstractListModel,
15    QByteArray,
16    QMimeData,
17    QModelIndex,
18    QObject,
19    QPoint,
20    QTimer,
21    QUrl,
22    Qt,
23    pyqtSignal,
24    pyqtSlot,
25)
26from .qtgui import (
27    QAbstractItemView,
28    QAction,
29    QCheckBox,
30    QComboBox,
31    QDialog,
32    QDialogButtonBox,
33    QDockWidget,
34    QFont,
35    QFrame,
36    QHBoxLayout,
37    QInputDialog,
38    QListView,
39    QMenu,
40    QMessageBox,
41    QPushButton,
42    QToolBar,
43    QToolButton,
44    QVBoxLayout,
45    QWidget,
46)
47
48from mercurial import (
49    error,
50    pycompat,
51)
52
53from ..util import hglib
54from ..util.i18n import _
55from . import (
56    cmdcore,
57    cmdui,
58    commit,
59    qtlib,
60    qdelete,
61    qfold,
62    rejects,
63)
64
65if hglib.TYPE_CHECKING:
66    from typing import (
67        Optional,
68    )
69
70def _checkForRejects(repo, rawoutput, parent=None):
71    """Parse output of qpush/qpop to resolve hunk failure manually"""
72    rejre = re.compile(r'saving rejects to file (.*)\.rej')
73    rejfiles = dict((m.group(1), False) for m in rejre.finditer(rawoutput))
74    for ufile in sorted(rejfiles):
75        wfile = hglib.fromunicode(ufile)
76        if not os.path.exists(repo.wjoin(wfile)):
77            continue
78        if qtlib.QuestionMsgBox(_('Manually resolve rejected chunks?'),
79                                _('%s had rejected chunks, edit patched '
80                                  'file together with rejects?') % ufile,
81                                parent=parent):
82            dlg = rejects.RejectsDialog(repo.ui, repo.wjoin(wfile), parent)
83            r = dlg.exec_()
84            rejfiles[ufile] = (r == QDialog.Accepted)
85
86    # empty rejfiles means we failed to parse output message
87    return bool(rejfiles) and all(rejfiles.values())
88
89class QueueManagementActions(QObject):
90    """Container for patch queue management actions"""
91
92    def __init__(self, parent=None):
93        super(QueueManagementActions, self).__init__(parent)
94        assert parent is None or isinstance(parent, QWidget), repr(parent)
95        self._repoagent = None
96        self._cmdsession = cmdcore.nullCmdSession()
97
98        self._actions = {
99            'commitQueue': QAction(_('&Commit to Queue...'), self),
100            'createQueue': QAction(_('Create &New Queue...'), self),
101            'renameQueue': QAction(_('&Rename Active Queue...'), self),
102            'deleteQueue': QAction(_('&Delete Queue...'), self),
103            'purgeQueue':  QAction(_('&Purge Queue...'), self),
104            }
105        for name, action in self._actions.items():
106            action.triggered.connect(getattr(self, '_' + name))
107        self._updateActions()
108
109    def _parentWidget(self):
110        # type: () -> Optional[QWidget]
111        p = self.parent()
112        assert p is None or isinstance(p, QWidget)
113        return p
114
115    def setRepoAgent(self, repoagent):
116        self._repoagent = repoagent
117        self._updateActions()
118
119    def _updateActions(self):
120        enabled = bool(self._repoagent) and self._cmdsession.isFinished()
121        for action in self._actions.values():
122            action.setEnabled(enabled)
123
124    def createMenu(self, parent=None):
125        menu = QMenu(parent)
126        menu.addAction(self._actions['commitQueue'])
127        menu.addSeparator()
128        for name in ['createQueue', 'renameQueue', 'deleteQueue', 'purgeQueue']:
129            menu.addAction(self._actions[name])
130        return menu
131
132    @pyqtSlot()
133    def _commitQueue(self):
134        assert self._repoagent
135        repo = self._repoagent.rawRepo()
136        if os.path.isdir(repo.mq.join(b'.hg')):
137            self._launchCommitDialog()
138            return
139        if not self._cmdsession.isFinished():
140            return
141
142        cmdline = hglib.buildcmdargs('init', mq=True)
143        self._cmdsession = sess = self._repoagent.runCommand(cmdline, self)
144        sess.commandFinished.connect(self._onQueueRepoInitialized)
145        self._updateActions()
146
147    @pyqtSlot(int)
148    def _onQueueRepoInitialized(self, ret):
149        if ret == 0:
150            self._launchCommitDialog()
151        self._onCommandFinished(ret)
152
153    def _launchCommitDialog(self):
154        if not self._repoagent:
155            return
156        repo = self._repoagent.rawRepo()
157        repoagent = self._repoagent.subRepoAgent(hglib.tounicode(repo.mq.path))
158        dlg = commit.CommitDialog(repoagent, [], {}, self._parentWidget())
159        dlg.finished.connect(dlg.deleteLater)
160        dlg.exec_()
161
162    def switchQueue(self, name):
163        return self._runQqueue(None, name)
164
165    @pyqtSlot()
166    def _createQueue(self):
167        name = self._getNewName(_('Create Patch Queue'),
168                                _('New patch queue name'),
169                                _('Create'))
170        if name:
171            self._runQqueue('create', name)
172
173    @pyqtSlot()
174    def _renameQueue(self):
175        curname = self._activeName()
176        newname = self._getNewName(_('Rename Patch Queue'),
177                                   _("Rename patch queue '%s' to") % curname,
178                                   _('Rename'))
179        if newname and curname != newname:
180            self._runQqueue('rename', newname)
181
182    @pyqtSlot()
183    def _deleteQueue(self):
184        name = self._getExistingName(_('Delete Patch Queue'),
185                                     _('Delete reference to'),
186                                     _('Delete'))
187        if name:
188            self._runQqueueInactive('delete', name)
189
190    @pyqtSlot()
191    def _purgeQueue(self):
192        name = self._getExistingName(_('Purge Patch Queue'),
193                                     _('Remove patch directory of'),
194                                     _('Purge'))
195        if name:
196            self._runQqueueInactive('purge', name)
197
198    def _activeName(self):
199        assert self._repoagent
200        repo = self._repoagent.rawRepo()
201        return hglib.tounicode(repo.thgactivemqname)
202
203    def _existingNames(self):
204        assert self._repoagent
205        return hglib.getqqueues(self._repoagent.rawRepo())
206
207    def _getNewName(self, title, labeltext, oktext):
208        dlg = QInputDialog(self._parentWidget())
209        dlg.setWindowTitle(title)
210        dlg.setLabelText(labeltext)
211        dlg.setOkButtonText(oktext)
212        if dlg.exec_():
213            return dlg.textValue()
214
215    def _getExistingName(self, title, labeltext, oktext):
216        dlg = QInputDialog(self._parentWidget())
217        dlg.setWindowTitle(title)
218        dlg.setLabelText(labeltext)
219        dlg.setOkButtonText(oktext)
220        dlg.setComboBoxEditable(False)
221        dlg.setComboBoxItems(self._existingNames())
222        dlg.setTextValue(self._activeName())
223        if dlg.exec_():
224            return dlg.textValue()
225
226    def abort(self):
227        self._cmdsession.abort()
228
229    def _runQqueue(self, op, name):
230        """Execute qqueue operation against the specified queue"""
231        assert self._repoagent
232        if not self._cmdsession.isFinished():
233            return cmdcore.nullCmdSession()
234
235        opts = {}
236        if op:
237            opts[op] = True
238        cmdline = hglib.buildcmdargs('qqueue', name, **opts)
239        self._cmdsession = sess = self._repoagent.runCommand(cmdline, self)
240        sess.commandFinished.connect(self._onCommandFinished)
241        self._updateActions()
242        return sess
243
244    def _runQqueueInactive(self, op, name):
245        """Execute qqueue operation after inactivating the specified queue"""
246        assert self._repoagent
247        if not self._cmdsession.isFinished():
248            return cmdcore.nullCmdSession()
249
250        if name != self._activeName():
251            return self._runQqueue(op, name)
252
253        sacrifices = [n for n in self._existingNames() if n != name]
254        if not sacrifices:
255            return self._runQqueue(op, name)  # will exit with error
256
257        opts = {}
258        if op:
259            opts[op] = True
260        cmdlines = [hglib.buildcmdargs('qqueue', sacrifices[0]),
261                    hglib.buildcmdargs('qqueue', name, **opts)]
262        self._cmdsession = sess = self._repoagent.runCommandSequence(cmdlines,
263                                                                     self)
264        sess.commandFinished.connect(self._onCommandFinished)
265        self._updateActions()
266        return sess
267
268    @pyqtSlot(int)
269    def _onCommandFinished(self, ret):
270        if ret != 0:
271            cmdui.errorMessageBox(self._cmdsession, self._parentWidget())
272        self._updateActions()
273
274
275class PatchQueueActions(QObject):
276    """Container for MQ patch actions except for queue management"""
277
278    def __init__(self, parent=None):
279        super(PatchQueueActions, self).__init__(parent)
280        assert parent is None or isinstance(parent, QWidget), repr(parent)
281        self._repoagent = None
282        self._cmdsession = cmdcore.nullCmdSession()
283        self._opts = {'force': False, 'keep_changes': False}
284
285    def _parentWidget(self):
286        # type: () -> Optional[QWidget]
287        p = self.parent()
288        assert p is None or isinstance(p, QWidget)
289        return p
290
291    def setRepoAgent(self, repoagent):
292        self._repoagent = repoagent
293
294    def gotoPatch(self, patch):
295        opts = {'force': self._opts['force'],
296                'keep_changes': self._opts['keep_changes']}
297        return self._runCommand('qgoto', [patch], opts, self._onPushFinished)
298
299    @pyqtSlot()
300    def pushPatch(self, patch=None, move=False, exact=False):
301        return self._runPush(patch, move=move, exact=exact)
302
303    @pyqtSlot()
304    def pushAllPatches(self):
305        return self._runPush(None, all=True)
306
307    def _runPush(self, patch, **opts):
308        opts['force'] = self._opts['force']
309        if not opts.get('exact'):
310            # --exact and --keep-changes cannot be used simultaneously
311            # thus we ignore the "default" setting for --keep-changes
312            # when --exact is explicitly set
313            opts['keep_changes'] = self._opts['keep_changes']
314        return self._runCommand('qpush', [patch], opts, self._onPushFinished)
315
316    @pyqtSlot()
317    def popPatch(self, patch=None):
318        return self._runPop(patch)
319
320    @pyqtSlot()
321    def popAllPatches(self):
322        return self._runPop(None, all=True)
323
324    def _runPop(self, patch, **opts):
325        opts['force'] = self._opts['force']
326        opts['keep_changes'] = self._opts['keep_changes']
327        return self._runCommand('qpop', [patch], opts)
328
329    def finishRevision(self, rev):
330        revspec = hglib.formatrevspec('qbase::%s', rev)
331        return self._runCommand('qfinish', [revspec], {})
332
333    def deletePatches(self, patches):
334        dlg = qdelete.QDeleteDialog(patches, self._parentWidget())
335        if not dlg.exec_():
336            return cmdcore.nullCmdSession()
337        return self._runCommand('qdelete', patches, dlg.options())
338
339    def foldPatches(self, patches):
340        lpatches = pycompat.maplist(hglib.fromunicode, patches)
341        dlg = qfold.QFoldDialog(self._repoagent, lpatches, self._parentWidget())
342        dlg.finished.connect(dlg.deleteLater)
343        if not dlg.exec_():
344            return cmdcore.nullCmdSession()
345        return self._runCommand('qfold', dlg.patches(), dlg.options())
346
347    def renamePatch(self, patch):
348        newname = self._getNewName(_('Rename Patch'),
349                                   _('Rename patch <b>%s</b> to:') % patch,
350                                   patch, _('Rename'))
351        if not newname or patch == newname:
352            return cmdcore.nullCmdSession()
353        return self._runCommand('qrename', [patch, newname], {})
354
355    def guardPatch(self, patch, guards):
356        args = [patch]
357        args.extend(guards)
358        opts = {'none': not guards}
359        return self._runCommand('qguard', args, opts)
360
361    def selectGuards(self, guards):
362        opts = {'none': not guards}
363        return self._runCommand('qselect', guards, opts)
364
365    def _getNewName(self, title, labeltext, curvalue, oktext):
366        dlg = QInputDialog(self._parentWidget())
367        dlg.setWindowTitle(title)
368        dlg.setLabelText(labeltext)
369        dlg.setTextValue(curvalue)
370        dlg.setOkButtonText(oktext)
371        if dlg.exec_():
372            return pycompat.unicode(dlg.textValue())
373
374    def abort(self):
375        self._cmdsession.abort()
376
377    def _runCommand(self, name, args, opts, finishslot=None):
378        assert self._repoagent
379        if not self._cmdsession.isFinished():
380            return cmdcore.nullCmdSession()
381        cmdline = hglib.buildcmdargs(name, *args, **opts)
382        self._cmdsession = sess = self._repoagent.runCommand(cmdline, self)
383        sess.commandFinished.connect(finishslot or self._onCommandFinished)
384        return sess
385
386    @pyqtSlot(int)
387    def _onPushFinished(self, ret):
388        if ret == 2 and self._repoagent:
389            repo = self._repoagent.rawRepo()
390            output = self._cmdsession.warningString()
391            if _checkForRejects(repo, output, self._parentWidget()):
392                ret = 0  # no further error dialog
393        if ret != 0:
394            cmdui.errorMessageBox(self._cmdsession, self._parentWidget())
395
396    @pyqtSlot(int)
397    def _onCommandFinished(self, ret):
398        if ret != 0:
399            cmdui.errorMessageBox(self._cmdsession, self._parentWidget())
400
401    @pyqtSlot()
402    def launchOptionsDialog(self):
403        dlg = OptionsDialog(self._opts, self._parentWidget())
404        dlg.finished.connect(dlg.deleteLater)
405        dlg.setWindowFlags(Qt.Sheet)
406        dlg.setWindowModality(Qt.WindowModal)
407        if dlg.exec_() == QDialog.Accepted:
408            self._opts.update(dlg.outopts)
409
410
411class PatchQueueModel(QAbstractListModel):
412    """List of all patches in active queue"""
413
414    def __init__(self, repoagent, parent=None):
415        super(PatchQueueModel, self).__init__(parent)
416        self._repoagent = repoagent
417        self._repoagent.repositoryChanged.connect(self._updateCache)
418        self._series = []
419        self._seriesguards = []
420        self._statusmap = {}  # patch: applied/guarded/unguarded
421        self._buildCache()
422
423    @pyqtSlot()
424    def _updateCache(self):
425        # optimize range of changed signals if necessary
426        repo = self._repoagent.rawRepo()
427        if self._series == repo.mq.series[::-1]:
428            self._buildCache()
429        else:
430            self._updateCacheAndLayout()
431        self.dataChanged.emit(self.index(0), self.index(self.rowCount() - 1))
432
433    def _updateCacheAndLayout(self):
434        self.layoutAboutToBeChanged.emit()
435        oldcount = len(self._series)
436        oldindexes = [(oi, self._series[oi.row()])
437                      for oi in self.persistentIndexList()]
438        self._buildCache()
439
440        mappedindexes = []  # old -> new
441        missngindexes = []  # old
442        for oi, patch in oldindexes:
443            try:
444                ni = self.index(self._series.index(patch), oi.column())
445                mappedindexes.append((oi, ni))
446            except ValueError:
447                missngindexes.append(oi)
448        # if no indexes are moved, assume missing ones were renamed
449        if (missngindexes and oldcount == len(self._series)
450            and all(oi.row() == ni.row() for oi, ni in mappedindexes)):
451            mappedindexes.extend((oi, self.index(oi.row(), oi.column()))
452                                 for oi in missngindexes)
453            del missngindexes[:]
454
455        for oi, ni in mappedindexes:
456            self.changePersistentIndex(oi, ni)
457        for oi in missngindexes:
458            self.changePersistentIndex(oi, QModelIndex())
459        self.layoutChanged.emit()
460
461    def _buildCache(self):
462        repo = self._repoagent.rawRepo()
463        self._series = repo.mq.series[::-1]
464        self._seriesguards = [list(xs) for xs in reversed(repo.mq.seriesguards)]
465
466        self._statusmap.clear()
467        self._statusmap.update((p.name, 'applied') for p in repo.mq.applied)
468        for i, patch in enumerate(repo.mq.series):
469            if patch in self._statusmap:
470                continue  # applied
471            pushable, why = repo.mq.pushable(i)
472            if not pushable:
473                self._statusmap[patch] = 'guarded'
474            elif why is not None:
475                self._statusmap[patch] = 'unguarded'
476
477    def data(self, index, role=Qt.DisplayRole):
478        if not index.isValid():
479            return
480        if role in (Qt.DisplayRole, Qt.EditRole):
481            return self.patchName(index)
482        if role == Qt.DecorationRole:
483            return self._statusIcon(index)
484        if role == Qt.FontRole:
485            return self._statusFont(index)
486        if role == Qt.ToolTipRole:
487            return self._toolTip(index)
488
489    def flags(self, index):
490        flags = super(PatchQueueModel, self).flags(index)
491        if not index.isValid():
492            return flags | Qt.ItemIsDropEnabled  # insertion point
493        patch = self._series[index.row()]
494        if self._statusmap.get(patch) != 'applied':
495            flags |= Qt.ItemIsDragEnabled
496        return flags
497
498    def rowCount(self, parent=QModelIndex()):
499        if parent.isValid():
500            return 0
501        return len(self._series)
502
503    def appliedCount(self):
504        return sum(s == 'applied' for s in self._statusmap.values())
505
506    def patchName(self, index):
507        if not index.isValid():
508            return ''
509        return hglib.tounicode(self._series[index.row()])
510
511    def patchGuards(self, index):
512        if not index.isValid():
513            return []
514        return pycompat.maplist(hglib.tounicode,
515                                self._seriesguards[index.row()])
516
517    def isApplied(self, index):
518        if not index.isValid():
519            return False
520        patch = self._series[index.row()]
521        return self._statusmap.get(patch) == 'applied'
522
523    def _statusIcon(self, index):
524        assert index.isValid()
525        patch = self._series[index.row()]
526        status = self._statusmap.get(patch)
527        if status:
528            return qtlib.geticon('hg-patch-%s' % status)
529
530    def _statusFont(self, index):
531        assert index.isValid()
532        patch = self._series[index.row()]
533        status = self._statusmap.get(patch)
534        if status not in ('applied', 'guarded'):
535            return
536        f = QFont()
537        f.setBold(status == 'applied')
538        f.setItalic(status == 'guarded')
539        return f
540
541    def _toolTip(self, index):
542        assert index.isValid()
543        repo = self._repoagent.rawRepo()
544        patch = self._series[index.row()]
545        try:
546            if patch in repo.thgmqunappliedpatches:
547                ctx = repo[patch]
548            else:
549                ctx = hglib.revsymbol(repo, patch)
550        except error.RepoLookupError:
551            # cache not updated after qdelete or qfinish
552            return
553        guards = self.patchGuards(index)
554        # longsummary() is on the thgrepo wrapped context
555        return '%s: %s\n%s' % (self.patchName(index),
556                               guards and ', '.join(guards) or _('no guards'),
557                               ctx.longsummary())  # pytype: disable=attribute-error
558
559    def topAppliedIndex(self, column=0):
560        """Index of the last applied, i.e. qtip, patch"""
561        for row, patch in enumerate(self._series):
562            if self._statusmap.get(patch) == 'applied':
563                return self.index(row, column)
564        return QModelIndex()
565
566    def mimeTypes(self):
567        return ['application/vnd.thg.mq.series', 'text/uri-list']
568
569    def mimeData(self, indexes):
570        repo = self._repoagent.rawRepo()
571        # in the same order as series file
572        patches = [self._series[i.row()]
573                   for i in sorted(indexes, reverse=True)]
574        data = QMimeData()
575        data.setData('application/vnd.thg.mq.series',
576                     QByteArray(b'\n'.join(patches) + b'\n'))
577        data.setUrls([QUrl.fromLocalFile(hglib.tounicode(repo.mq.join(p)))
578                      for p in patches])
579        return data
580
581    def dropMimeData(self, data, action, row, column, parent):
582        if (action != Qt.MoveAction
583            or not data.hasFormat('application/vnd.thg.mq.series')
584            or row < 0 or parent.isValid()):
585            return False
586
587        repo = self._repoagent.rawRepo()
588        qtiprow = len(self._series) - repo.mq.seriesend(True)
589        if row > qtiprow:
590            return False
591        if row < len(self._series):
592            after = hglib.tounicode(self._series[row])
593        else:
594            after = None  # next to working rev
595        patches = hglib.tounicode(
596            bytes(data.data('application/vnd.thg.mq.series'))).splitlines()
597        cmdline = hglib.buildcmdargs('qreorder', after=after, *patches)
598        self._repoagent.runCommand(cmdline)
599        return True
600
601    def supportedDropActions(self):
602        return Qt.MoveAction
603
604
605class MQPatchesWidget(QDockWidget):
606    patchSelected = pyqtSignal(str)
607
608    def __init__(self, actionregistry, parent):
609        QDockWidget.__init__(self, parent)
610        self._actionregistry = actionregistry
611        self._repoagent = None
612
613        self.setFeatures(QDockWidget.DockWidgetClosable |
614                         QDockWidget.DockWidgetMovable  |
615                         QDockWidget.DockWidgetFloatable)
616        self.setWindowTitle(_('Patch Queue'))
617
618        w = QWidget()
619        mainlayout = QVBoxLayout()
620        mainlayout.setContentsMargins(0, 0, 0, 0)
621        w.setLayout(mainlayout)
622        self.setWidget(w)
623
624        self._patchActions = PatchQueueActions(self)
625
626        # top toolbar
627        w = QWidget()
628        tbarhbox = QHBoxLayout()
629        tbarhbox.setContentsMargins(0, 0, 0, 0)
630        w.setLayout(tbarhbox)
631        mainlayout.addWidget(w)
632
633        # TODO: move QAction instances to PatchQueueActions
634        self._qpushAct = a = QAction(qtlib.geticon('hg-qpush'), '', self)
635        a.setToolTip(_('Apply one patch'))
636        self._qpushAllAct = a = QAction(qtlib.geticon('hg-qpush-all'), '', self)
637        a.setToolTip(_('Apply all patches'))
638        self._qpopAct = a = QAction(qtlib.geticon('hg-qpop'), '', self)
639        a.setToolTip(_('Unapply one patch'))
640        self._qpopAllAct = a = QAction(qtlib.geticon('hg-qpop-all'), '', self)
641        a.setToolTip(_('Unapply all patches'))
642        self._qgotoAct = QAction(qtlib.geticon('hg-qgoto'), '', self)
643        self._qpushMoveAct = a = QAction(qtlib.geticon('hg-qpush-move'), '',
644                                         self)
645        a.setToolTip(_('Apply only the selected patch'))
646        self._qfinishAct = a = QAction(qtlib.geticon('qfinish'), '', self)
647        a.setToolTip(_('Move applied patches into repository history'))
648        self._qdeleteAct = a = QAction(qtlib.geticon('hg-qdelete'), '', self)
649        a.setToolTip(_('Delete selected patches'))
650        self._qrenameAct = a = QAction(self)
651        self._setGuardsAct = a = QAction(qtlib.geticon('hg-qguard'), '', self)
652        a.setToolTip(_('Configure guards for selected patch'))
653
654        namedActions = {
655            'PatchQueue.pushOnePatch': self._qpushAct,
656            'PatchQueue.pushAllPatches': self._qpushAllAct,
657            'PatchQueue.popOnePatch': self._qpopAct,
658            'PatchQueue.popAllPatches': self._qpopAllAct,
659            'PatchQueue.goToPatch': self._qgotoAct,
660            'PatchQueue.pushMovePatch': self._qpushMoveAct,
661            'PatchQueue.finishRevision': self._qfinishAct,
662            'PatchQueue.deletePatches': self._qdeleteAct,
663            'PatchQueue.renamePatch': self._qrenameAct,
664            'PatchQueue.guardPatch': self._setGuardsAct,
665        }
666        for n, a in namedActions.items():
667            self.addAction(a)
668            a.setShortcutContext(Qt.WidgetWithChildrenShortcut)
669            self._actionregistry.registerAction(n, a)
670
671        tbar = QToolBar(_('Patch Queue Actions Toolbar'), self)
672        tbar.setIconSize(qtlib.smallIconSize())
673        tbarhbox.addWidget(tbar)
674        tbar.addAction(self._qpushAct)
675        tbar.addAction(self._qpushAllAct)
676        tbar.addSeparator()
677        tbar.addAction(self._qpopAct)
678        tbar.addAction(self._qpopAllAct)
679        tbar.addSeparator()
680        tbar.addAction(self._qpushMoveAct)
681        tbar.addSeparator()
682        tbar.addAction(self._qfinishAct)
683        tbar.addAction(self._qdeleteAct)
684        tbar.addSeparator()
685        tbar.addAction(self._setGuardsAct)
686
687        self._queueFrame = w = QFrame()
688        mainlayout.addWidget(w)
689
690        # Patch Queue Frame
691        layout = QVBoxLayout()
692        layout.setSpacing(5)
693        layout.setContentsMargins(0, 0, 0, 0)
694        self._queueFrame.setLayout(layout)
695
696        qqueuehbox = QHBoxLayout()
697        qqueuehbox.setSpacing(5)
698        layout.addLayout(qqueuehbox)
699        self._qqueueComboWidget = QComboBox(self)
700        qqueuehbox.addWidget(self._qqueueComboWidget, 1)
701        self._qqueueConfigBtn = QToolButton(self)
702        self._qqueueConfigBtn.setText('...')
703        self._qqueueConfigBtn.setPopupMode(QToolButton.InstantPopup)
704        qqueuehbox.addWidget(self._qqueueConfigBtn)
705
706        self._qqueueActions = QueueManagementActions(self)
707        self._qqueueConfigBtn.setMenu(self._qqueueActions.createMenu(self))
708
709        self._queueListWidget = QListView(self)
710        self._queueListWidget.setDragDropMode(QAbstractItemView.InternalMove)
711        self._queueListWidget.setEditTriggers(QAbstractItemView.NoEditTriggers)
712        self._queueListWidget.setIconSize(qtlib.smallIconSize() * 0.75)
713        self._queueListWidget.setSelectionMode(
714            QAbstractItemView.ExtendedSelection)
715        self._queueListWidget.setContextMenuPolicy(Qt.CustomContextMenu)
716        self._queueListWidget.customContextMenuRequested.connect(
717            self._onMenuRequested)
718        layout.addWidget(self._queueListWidget, 1)
719
720        bbarhbox = QHBoxLayout()
721        bbarhbox.setSpacing(5)
722        layout.addLayout(bbarhbox)
723        self._guardSelBtn = QPushButton()
724        menu = QMenu(self)
725        menu.triggered.connect(self._onGuardSelectionChange)
726        self._guardSelBtn.setMenu(menu)
727        bbarhbox.addWidget(self._guardSelBtn)
728
729        self._qqueueComboWidget.activated[str].connect(self._onQQueueActivated)
730
731        self._queueListWidget.activated.connect(self._onGotoPatch)
732
733        self._qpushAct.triggered.connect(self._patchActions.pushPatch)
734        self._qpushAllAct.triggered.connect(self._patchActions.pushAllPatches)
735        self._qpopAct.triggered.connect(self._patchActions.popPatch)
736        self._qpopAllAct.triggered.connect(self._patchActions.popAllPatches)
737        self._qgotoAct.triggered.connect(self._onGotoPatch)
738        self._qpushMoveAct.triggered.connect(self._onPushMovePatch)
739        self._qfinishAct.triggered.connect(self._onFinishRevision)
740        self._qdeleteAct.triggered.connect(self._onDelete)
741        self._qrenameAct.triggered.connect(self._onRenamePatch)
742        self._setGuardsAct.triggered.connect(self._onGuardConfigure)
743
744        self.setAcceptDrops(True)
745
746        self.layout().setContentsMargins(2, 2, 2, 2)
747
748        QTimer.singleShot(0, self.reload)
749
750    @property
751    def _repo(self):
752        if self._repoagent:
753            return self._repoagent.rawRepo()
754
755    def setRepoAgent(self, repoagent):
756        if self._repoagent:
757            self._repoagent.repositoryChanged.disconnect(self.reload)
758        self._repoagent = None
759        if repoagent and b'mq' in repoagent.rawRepo().extensions():
760            self._repoagent = repoagent
761            self._repoagent.repositoryChanged.connect(self.reload)
762        self._changePatchQueueModel()
763        self._patchActions.setRepoAgent(repoagent)
764        self._qqueueActions.setRepoAgent(repoagent)
765        QTimer.singleShot(0, self.reload)
766
767    def _changePatchQueueModel(self):
768        oldmodel = self._queueListWidget.model()
769        if self._repoagent:
770            newmodel = PatchQueueModel(self._repoagent, self)
771            self._queueListWidget.setModel(newmodel)
772            newmodel.dataChanged.connect(self._updatePatchActions)
773            selmodel = self._queueListWidget.selectionModel()
774            selmodel.currentRowChanged.connect(self._onPatchSelected)
775            selmodel.selectionChanged.connect(self._updatePatchActions)
776            self._updatePatchActions()
777        else:
778            self._queueListWidget.setModel(None)
779        if oldmodel:
780            oldmodel.setParent(None)
781
782    @pyqtSlot()
783    def _showActiveQueue(self):
784        combo = self._qqueueComboWidget
785        q = hglib.tounicode(self._repo.thgactivemqname)
786        index = combo.findText(q)
787        combo.setCurrentIndex(index)
788
789    @pyqtSlot(QPoint)
790    def _onMenuRequested(self, pos):
791        menu = QMenu(self)
792        menu.addAction(self._qgotoAct)
793        menu.addAction(self._qpushMoveAct)
794        menu.addAction(self._qfinishAct)
795        menu.addAction(self._qdeleteAct)
796        menu.addAction(self._qrenameAct)
797        menu.addAction(self._setGuardsAct)
798        menu.setAttribute(Qt.WA_DeleteOnClose)
799        menu.popup(self._queueListWidget.viewport().mapToGlobal(pos))
800
801    def _currentPatchName(self):
802        model = self._queueListWidget.model()
803        assert model is not None
804        index = self._queueListWidget.currentIndex()
805        return model.patchName(index)
806
807    @pyqtSlot()
808    def _onGuardConfigure(self):
809        model = self._queueListWidget.model()
810        assert model is not None
811        index = self._queueListWidget.currentIndex()
812        patch = model.patchName(index)
813        uguards = ' '.join(model.patchGuards(index))
814        new, ok = qtlib.getTextInput(self,
815                      _('Configure guards'),
816                      _('Input new guards for %s:') % patch,
817                      text=uguards)
818        if not ok or new == uguards:
819            return
820        self._patchActions.guardPatch(patch, pycompat.unicode(new).split())
821
822    @pyqtSlot()
823    def _onDelete(self):
824        model = self._queueListWidget.model()
825        selmodel = self._queueListWidget.selectionModel()
826        assert model is not None
827        assert selmodel is not None
828        patches = pycompat.maplist(model.patchName, selmodel.selectedRows())
829        self._patchActions.deletePatches(patches)
830
831    @pyqtSlot()
832    def _onGotoPatch(self):
833        patch = self._currentPatchName()
834        self._patchActions.gotoPatch(patch)
835
836    @pyqtSlot()
837    def _onPushMovePatch(self):
838        patch = self._currentPatchName()
839        self._patchActions.pushPatch(patch, move=True)
840
841    @pyqtSlot()
842    def _onFinishRevision(self):
843        patch = self._currentPatchName()
844        self._patchActions.finishRevision(patch)
845
846    @pyqtSlot()
847    def _onRenamePatch(self):
848        patch = self._currentPatchName()
849        self._patchActions.renamePatch(patch)
850
851    @pyqtSlot()
852    def _onPatchSelected(self):
853        patch = self._currentPatchName()
854        if patch:
855            self.patchSelected.emit(patch)
856
857    @pyqtSlot()
858    def _updatePatchActions(self):
859        model = self._queueListWidget.model()
860        selmodel = self._queueListWidget.selectionModel()
861        assert model is not None
862        assert selmodel is not None
863
864        appliedcnt = model.appliedCount()
865        seriescnt = model.rowCount()
866        self._qpushAllAct.setEnabled(seriescnt > appliedcnt)
867        self._qpushAct.setEnabled(seriescnt > appliedcnt)
868        self._qpopAct.setEnabled(appliedcnt > 0)
869        self._qpopAllAct.setEnabled(appliedcnt > 0)
870
871        indexes = selmodel.selectedRows()
872        anyapplied = any(model.isApplied(i) for i in indexes)
873        self._qgotoAct.setEnabled(len(indexes) == 1
874                                  and indexes[0] != model.topAppliedIndex())
875        self._qpushMoveAct.setEnabled(len(indexes) == 1 and not anyapplied)
876        self._qfinishAct.setEnabled(len(indexes) == 1 and anyapplied)
877        self._qdeleteAct.setEnabled(len(indexes) > 0 and not anyapplied)
878        self._setGuardsAct.setEnabled(len(indexes) == 1)
879        self._qrenameAct.setEnabled(len(indexes) == 1)
880
881    @pyqtSlot(str)
882    def _onQQueueActivated(self, text):
883        if text == hglib.tounicode(self._repo.thgactivemqname):
884            return
885
886        if qtlib.QuestionMsgBox(_('Confirm patch queue switch'),
887                _("Do you really want to activate patch queue '%s' ?") % text,
888                parent=self, defaultbutton=QMessageBox.No):
889            sess = self._qqueueActions.switchQueue(text)
890            sess.commandFinished.connect(self._showActiveQueue)
891        else:
892            self._showActiveQueue()
893
894    @pyqtSlot()
895    def reload(self):
896        # Repository mutation should be disabled while incoming bundle is
897        # applied. Some MQ operation could be allowed, but let's simply
898        # disable the widget at all.
899        self.widget().setEnabled(bool(self._repoagent)
900                                 and not self._repoagent.overlayUrl())
901        if not self._repoagent:
902            return
903
904        self._loadQQueues()
905        self._showActiveQueue()
906
907        repo = self._repo
908
909        self._allguards = set()
910        for idx, patch in enumerate(repo.mq.series):
911            patchguards = repo.mq.seriesguards[idx]
912            if patchguards:
913                for guard in patchguards:
914                    self._allguards.add(guard[1:])
915
916        for guard in repo.mq.active():
917            self._allguards.add(guard)
918        self._refreshSelectedGuards()
919
920        self._qqueueComboWidget.setEnabled(self._qqueueComboWidget.count() > 1)
921
922    def _loadQQueues(self):
923        repo = self._repo
924        combo = self._qqueueComboWidget
925        combo.clear()
926        combo.addItems(hglib.getqqueues(repo))
927
928    def _refreshSelectedGuards(self):
929        total = len(self._allguards)
930        count = len(self._repo.mq.active())
931        menu = self._guardSelBtn.menu()
932        menu.clear()
933        for guard in self._allguards:
934            a = menu.addAction(hglib.tounicode(guard))
935            a.setCheckable(True)
936            a.setChecked(guard in self._repo.mq.active())
937        self._guardSelBtn.setText(_('Guards: %d/%d') % (count, total))
938        self._guardSelBtn.setEnabled(bool(total))
939
940    @pyqtSlot(QAction)
941    def _onGuardSelectionChange(self, action):
942        guard = hglib.fromunicode(action.text())
943        newguards = self._repo.mq.active()[:]
944        if action.isChecked():
945            newguards.append(guard)
946        elif guard in newguards:
947            newguards.remove(guard)
948        self._patchActions.selectGuards(pycompat.maplist(hglib.tounicode,
949                                                         newguards))
950
951    def keyPressEvent(self, event):
952        if event.key() == Qt.Key_Escape:
953            self._patchActions.abort()
954            self._qqueueActions.abort()
955        else:
956            return super(MQPatchesWidget, self).keyPressEvent(event)
957
958
959class OptionsDialog(QDialog):
960    'Utility dialog for configuring uncommon options'
961    def __init__(self, opts, parent=None):
962        QDialog.__init__(self, parent)
963        self.setWindowTitle(_('MQ options'))
964
965        layout = QVBoxLayout()
966        self.setLayout(layout)
967
968        self.forcecb = QCheckBox(
969            _('Force push or pop (--force)'))
970        layout.addWidget(self.forcecb)
971
972        self.keepcb = QCheckBox(
973            _('Tolerate non-conflicting local changes (--keep-changes)'))
974        layout.addWidget(self.keepcb)
975
976        self.forcecb.setChecked(opts.get('force', False))
977        self.keepcb.setChecked(opts.get('keep_changes', False))
978
979        for cb in [self.forcecb, self.keepcb]:
980            cb.clicked.connect(self._resolveopts)
981
982        BB = QDialogButtonBox
983        bb = QDialogButtonBox(BB.Ok|BB.Cancel)
984        bb.accepted.connect(self.accept)
985        bb.rejected.connect(self.reject)
986        self.bb = bb
987        layout.addWidget(bb)
988
989    @pyqtSlot()
990    def _resolveopts(self):
991        # cannot use both --force and --keep-changes
992        exclmap = {self.forcecb: [self.keepcb],
993                   self.keepcb: [self.forcecb],
994                   }
995        sendercb = self.sender()
996        if sendercb.isChecked():
997            for cb in exclmap[sendercb]:
998                cb.setChecked(False)
999
1000    def accept(self):
1001        outopts = {'force': self.forcecb.isChecked(),
1002                   'keep_changes': self.keepcb.isChecked()}
1003        self.outopts = outopts
1004        QDialog.accept(self)
1005