1# repowidget.py - TortoiseHg repository widget
2#
3# Copyright (C) 2007-2010 Logilab. All rights reserved.
4# Copyright (C) 2010 Adrian Buehlmann <adrian@cadifra.com>
5#
6# This software may be used and distributed according to the terms
7# of the GNU General Public License, incorporated herein by reference.
8
9from __future__ import absolute_import
10
11import binascii
12import os
13import shlex  # used by runCustomCommand
14import subprocess  # used by runCustomCommand
15
16from .qtcore import (
17    QFile,
18    QIODevice,
19    QItemSelectionModel,
20    QMimeData,
21    QPoint,
22    QSettings,
23    QTimer,
24    QUrl,
25    Qt,
26    pyqtSignal,
27    pyqtSlot,
28)
29from .qtgui import (
30    QAction,
31    QApplication,
32    QDesktopServices,
33    QFileDialog,
34    QIcon,
35    QKeySequence,
36    QMainWindow,
37    QMenu,
38    QMessageBox,
39    QSplitter,
40    QTabWidget,
41    QVBoxLayout,
42    QWidget,
43)
44
45from mercurial import (
46    error,
47    node as nodemod,
48    phases,
49    pycompat,
50    scmutil,
51)
52from mercurial.utils import (
53    procutil,
54)
55
56from ..util import (
57    hglib,
58    paths,
59    shlib,
60)
61from ..util.i18n import _
62from . import (
63    archive,
64    backout,
65    bisect,
66    bookmark,
67    close_branch,
68    cmdcore,
69    cmdui,
70    compress,
71    graft,
72    hgemail,
73    infobar,
74    matching,
75    merge,
76    mq,
77    pick,
78    phabreview,
79    postreview,
80    prune,
81    purge,
82    qtlib,
83    rebase,
84    repomodel,
85    resolve,
86    revdetails,
87    settings,
88    shelve,
89    sign,
90    tag,
91    thgimport,
92    thgstrip,
93    topic,
94    update,
95    visdiff,
96)
97from .commit import CommitWidget
98from .docklog import ConsoleWidget
99from .grep import SearchWidget
100from .qtlib import (
101    DemandWidget,
102    InfoMsgBox,
103    QuestionMsgBox,
104    WarningMsgBox,
105)
106from .repofilter import RepoFilterBar
107from .repoview import HgRepoView
108from .sync import SyncWidget
109
110if hglib.TYPE_CHECKING:
111    from typing import (
112        Callable,
113        Dict,
114        List,
115        Optional,
116        Sequence,
117        Set,
118        Text,
119        Tuple,
120        Union,
121    )
122
123
124_SELECTION_SINGLE = 'single'
125_SELECTION_PAIR = 'pair'
126_SELECTION_SOME = 'some'
127
128_SELECTION_INCOMING = 'incoming'
129_SELECTION_OUTGOING = 'outgoing'
130
131# iswd = working directory
132# isrev = the changeset has an integer revision number
133# isctx = changectx or workingctx
134# ispatch = applied revision or unapplied patch
135# fixed = the changeset is considered permanent
136# applied = an applied patch
137# unapplied = unapplied patch
138# qfold = unapplied patch and at least one applied patch exists
139# qgoto = applied patch or qparent
140# qpush = unapplied patch and can qpush
141# qpushmove = unapplied patch and can qpush --move to reorder patches
142# isdraftorwd = working directory or changset is draft
143_SELECTION_ISREV = 'isrev'
144_SELECTION_ISWD = 'iswd'
145_SELECTION_ISCTX = 'isctx'
146_SELECTION_ISPATCH = 'ispatch'
147_SELECTION_FIXED = 'fixed'
148_SELECTION_APPLIED = 'applied'
149_SELECTION_UNAPPLIED = 'unapplied'
150_SELECTION_QFOLD = 'qfold'
151_SELECTION_QGOTO = 'qgoto'
152_SELECTION_QPUSH = 'qpush'
153_SELECTION_QPUSHMOVE = 'qpushmove'
154_SELECTION_ISTRUE = 'istrue'
155_SELECTION_ISDRAFTORWD = 'isdraftorwd'
156
157_KNOWN_SELECTION_ATTRS = {
158    _SELECTION_SINGLE,
159    _SELECTION_PAIR,
160    _SELECTION_SOME,
161
162    _SELECTION_INCOMING,
163    _SELECTION_OUTGOING,
164
165    _SELECTION_ISREV,
166    _SELECTION_ISWD,
167    _SELECTION_ISCTX,
168    _SELECTION_ISPATCH,
169    _SELECTION_FIXED,
170    _SELECTION_APPLIED,
171    _SELECTION_UNAPPLIED,
172    _SELECTION_QFOLD,
173    _SELECTION_QGOTO,
174    _SELECTION_QPUSH,
175    _SELECTION_QPUSHMOVE,
176    _SELECTION_ISTRUE,
177    _SELECTION_ISDRAFTORWD,
178}  # type: Set[Text]
179
180# selection attributes which may be specified by user
181_CUSTOM_TOOLS_SELECTION_ATTRS = {
182    _SELECTION_ISREV,
183    _SELECTION_ISWD,
184    _SELECTION_ISCTX,
185    _SELECTION_FIXED,
186    _SELECTION_APPLIED,
187    _SELECTION_QGOTO,
188    _SELECTION_ISTRUE,
189    _SELECTION_ISDRAFTORWD,
190}  # type: Set[Text]
191
192
193class RepoWidget(QWidget):
194
195    currentTaskTabChanged = pyqtSignal()
196    showMessageSignal = pyqtSignal(str)
197    taskTabVisibilityChanged = pyqtSignal(bool)
198    toolbarVisibilityChanged = pyqtSignal(bool)
199
200    # TODO: progress can be removed if all actions are run as hg command
201    progress = pyqtSignal(str, object, str, str, object)
202    makeLogVisible = pyqtSignal(bool)
203
204    revisionSelected = pyqtSignal(object)
205
206    titleChanged = pyqtSignal(str)
207    """Emitted when changed the expected title for the RepoWidget tab"""
208
209    busyIconChanged = pyqtSignal()
210
211    repoLinkClicked = pyqtSignal(str)
212    """Emitted when clicked a link to open repository"""
213
214    def __init__(self, actionregistry, repoagent, parent=None, bundle=None):
215        QWidget.__init__(self, parent, acceptDrops=True)
216
217        self._actionregistry = actionregistry
218        self._repoagent = repoagent
219        self.bundlesource = None  # source URL of incoming bundle [unicode]
220        self.outgoingMode = False
221        self._busyIconNames = []
222        self._namedTabs = {}
223        self.destroyed.connect(self.repo.thginvalidate)
224
225        self.currentMessage = ''
226
227        self.setupUi()
228        self._actions = {}  # type: Dict[Text, Tuple[QAction, Set[Text], Set[Text]]]
229        self.createActions()
230        self.loadSettings()
231        self._initModel()
232
233        self._lastTaskTabVisible = self.isTaskTabVisible()
234        self.repotabs_splitter.splitterMoved.connect(self._onSplitterMoved)
235
236        if bundle:
237            self.setBundle(bundle)
238
239        self._dialogs = qtlib.DialogKeeper(
240            lambda self, dlgmeth, *args: dlgmeth(self, *args), parent=self)
241
242        # listen to change notification after initial settings are loaded
243        repoagent.repositoryChanged.connect(self.repositoryChanged)
244        repoagent.configChanged.connect(self.configChanged)
245
246        self._updateNamedActions()
247        QTimer.singleShot(0, self._initView)
248
249    def setupUi(self):
250        self.repotabs_splitter = QSplitter(orientation=Qt.Vertical)
251        self.setLayout(QVBoxLayout())
252        self.layout().setContentsMargins(0, 0, 0, 0)
253        self.layout().setSpacing(0)
254
255        # placeholder to shift repoview while infobar is overlaid
256        self._repoviewFrame = infobar.InfoBarPlaceholder(self._repoagent, self)
257        self._repoviewFrame.linkActivated.connect(self._openLink)
258
259        self.filterbar = RepoFilterBar(self._repoagent, self)
260        self.layout().addWidget(self.filterbar)
261
262        self.filterbar.branchChanged.connect(self.setBranch)
263        self.filterbar.showHiddenChanged.connect(self.setShowHidden)
264        self.filterbar.showGraftSourceChanged.connect(self.setShowGraftSource)
265        self.filterbar.setRevisionSet.connect(self.setRevisionSet)
266        self.filterbar.filterToggled.connect(self.filterToggled)
267        self.filterbar.visibilityChanged.connect(self.toolbarVisibilityChanged)
268        self.filterbar.hide()
269
270        self.layout().addWidget(self.repotabs_splitter)
271
272        cs = ('Workbench', _('Workbench Log Columns'))
273        self.repoview = view = HgRepoView(self._repoagent, 'repoWidget', cs,
274                                          self)
275        view.clicked.connect(self._clearInfoMessage)
276        view.revisionSelected.connect(self.onRevisionSelected)
277        view.revisionActivated.connect(self.onRevisionActivated)
278        view.showMessage.connect(self.showMessage)
279        view.menuRequested.connect(self._popupSelectionMenu)
280        self._repoviewFrame.setView(view)
281
282        self.repotabs_splitter.addWidget(self._repoviewFrame)
283        self.repotabs_splitter.setCollapsible(0, True)
284        self.repotabs_splitter.setStretchFactor(0, 1)
285
286        self.taskTabsWidget = tt = QTabWidget()
287        self.repotabs_splitter.addWidget(self.taskTabsWidget)
288        self.repotabs_splitter.setStretchFactor(1, 1)
289        tt.setDocumentMode(True)
290        self.updateTaskTabs()
291        tt.currentChanged.connect(self.currentTaskTabChanged)
292
293        w = revdetails.RevDetailsWidget(self._repoagent, self)
294        self.revDetailsWidget = w
295        self.revDetailsWidget.filelisttbar.setStyleSheet(qtlib.tbstylesheet)
296        w.linkActivated.connect(self._openLink)
297        w.revisionSelected.connect(self.repoview.goto)
298        w.grepRequested.connect(self.grep)
299        w.showMessage.connect(self.showMessage)
300        w.revsetFilterRequested.connect(self.setFilter)
301        w.runCustomCommandRequested.connect(
302            self.handleRunCustomCommandRequest)
303        idx = tt.addTab(w, qtlib.geticon('hg-log'), '')
304        self._namedTabs['log'] = idx
305        tt.setTabToolTip(idx, _("Revision details", "tab tooltip"))
306
307        self.commitDemand = w = DemandWidget('createCommitWidget', self)
308        idx = tt.addTab(w, qtlib.geticon('hg-commit'), '')
309        self._namedTabs['commit'] = idx
310        tt.setTabToolTip(idx, _("Commit", "tab tooltip"))
311
312        self.grepDemand = w = DemandWidget('createGrepWidget', self)
313        idx = tt.addTab(w, qtlib.geticon('hg-grep'), '')
314        self._namedTabs['grep'] = idx
315        tt.setTabToolTip(idx, _("Search", "tab tooltip"))
316
317        w = ConsoleWidget(self._repoagent, self)
318        self.consoleWidget = w
319        w.closeRequested.connect(self.switchToPreferredTaskTab)
320        idx = tt.addTab(w, qtlib.geticon('thg-console'), '')
321        self._namedTabs['console'] = idx
322        tt.setTabToolTip(idx, _("Console log", "tab tooltip"))
323
324        self.syncDemand = w = DemandWidget('createSyncWidget', self)
325        idx = tt.addTab(w, qtlib.geticon('thg-sync'), '')
326        self._namedTabs['sync'] = idx
327        tt.setTabToolTip(idx, _("Synchronize", "tab tooltip"))
328
329    @pyqtSlot()
330    def _initView(self):
331        self._updateRepoViewForModel()
332        # restore column widths when model is initially loaded.  For some
333        # reason, this needs to be deferred after updating the view.  Otherwise
334        # repoview.HgRepoView.resizeEvent() fires as the vertical scrollbar is
335        # added, which causes the last column to grow by the scrollbar width on
336        # each restart (and steal from the description width).
337        QTimer.singleShot(0, self.repoview.resizeColumns)
338
339        # select the widget chosen by the user
340        name = self._repoagent.configString('tortoisehg', 'defaultwidget')
341        if name:
342            name = {'revdetails': 'log', 'search': 'grep'}.get(name, name)
343            self.taskTabsWidget.setCurrentIndex(self._namedTabs.get(name, 0))
344
345    def currentTaskTabName(self):
346        indexmap = dict((idx, name)
347                        for name, idx in self._namedTabs.items())
348        return indexmap.get(self.taskTabsWidget.currentIndex())
349
350    @pyqtSlot(str)
351    def switchToNamedTaskTab(self, tabname):
352        tabname = str(tabname)
353        if tabname in self._namedTabs:
354            idx = self._namedTabs[tabname]
355            # refresh status even if current widget is already a 'commit'
356            if (tabname == 'commit'
357                and self.taskTabsWidget.currentIndex() == idx):
358                self._refreshCommitTabIfNeeded()
359            self.taskTabsWidget.setCurrentIndex(idx)
360
361            # restore default splitter position if task tab is invisible
362            self.setTaskTabVisible(True)
363
364    def isTaskTabVisible(self):
365        return self.repotabs_splitter.sizes()[1] > 0
366
367    def setTaskTabVisible(self, visible):
368        if visible == self.isTaskTabVisible():
369            return
370        if visible:
371            self.repotabs_splitter.setSizes([1, 1])
372        else:
373            self.repotabs_splitter.setSizes([1, 0])
374        self._updateLastTaskTabState(visible)
375
376    @pyqtSlot()
377    def _onSplitterMoved(self):
378        visible = self.isTaskTabVisible()
379        if self._lastTaskTabVisible == visible:
380            return
381        self._updateLastTaskTabState(visible)
382
383    def _updateLastTaskTabState(self, visible):
384        self._lastTaskTabVisible = visible
385        self.taskTabVisibilityChanged.emit(visible)
386
387    @property
388    def repo(self):
389        return self._repoagent.rawRepo()
390
391    def repoRootPath(self):
392        return self._repoagent.rootPath()
393
394    def repoDisplayName(self):
395        return self._repoagent.displayName()
396
397    def title(self):
398        """Returns the expected title for this widget [unicode]"""
399        name = self._repoagent.shortName()
400        if self._repoagent.overlayUrl():
401            return _('%s <incoming>') % name
402        elif self.repomodel.branch():
403            return u'%s [%s]' % (name, self.repomodel.branch())
404        else:
405            return name
406
407    def busyIcon(self):
408        if self._busyIconNames:
409            return qtlib.geticon(self._busyIconNames[-1])
410        else:
411            return QIcon()
412
413    def filterBar(self):
414        return self.filterbar
415
416    def filterBarVisible(self):
417        return self.filterbar.isVisible()
418
419    @pyqtSlot(bool)
420    def toggleFilterBar(self, checked):
421        """Toggle display repowidget filter bar"""
422        if self.filterbar.isVisibleTo(self) == checked:
423            return
424        self.filterbar.setVisible(checked)
425        if checked:
426            self.filterbar.setFocus()
427
428    def _openRepoLink(self, upath):
429        path = hglib.fromunicode(upath)
430        if not os.path.isabs(path):
431            path = self.repo.wjoin(path)
432        self.repoLinkClicked.emit(hglib.tounicode(path))
433
434    @pyqtSlot(str)
435    def _openLink(self, link):
436        link = pycompat.unicode(link)
437        handlers = {'cset': self.goto,
438                    'log': lambda a: self.makeLogVisible.emit(True),
439                    'repo': self._openRepoLink,
440                    'shelve' : self.shelve}
441        if ':' in link:
442            scheme, param = link.split(':', 1)
443            hdr = handlers.get(scheme)
444            if hdr:
445                return hdr(param)
446        if os.path.isabs(link):
447            qtlib.openlocalurl(link)
448        else:
449            QDesktopServices.openUrl(QUrl(link))
450
451    def setInfoBar(self, cls, *args, **kwargs):
452        return self._repoviewFrame.setInfoBar(cls, *args, **kwargs)
453
454    def clearInfoBar(self, priority=None):
455        return self._repoviewFrame.clearInfoBar(priority)
456
457    def createCommitWidget(self):
458        pats = []
459        opts = {}
460        cw = CommitWidget(self._repoagent, pats, opts, self, rev=self.rev)
461        cw.buttonHBox.addWidget(cw.commitSetupButton())
462        cw.loadSettings(QSettings(), 'Workbench')
463
464        cw.progress.connect(self.progress)
465        cw.linkActivated.connect(self._openLink)
466        cw.showMessage.connect(self.showMessage)
467        cw.grepRequested.connect(self.grep)
468        cw.runCustomCommandRequested.connect(
469            self.handleRunCustomCommandRequest)
470        QTimer.singleShot(0, self._initCommitWidgetLate)
471        return cw
472
473    @pyqtSlot()
474    def _initCommitWidgetLate(self):
475        cw = self.commitDemand.get()
476        cw.reload()
477        # auto-refresh should be enabled after initial reload(); otherwise
478        # refreshWctx() can be doubled
479        self.taskTabsWidget.currentChanged.connect(
480            self._refreshCommitTabIfNeeded)
481
482    def createSyncWidget(self):
483        sw = SyncWidget(self._repoagent, self)
484        sw.newCommand.connect(self._handleNewSyncCommand)
485        sw.outgoingNodes.connect(self.setOutgoingNodes)
486        sw.showMessage.connect(self.showMessage)
487        sw.showMessage.connect(self._repoviewFrame.showMessage)
488        sw.incomingBundle.connect(self.setBundle)
489        sw.pullCompleted.connect(self.onPullCompleted)
490        sw.pushCompleted.connect(self.clearRevisionSet)
491        sw.refreshTargets(self.rev)
492        sw.switchToRequest.connect(self.switchToNamedTaskTab)
493        return sw
494
495    @pyqtSlot(cmdcore.CmdSession)
496    def _handleNewSyncCommand(self, sess):
497        self._handleNewCommand(sess)
498        if sess.isFinished():
499            return
500        sess.commandFinished.connect(self._onSyncCommandFinished)
501        self._setBusyIcon('thg-sync')
502
503    @pyqtSlot()
504    def _onSyncCommandFinished(self):
505        self._clearBusyIcon('thg-sync')
506
507    def _setBusyIcon(self, iconname):
508        self._busyIconNames.append(iconname)
509        self.busyIconChanged.emit()
510
511    def _clearBusyIcon(self, iconname):
512        if iconname in self._busyIconNames:
513            self._busyIconNames.remove(iconname)
514        self.busyIconChanged.emit()
515
516    @pyqtSlot(str)
517    def setFilter(self, filter):
518        self.filterbar.setQuery(filter)
519        self.filterbar.setVisible(True)
520        self.filterbar.runQuery()
521
522    def isBundleSet(self):
523        # type: () -> bool
524        return (bool(self._repoagent.overlayUrl())
525                and self.repomodel.revset() == 'bundle()')
526
527    @pyqtSlot(str, str)
528    def setBundle(self, bfile, bsource=None):
529        if self._repoagent.overlayUrl():
530            self.clearBundle()
531        self.bundlesource = bsource and pycompat.unicode(bsource) or None
532        oldlen = len(self.repo)
533        # no "bundle:<bfile>" because bfile may contain "+" separator
534        self._repoagent.setOverlay(bfile)
535        self.filterbar.setQuery('bundle()')
536        self.filterbar.runQuery()
537        self.titleChanged.emit(self.title())
538        newlen = len(self.repo)
539
540        w = self.setInfoBar(infobar.ConfirmInfoBar,
541            _('Found %d incoming changesets') % (newlen - oldlen))
542        assert w
543        w.acceptButton.setText(_('Pull'))
544        w.acceptButton.setToolTip(_('Pull incoming changesets into '
545                                    'your repository'))
546        w.rejectButton.setText(_('Cancel'))
547        w.rejectButton.setToolTip(_('Reject incoming changesets'))
548        w.accepted.connect(self.acceptBundle)
549        w.rejected.connect(self.clearBundle)
550
551    @pyqtSlot()
552    def clearBundle(self):
553        self.clearRevisionSet()
554        self.bundlesource = None
555        self._repoagent.clearOverlay()
556        self.titleChanged.emit(self.title())
557
558    @pyqtSlot()
559    def onPullCompleted(self):
560        if self._repoagent.overlayUrl():
561            self.clearBundle()
562
563    @pyqtSlot()
564    def acceptBundle(self):
565        bundle = self._repoagent.overlayUrl()
566        if bundle:
567            w = self.syncDemand.get()
568            w.pullBundle(bundle, None, self.bundlesource)
569
570    @pyqtSlot()
571    def pullBundleToRev(self):
572        bundle = self._repoagent.overlayUrl()
573        if bundle:
574            # manually remove infobar to work around unwanted clearBundle
575            # during pull operation (issue #2596)
576            self._repoviewFrame.discardInfoBar()
577
578            w = self.syncDemand.get()
579            w.pullBundle(bundle, self.repo[self.rev].hex(), self.bundlesource)
580
581    @pyqtSlot()
582    def clearRevisionSet(self):
583        self.filterbar.setQuery('')
584        self.setRevisionSet('')
585
586    def setRevisionSet(self, revspec):
587        self.repomodel.setRevset(revspec)
588        if not revspec and self.outgoingMode:
589            self.outgoingMode = False
590            self._updateNamedActions()
591
592    @pyqtSlot(bool)
593    def filterToggled(self, checked):
594        self.repomodel.setFilterByRevset(checked)
595
596    def setOutgoingNodes(self, nodes):
597        self.filterbar.setQuery('outgoing()')
598        revs = [self.repo[n].rev() for n in nodes]
599        self.setRevisionSet(hglib.compactrevs(revs))
600        self.outgoingMode = True
601        numnodes = len(nodes)
602        numoutgoing = numnodes
603
604        if self.syncDemand.get().isTargetSelected():
605            # Outgoing preview is already filtered by target selection
606            defaultpush = None
607        else:
608            # Read the tortoisehg.defaultpush setting to determine what to push
609            # by default, and set the button label and action accordingly
610            defaultpush = self._repoagent.configString(
611                'tortoisehg', 'defaultpush')
612        rev = None
613        branch = None
614        pushall = False
615        # note that we assume that none of the revisions
616        # on the nodes/revs lists is secret
617        if defaultpush == 'branch':
618            branch = self.repo[b'.'].branch()
619            ubranch = hglib.tounicode(branch)
620            # Get the list of revs that will be actually pushed
621            outgoingrevs = self.repo.revs(b'%ld and branch(.)', revs)
622            numoutgoing = len(outgoingrevs)
623        elif defaultpush == 'revision':
624            rev = self.repo[b'.'].rev()
625            # Get the list of revs that will be actually pushed
626            # excluding (potentially) the current rev
627            outgoingrevs = self.repo.revs(b'%ld and ::.', revs)
628            numoutgoing = len(outgoingrevs)
629            maxrev = rev
630            if numoutgoing > 0:
631                maxrev = max(outgoingrevs)
632        else:
633            pushall = True
634
635        # Set the default acceptbuttontext
636        # Note that the pushall case uses the default accept button text
637        if branch is not None:
638            acceptbuttontext = _('Push current branch (%s)') % ubranch
639        elif rev is not None:
640            if maxrev == rev:
641                acceptbuttontext = _('Push up to current revision (#%d)') % rev
642            else:
643                acceptbuttontext = _('Push up to revision #%d') % maxrev
644        else:
645            acceptbuttontext = _('Push all')
646
647        if numnodes == 0:
648            msg = _('no outgoing changesets')
649        elif numoutgoing == 0:
650            if branch:
651                msg = _('no outgoing changesets in current branch (%s) '
652                    '/ %d in total') % (ubranch, numnodes)
653            elif rev is not None:
654                if maxrev == rev:
655                    msg = _('no outgoing changesets up to current revision '
656                            '(#%d) / %d in total') % (rev, numnodes)
657                else:
658                    msg = _('no outgoing changesets up to revision #%d '
659                            '/ %d in total') % (maxrev, numnodes)
660        elif numoutgoing == numnodes:
661            # This case includes 'Push all' among others
662            msg = _('%d outgoing changesets') % numoutgoing
663        elif branch:
664            msg = _('%d outgoing changesets in current branch (%s) '
665                    '/ %d in total') % (numoutgoing, ubranch, numnodes)
666        elif rev:
667            if maxrev == rev:
668                msg = _('%d outgoing changesets up to current revision (#%d) '
669                        '/ %d in total') % (numoutgoing, rev, numnodes)
670            else:
671                msg = _('%d outgoing changesets up to revision #%d '
672                        '/ %d in total') % (numoutgoing, maxrev, numnodes)
673        else:
674            # This should never happen but we leave this else clause
675            # in case there is a flaw in the logic above (e.g. due to
676            # a future change in the code)
677            msg = _('%d outgoing changesets') % numoutgoing
678
679        w = self.setInfoBar(infobar.ConfirmInfoBar, msg.strip())
680        assert w
681
682        if numoutgoing == 0:
683            acceptbuttontext = _('Nothing to push')
684            w.acceptButton.setEnabled(False)
685        w.acceptButton.setText(acceptbuttontext)
686        w.accepted.connect(lambda: self.push(False,
687            rev=rev, branch=branch, pushall=pushall))  # TODO: to the same URL
688        w.rejected.connect(self.clearRevisionSet)
689        self._updateNamedActions()
690
691    def createGrepWidget(self):
692        upats = {}
693        gw = SearchWidget(self._repoagent, upats, self)
694        gw.setRevision(self.repoview.current_rev)
695        gw.showMessage.connect(self.showMessage)
696        gw.progress.connect(self.progress)
697        gw.revisionSelected.connect(self.goto)
698        return gw
699
700    @property
701    def rev(self):
702        """Returns the current active revision"""
703        return self.repoview.current_rev
704
705    def gotoRev(self, revspec):
706        """Select and scroll to the specified revision"""
707        try:
708            # try instant look up
709            if scmutil.isrevsymbol(self.repo, hglib.fromunicode(revspec)):
710                self.repoview.goto(revspec)
711                return
712        except error.LookupError:
713            pass  # ambiguous node
714
715        cmdline = hglib.buildcmdargs('log', rev=revspec, template='{rev}\n')
716        sess = self._runCommand(cmdline)
717        sess.setCaptureOutput(True)
718        sess.commandFinished.connect(self._onGotoRevQueryFinished)
719
720    @pyqtSlot(int)
721    def _onGotoRevQueryFinished(self, ret):
722        sess = self.sender()
723        if ret != 0:
724            return
725        output = bytes(sess.readAll())
726        if not output:
727            # TODO: maybe this should be a warning bar since there would be no
728            # information in log window.
729            self.setInfoBar(infobar.CommandErrorInfoBar, _('No revision found'))
730            return
731        rev = int(output.splitlines()[-1])  # pick last rev as "hg update" does
732        self.repoview.goto(rev)
733
734    def showMessage(self, msg):
735        self.currentMessage = msg
736        if self.isVisible():
737            self.showMessageSignal.emit(msg)
738
739    def keyPressEvent(self, event):
740        if self._repoviewFrame.activeInfoBar() and event.key() == Qt.Key_Escape:
741            self.clearInfoBar(infobar.INFO)
742        else:
743            QWidget.keyPressEvent(self, event)
744
745    def showEvent(self, event):
746        QWidget.showEvent(self, event)
747        self.showMessageSignal.emit(self.currentMessage)
748        if not event.spontaneous():
749            # RepoWidget must be the main widget in any window, so grab focus
750            # when it gets visible at start-up or by switching tabs.
751            self.repoview.setFocus()
752
753    def createActions(self):
754        self._mqActions = None
755        if b'mq' in self.repo.extensions():
756            self._mqActions = mq.PatchQueueActions(self)
757            self._mqActions.setRepoAgent(self._repoagent)
758
759        self._setUpNamedActions()
760
761    def detectPatches(self, paths):
762        filepaths = []
763        for p in paths:
764            if not os.path.isfile(p):
765                continue
766            try:
767                pf = open(p, 'rb')
768                earlybytes = pf.read(4096)
769                if b'\0' in earlybytes:
770                    continue
771                pf.seek(0)
772                with hglib.extractpatch(self.repo.ui, pf) as data:
773                    if data.get('filename'):
774                        filepaths.append(p)
775            except EnvironmentError:
776                pass
777        return filepaths
778
779    def dragEnterEvent(self, event):
780        paths = [pycompat.unicode(u.toLocalFile()) for u in event.mimeData().urls()]
781        if self.detectPatches(paths):
782            event.setDropAction(Qt.CopyAction)
783            event.accept()
784
785    def dropEvent(self, event):
786        paths = [pycompat.unicode(u.toLocalFile()) for u in event.mimeData().urls()]
787        patches = self.detectPatches(paths)
788        if not patches:
789            return
790        event.setDropAction(Qt.CopyAction)
791        event.accept()
792        self.thgimport(patches)
793
794    ## Begin Workbench event forwards
795
796    def back(self):
797        self.repoview.back()
798
799    def forward(self):
800        self.repoview.forward()
801
802    def bisect(self):
803        self._dialogs.open(RepoWidget._createBisectDialog)
804
805    @pyqtSlot()
806    def bisectGoodBadRevisionsPair(self):
807        revA, revB = self._selectedIntRevisionsPair()
808        dlg = self._dialogs.open(RepoWidget._createBisectDialog)
809        dlg.restart(str(revA), str(revB))
810
811    @pyqtSlot()
812    def bisectBadGoodRevisionsPair(self):
813        revA, revB = self._selectedIntRevisionsPair()
814        dlg = self._dialogs.open(RepoWidget._createBisectDialog)
815        dlg.restart(str(revB), str(revA))
816
817    def _createBisectDialog(self):
818        dlg = bisect.BisectDialog(self._repoagent, self)
819        dlg.newCandidate.connect(self.gotoParent)
820        return dlg
821
822    def resolve(self):
823        dlg = resolve.ResolveDialog(self._repoagent, self)
824        dlg.exec_()
825
826    def thgimport(self, paths=None):
827        dlg = thgimport.ImportDialog(self._repoagent, self)
828        if paths:
829            dlg.setfilepaths(paths)
830        if dlg.exec_() == 0:
831            self.gotoTip()
832
833    def unbundle(self):
834         w = self.syncDemand.get()
835         w.unbundle()
836
837    def shelve(self, arg=None):
838        self._dialogs.open(RepoWidget._createShelveDialog)
839
840    def _createShelveDialog(self):
841        dlg = shelve.ShelveDialog(self._repoagent)
842        dlg.finished.connect(self._refreshCommitTabIfNeeded)
843        return dlg
844
845    def verify(self):
846        cmdline = ['verify', '--verbose']
847        dlg = cmdui.CmdSessionDialog(self)
848        dlg.setWindowIcon(qtlib.geticon('hg-verify'))
849        dlg.setWindowTitle(_('%s - verify repository') % self.repoDisplayName())
850        dlg.setWindowFlags(dlg.windowFlags() | Qt.WindowMaximizeButtonHint)
851        dlg.setSession(self._repoagent.runCommand(cmdline, self))
852        dlg.exec_()
853
854    def recover(self):
855        cmdline = ['recover', '--verbose']
856        dlg = cmdui.CmdSessionDialog(self)
857        dlg.setWindowIcon(qtlib.geticon('hg-recover'))
858        dlg.setWindowTitle(_('%s - recover repository')
859                           % self.repoDisplayName())
860        dlg.setWindowFlags(dlg.windowFlags() | Qt.WindowMaximizeButtonHint)
861        dlg.setSession(self._repoagent.runCommand(cmdline, self))
862        dlg.exec_()
863
864    def rollback(self):
865        desc, oldlen = hglib.readundodesc(self.repo)
866        if not desc:
867            InfoMsgBox(_('No transaction available'),
868                       _('There is no rollback transaction available'))
869            return
870        elif desc == 'commit':
871            if not QuestionMsgBox(_('Undo last commit?'),
872                   _('Undo most recent commit (%d), preserving file changes?') %
873                   oldlen):
874                return
875        else:
876            if not QuestionMsgBox(_('Undo last transaction?'),
877                    _('Rollback to revision %d (undo %s)?') %
878                    (oldlen - 1, desc)):
879                return
880            try:
881                rev = self.repo[b'.'].rev()
882            except error.LookupError as e:
883                InfoMsgBox(_('Repository Error'),
884                           _('Unable to determine working copy revision\n') +
885                           hglib.tounicode(bytes(e)))
886                return
887            if rev >= oldlen and not QuestionMsgBox(
888                    _('Remove current working revision?'),
889                    _('Your current working revision (%d) will be removed '
890                      'by this rollback, leaving uncommitted changes.\n '
891                      'Continue?') % rev):
892                return
893        cmdline = ['rollback', '--verbose']
894        sess = self._runCommand(cmdline)
895        sess.commandFinished.connect(self._notifyWorkingDirChanges)
896
897    def purge(self):
898        dlg = purge.PurgeDialog(self._repoagent, self)
899        dlg.setWindowFlags(Qt.Sheet)
900        dlg.setWindowModality(Qt.WindowModal)
901        dlg.showMessage.connect(self.showMessage)
902        dlg.progress.connect(self.progress)
903        dlg.exec_()
904        # ignores result code of PurgeDialog because it's unreliable
905        self._refreshCommitTabIfNeeded()
906
907    ## End workbench event forwards
908
909    @pyqtSlot(str, dict)
910    def grep(self, pattern='', opts=None):
911        """Open grep task tab"""
912        if opts is None:
913            opts = {}
914        opts = dict((str(k), str(v)) for k, v in opts.items())
915        self.taskTabsWidget.setCurrentIndex(self._namedTabs['grep'])
916        self.grepDemand.setSearch(pattern, **opts)
917        self.grepDemand.runSearch()
918
919    def _initModel(self):
920        self.repomodel = repomodel.HgRepoListModel(self._repoagent, self)
921        self.repomodel.setBranch(self.filterbar.branch(),
922                                 self.filterbar.branchAncestorsIncluded())
923        self.repomodel.setFilterByRevset(self.filterbar.filtercb.isChecked())
924        self.repomodel.setShowGraftSource(self.filterbar.getShowGraftSource())
925        self.repomodel.showMessage.connect(self.showMessage)
926        self.repomodel.showMessage.connect(self._repoviewFrame.showMessage)
927        self.repoview.setModel(self.repomodel)
928        self.repomodel.revsUpdated.connect(self._updateRepoViewForModel)
929
930        selmodel = self.repoview.selectionModel()
931        assert selmodel is not None
932        selmodel.selectionChanged.connect(self._onSelectedRevisionsChanged)
933
934    @pyqtSlot()
935    def _updateRepoViewForModel(self):
936        model = self.repoview.model()
937        selmodel = self.repoview.selectionModel()
938        assert model is not None
939        assert selmodel is not None
940        index = selmodel.currentIndex()
941        if not (index.flags() & Qt.ItemIsEnabled):
942            index = model.defaultIndex()
943            f = QItemSelectionModel.ClearAndSelect | QItemSelectionModel.Rows
944            selmodel.setCurrentIndex(index, f)
945        self.repoview.scrollTo(index)
946        self.repoview.enablefilterpalette(bool(model.revset()))
947        self.clearInfoBar(infobar.INFO)  # clear progress message
948
949    @pyqtSlot()
950    def _clearInfoMessage(self):
951        self.clearInfoBar(infobar.INFO)
952
953    @pyqtSlot()
954    def switchToPreferredTaskTab(self):
955        tw = self.taskTabsWidget
956        rev = self.rev
957        ctx = self.repo[rev]
958        if rev is None or (b'mq' in self.repo.extensions()
959                           and b'qtip' in ctx.tags()
960                           and self.repo[b'.'].rev() == rev):
961            # Clicking on working copy or on the topmost applied patch
962            # (_if_ it is also the working copy parent) switches to the commit tab
963            tw.setCurrentIndex(self._namedTabs['commit'])
964        else:
965            # Clicking on a normal revision switches from commit tab
966            tw.setCurrentIndex(self._namedTabs['log'])
967
968    def onRevisionSelected(self, rev):
969        'View selection changed, could be a reload'
970        self.showMessage('')
971        try:
972            self.revDetailsWidget.onRevisionSelected(rev)
973            self.revisionSelected.emit(rev)
974            if not isinstance(rev, str):
975                # Regular patch or working directory
976                self.grepDemand.forward('setRevision', rev)
977                self.syncDemand.forward('refreshTargets', rev)
978                self.commitDemand.forward('setRev', rev)
979        except (IndexError, error.RevlogError, error.Abort) as e:
980            self.showMessage(hglib.tounicode(str(e)))
981
982        cw = self.taskTabsWidget.currentWidget()
983        if cw.canswitch():
984            self.switchToPreferredTaskTab()
985
986    @pyqtSlot()
987    def _onSelectedRevisionsChanged(self):
988        self._updateNamedActions()
989
990    @pyqtSlot()
991    def gotoParent(self):
992        self.goto('.')
993
994    def gotoTip(self):
995        self.repoview.clearSelection()
996        self.goto('tip')
997
998    def _gotoAncestor(self):
999        revs = self._selectedIntRevisions()
1000        if not revs:
1001            return
1002        ancestor = self.repo[revs[0]]
1003        for rev in revs[1:]:
1004            ctx = self.repo[rev]
1005            ancestor = ancestor.ancestor(ctx)
1006        self.goto(ancestor.rev())
1007
1008    def goto(self, rev):
1009        self.repoview.goto(rev)
1010
1011    def onRevisionActivated(self, rev):
1012        qgoto = False
1013        if hglib.isbasestring(rev):
1014            qgoto = True
1015        else:
1016            ctx = self.repo[rev]
1017            if b'qparent' in ctx.tags() or ctx.thgmqappliedpatch():
1018                qgoto = True
1019            if b'qtip' in ctx.tags():
1020                qgoto = False
1021        if qgoto:
1022            self.qgotoSelectedRevision()
1023        else:
1024            self.visualDiffRevision()
1025
1026    def reload(self, invalidate=True):
1027        'Initiate a refresh of the repo model, rebuild graph'
1028        try:
1029            if invalidate:
1030                self.repo.thginvalidate()
1031            self.rebuildGraph()
1032            self.reloadTaskTab()
1033        except EnvironmentError as e:
1034            self.showMessage(hglib.tounicode(str(e)))
1035
1036    def rebuildGraph(self):
1037        'Called by repositoryChanged signals, and during reload'
1038        self.showMessage('')
1039        self.filterbar.refresh()
1040        self.repoview.saveSettings()
1041
1042    def reloadTaskTab(self):
1043        w = self.taskTabsWidget.currentWidget()
1044        w.reload()
1045
1046    @pyqtSlot()
1047    def repositoryChanged(self):
1048        'Repository has detected a changelog / dirstate change'
1049        try:
1050            self.rebuildGraph()
1051        except (error.RevlogError, error.RepoError) as e:
1052            self.showMessage(hglib.tounicode(str(e)))
1053        self._updateNamedActions()
1054
1055    @pyqtSlot()
1056    def configChanged(self):
1057        'Repository is reporting its config files have changed'
1058        self.revDetailsWidget.reload()
1059        self.titleChanged.emit(self.title())
1060        self.updateTaskTabs()
1061
1062    def updateTaskTabs(self):
1063        val = self._repoagent.configString('tortoisehg', 'tasktabs').lower()
1064        if val == 'east':
1065            self.taskTabsWidget.setTabPosition(QTabWidget.East)
1066            self.taskTabsWidget.tabBar().show()
1067        elif val == 'west':
1068            self.taskTabsWidget.setTabPosition(QTabWidget.West)
1069            self.taskTabsWidget.tabBar().show()
1070        else:
1071            self.taskTabsWidget.tabBar().hide()
1072
1073    @pyqtSlot(str, bool)
1074    def setBranch(self, branch, allparents):
1075        self.repomodel.setBranch(branch, allparents=allparents)
1076        self.titleChanged.emit(self.title())
1077
1078    @pyqtSlot(bool)
1079    def setShowHidden(self, showhidden):
1080        self._repoagent.setHiddenRevsIncluded(showhidden)
1081
1082    @pyqtSlot(bool)
1083    def setShowGraftSource(self, showgraftsource):
1084        self.repomodel.setShowGraftSource(showgraftsource)
1085
1086    ##
1087    ## Workbench methods
1088    ##
1089
1090    def canGoBack(self):
1091        return self.repoview.canGoBack()
1092
1093    def canGoForward(self):
1094        return self.repoview.canGoForward()
1095
1096    def loadSettings(self):
1097        s = QSettings()
1098        repoid = hglib.shortrepoid(self.repo)
1099        self.revDetailsWidget.loadSettings(s)
1100        self.filterbar.loadSettings(s)
1101        self._repoagent.setHiddenRevsIncluded(self.filterbar.getShowHidden())
1102        self.repotabs_splitter.restoreState(
1103            qtlib.readByteArray(s, 'repoWidget/splitter-' + repoid))
1104
1105    def okToContinue(self):
1106        if self._repoagent.isBusy():
1107            r = QMessageBox.question(self, _('Confirm Exit'),
1108                                     _('Mercurial command is still running.\n'
1109                                       'Are you sure you want to terminate?'),
1110                                     QMessageBox.Yes | QMessageBox.No,
1111                                     QMessageBox.No)
1112            if r == QMessageBox.Yes:
1113                self._repoagent.abortCommands()
1114            return False
1115        for i in pycompat.xrange(self.taskTabsWidget.count()):
1116            w = self.taskTabsWidget.widget(i)
1117            if w.canExit():
1118                continue
1119            self.taskTabsWidget.setCurrentWidget(w)
1120            self.showMessage(_('Tab cannot exit'))
1121            return False
1122        return True
1123
1124    def closeRepoWidget(self):
1125        '''returns False if close should be aborted'''
1126        if not self.okToContinue():
1127            return False
1128        s = QSettings()
1129        if self.isVisible():
1130            try:
1131                repoid = hglib.shortrepoid(self.repo)
1132                s.setValue('repoWidget/splitter-' + repoid,
1133                           self.repotabs_splitter.saveState())
1134            except EnvironmentError:
1135                pass
1136        self.revDetailsWidget.saveSettings(s)
1137        self.commitDemand.forward('saveSettings', s, 'workbench')
1138        self.grepDemand.forward('saveSettings', s)
1139        self.filterbar.saveSettings(s)
1140        self.repoview.saveSettings(s)
1141        return True
1142
1143    def setSyncUrl(self, url):
1144        """Change the current peer-repo url of the sync widget; url may be
1145        a symbolic name defined in [paths] section"""
1146        self.syncDemand.get().setUrl(url)
1147
1148    def incoming(self):
1149        self.syncDemand.get().incoming()
1150
1151    def pull(self):
1152        self.syncDemand.get().pull()
1153    def outgoing(self):
1154        self.syncDemand.get().outgoing()
1155    def push(self, confirm=None, **kwargs):
1156        """Call sync push.
1157
1158        If confirm is False, the user will not be prompted for
1159        confirmation. If confirm is True, the prompt might be used.
1160        """
1161        self.syncDemand.get().push(confirm, **kwargs)
1162        self.outgoingMode = False
1163        self._updateNamedActions()
1164
1165    def syncBookmark(self):
1166        self.syncDemand.get().syncBookmark()
1167
1168    ##
1169    ## Repoview context menu
1170    ##
1171
1172    def _isRevisionSelected(self):
1173        # type: () -> bool
1174        """True if the selection includes change/workingctx revision"""
1175        return any(r is None or isinstance(r, int)
1176                   for r in self.repoview.selectedRevisions())
1177
1178    def _isUnappliedPatchSelected(self):
1179        # type: () -> bool
1180        """True if the selection includes unapplied patch"""
1181        return any(r is not None and not isinstance(r, int)
1182                   for r in self.repoview.selectedRevisions())
1183
1184    def _isRevisionsPairSelected(self):
1185        # type: () -> bool
1186        """True if exactly two change/workingctx revisions are selected"""
1187        return (len(self.repoview.selectedRevisions()) == 2
1188                and not self._isUnappliedPatchSelected())
1189
1190    def _selectionAttributes(self):
1191        # type: () -> Set[Text]
1192        """Returns a set of keywords that describe the selected revisions"""
1193        attributes = {_SELECTION_ISTRUE}
1194
1195        revisions, patches = self._selectedIntRevisionsAndUnappliedPatches()
1196
1197        if len(revisions) + len(patches) == 1:
1198            attributes.add(_SELECTION_SINGLE)
1199        if len(revisions) + len(patches) == 2:
1200            attributes.add(_SELECTION_PAIR)
1201        if revisions or patches:
1202            attributes.add(_SELECTION_SOME)
1203
1204        # In incoming/outgoing mode, unrelated revisions and patches are
1205        # filtered out. So we don't have to test each selected revision.
1206        if self.isBundleSet():
1207            attributes.add(_SELECTION_INCOMING)
1208        if self.outgoingMode:
1209            attributes.add(_SELECTION_OUTGOING)
1210
1211        if not patches and revisions:
1212            attributes.add(_SELECTION_ISCTX)
1213
1214            haswdir = max(revisions) == nodemod.wdirrev
1215            if not haswdir:
1216                attributes.add(_SELECTION_ISREV)
1217            if len(revisions) == 1 and haswdir:
1218                attributes.add(_SELECTION_ISWD)
1219
1220            ctxs = [self.repo[rev] for rev in revisions]
1221            if all(c.phase() >= phases.draft or c.rev() is None for c in ctxs):
1222                attributes.add(_SELECTION_ISDRAFTORWD)
1223            if not haswdir and not any(c.thgmqappliedpatch() for c in ctxs):
1224                attributes.add(_SELECTION_FIXED)
1225            if all(c.thgmqappliedpatch() for c in ctxs):
1226                attributes.add(_SELECTION_ISPATCH)
1227                attributes.add(_SELECTION_APPLIED)
1228            if all(c.thgmqappliedpatch() or b'qparent' in c.tags()
1229                   for c in ctxs):
1230                attributes.add(_SELECTION_QGOTO)
1231
1232        if not revisions and patches:
1233            attributes.add(_SELECTION_ISPATCH)
1234            attributes.add(_SELECTION_UNAPPLIED)
1235            if b'qtip' in self.repo.tags():
1236                attributes.add(_SELECTION_QFOLD)
1237
1238            # TODO: maybe better to not scan all patches and test selection?
1239            q = self.repo.mq
1240            ispushable = False
1241            qnext = ''
1242            unapplied = 0
1243            for i in pycompat.xrange(q.seriesend(), len(q.series)):
1244                pushable, reason = q.pushable(i)
1245                if pushable:
1246                    if unapplied == 0:
1247                        qnext = hglib.tounicode(q.series[i])
1248                    if self.rev == q.series[i]:
1249                        ispushable = True
1250                    unapplied += 1
1251
1252            if ispushable:
1253                attributes.add(_SELECTION_QPUSH)
1254            if ispushable and len(patches) == 1 and patches[0] != qnext:
1255                attributes.add(_SELECTION_QPUSHMOVE)
1256
1257        return attributes
1258
1259    def _selectedIntRevisionsAndUnappliedPatches(self):
1260        # type: () -> Tuple[List[int], List[Text]]
1261        """Returns lists of selected change/workingctx revisions and unapplied
1262        patches"""
1263        revisions = []
1264        patches = []
1265        for r in self.repoview.selectedRevisions():
1266            if r is None:
1267                revisions.append(nodemod.wdirrev)
1268            elif isinstance(r, int):
1269                revisions.append(r)
1270            else:
1271                assert isinstance(r, bytes)
1272                patches.append(hglib.tounicode(r))
1273        return revisions, patches
1274
1275    def _selectedIntRevisions(self):
1276        # type: () -> List[int]
1277        """Returns a list of selected change/workingctx revisions
1278
1279        Unapplied patches are excluded.
1280        """
1281        revisions, _patches = self._selectedIntRevisionsAndUnappliedPatches()
1282        return revisions
1283
1284    def _selectedIntRevisionsPair(self):
1285        # type: () -> Tuple[int, int]
1286        """Returns a pair of change/workingctx revisions if exactly two
1287        revisions are selected
1288
1289        Otherwise returns (nullrev, nullrev) for convenience. Use
1290        _isRevisionsPairSelected() if you need to check it strictly.
1291        """
1292        if not self._isRevisionsPairSelected():
1293            return nodemod.nullrev, nodemod.nullrev
1294        rev0, rev1 = self._selectedIntRevisions()
1295        return rev0, rev1
1296
1297    def _selectedDagRangeRevisions(self):
1298        # type: () -> List[int]
1299        """Returns a list of revisions in the DAG range specified by the
1300        selected revisions pair
1301
1302        If no revisions pair selected, returns an empty list.
1303        """
1304        if not self._isRevisionsPairSelected():
1305            return []
1306        rev0, rev1 = sorted(self._selectedIntRevisions())
1307        # simply disable lazy evaluation as we won't handle slow query
1308        return list(self.repo.revs(b'%d::%d', rev0, rev1))
1309
1310    def _selectedUnappliedPatches(self):
1311        # type: () -> List[Text]
1312        """Returns a list of selected unapplied patches"""
1313        _revisions, patches = self._selectedIntRevisionsAndUnappliedPatches()
1314        return patches
1315
1316    @pyqtSlot(QPoint)
1317    def _popupSelectionMenu(self, point):
1318        'User requested a context menu in repo view widget'
1319
1320        selection = self.repoview.selectedRevisions()
1321        if not selection:
1322            return
1323
1324        if self.isBundleSet():
1325            self._popupIncomingBundleMenu(point)
1326        elif not self._isRevisionSelected():
1327            self._popupUnappliedPatchMenu(point)
1328        elif len(selection) == 1:
1329            self._popupSingleSelectionMenu(point)
1330        elif len(selection) == 2:
1331            self._popupPairSelectionMenu(point)
1332        else:
1333            self._popupMultipleSelectionMenu(point)
1334
1335    def _popupSingleSelectionMenu(self, point):
1336        menu = QMenu(self)
1337
1338        if self.outgoingMode:
1339            submenu = menu.addMenu(_('Pus&h'))
1340            self._addNamedActionsToMenu(submenu, [
1341                'Repository.pushToRevision',
1342                'Repository.pushBranch',
1343                'Repository.pushAll',
1344            ])
1345            menu.addSeparator()
1346
1347        self._addNamedActionsToMenu(menu, [
1348            'Repository.updateToRevision',
1349            None,
1350            'Repository.visualDiff',
1351            'Repository.visualDiffToLocal',
1352            'Repository.browseRevision',
1353            'RepoView.filterByRevisionsMenu',
1354            None,
1355            'Repository.mergeWithRevision',
1356            'Repository.closeRevision',
1357            'Repository.tagRevision',
1358            'Repository.bookmarkRevision',
1359            'Repository.topicRevision',
1360            'Repository.signRevision',
1361            None,
1362            'Repository.backoutToRevision',
1363            'Repository.revertToRevision',
1364            None,
1365        ])
1366
1367        submenu = menu.addMenu(_('Copy &Hash'))
1368        self._addNamedActionsToMenu(submenu, [
1369            'Repository.copyHash',
1370            'Repository.copyShortHash',
1371            None,
1372            'Repository.copyGitHash',
1373            'Repository.copyShortGitHash',
1374        ])
1375        menu.addSeparator()
1376
1377        submenu = menu.addMenu(_('E&xport'))
1378        self._addNamedActionsToMenu(submenu, [
1379            'Repository.exportRevisions',
1380            'Repository.emailRevisions',
1381            'Repository.archiveRevision',
1382            'Repository.bundleRevisions',
1383            'Repository.copyPatch',
1384        ])
1385        menu.addSeparator()
1386
1387        self._addNamedActionsToMenu(menu, [
1388            'RepoView.changePhaseMenu',
1389            None,
1390            'Repository.graftRevisions',
1391        ])
1392
1393        submenu = menu.addMenu(_('Modi&fy History'))
1394        self._addNamedActionsToMenu(submenu, [
1395            'PatchQueue.popPatch',
1396            'PatchQueue.importRevision',
1397            'PatchQueue.finishRevision',
1398            'PatchQueue.renamePatch',
1399            None,
1400            'PatchQueue.launchOptionsDialog',
1401            None,
1402            'Repository.pickRevision',
1403            'Repository.rebaseRevision',
1404            None,
1405            'Repository.pruneRevisions',
1406            'Repository.stripRevision'
1407        ])
1408        submenu.menuAction().setVisible(not submenu.isEmpty())
1409
1410        self._addNamedActionsToMenu(menu, [
1411            'Repository.sendToReviewBoard',
1412            'Repository.sendToPhabricator',
1413        ])
1414
1415        self._addCustomToolsSubMenu(menu, 'workbench.revdetails.custom-menu')
1416
1417        menu.setAttribute(Qt.WA_DeleteOnClose)
1418        menu.popup(point)
1419
1420    def _popupPairSelectionMenu(self, point):
1421        menu = QMenu(self)
1422
1423        self._addNamedActionsToMenu(menu, [
1424            'Repository.visualDiffRevisionsPair',
1425            'Repository.exportDiff',
1426            None,
1427            'Repository.exportRevisions',
1428            'Repository.emailRevisions',
1429            'Repository.copyPatch',
1430            None,
1431            'Repository.archiveDagRangeRevisions',
1432            'Repository.exportDagRangeRevisions',
1433            'Repository.emailDagRangeRevisions',
1434            'Repository.bundleDagRangeRevisions',
1435            None,
1436            'Repository.bisectGoodBadRevisionsPair',
1437            'Repository.bisectBadGoodRevisionsPair',
1438            'Repository.compressRevisionsPair',
1439            'Repository.rebaseSourceDestRevisionsPair',
1440            None,
1441            'RepoView.goToCommonAncestor',
1442            'RepoView.filterByRevisionsMenu',
1443            None,
1444            'Repository.graftRevisions',
1445            None,
1446            'Repository.pruneRevisions',
1447            None,
1448            'Repository.sendToReviewBoard',
1449            None,
1450            'Repository.sendToPhabricator',
1451        ])
1452
1453        self._addCustomToolsSubMenu(menu, 'workbench.pairselection.custom-menu')
1454
1455        menu.setAttribute(Qt.WA_DeleteOnClose)
1456        menu.popup(point)
1457
1458    def _popupMultipleSelectionMenu(self, point):
1459        menu = QMenu(self)
1460
1461        self._addNamedActionsToMenu(menu, [
1462            'Repository.exportRevisions',
1463            'Repository.emailRevisions',
1464            'Repository.copyPatch',
1465            None,
1466            'RepoView.goToCommonAncestor',
1467            'RepoView.filterByRevisionsMenu',
1468            None,
1469            'Repository.graftRevisions',
1470            None,
1471            'Repository.pruneRevisions',
1472            'Repository.sendToReviewBoard',
1473            'Repository.sendToPhabricator',
1474        ])
1475
1476        self._addCustomToolsSubMenu(menu,
1477                                    'workbench.multipleselection.custom-menu')
1478
1479        menu.setAttribute(Qt.WA_DeleteOnClose)
1480        menu.popup(point)
1481
1482    def _popupIncomingBundleMenu(self, point):
1483        menu = QMenu(self)
1484
1485        self._addNamedActionsToMenu(menu, [
1486            'Repository.pullToRevision',
1487            'Repository.visualDiff',
1488        ])
1489
1490        menu.setAttribute(Qt.WA_DeleteOnClose)
1491        menu.popup(point)
1492
1493    def _popupUnappliedPatchMenu(self, point):
1494        menu = QMenu(self)
1495
1496        self._addNamedActionsToMenu(menu, [
1497            'PatchQueue.pushPatch',
1498            'PatchQueue.pushExactPatch',
1499            'PatchQueue.pushMovePatch',
1500            'PatchQueue.foldPatches',
1501            'PatchQueue.deletePatches',
1502            'PatchQueue.renamePatch',
1503            None,
1504            'PatchQueue.launchOptionsDialog',
1505        ])
1506
1507        menu.setAttribute(Qt.WA_DeleteOnClose)
1508        menu.popup(point)
1509
1510    def _createNamedAction(self, name, attrs, exts=None, icon=None, cb=None):
1511        # type: (Text, Set[Text], Optional[Set[Text]], Optional[Text], Optional[Callable]) -> QAction
1512        act = QAction(self)
1513        act.setShortcutContext(Qt.WidgetWithChildrenShortcut)
1514        if icon:
1515            act.setIcon(qtlib.geticon(icon))
1516        if cb:
1517            act.triggered.connect(cb)
1518        self._addNamedAction(name, act, attrs, exts)
1519        return act
1520
1521    def _addNamedAction(self, name, act, attrs, exts=None):
1522        # type: (Text, QAction, Set[Text], Optional[Set[Text]]) -> None
1523        assert name not in self._actions, name
1524        assert attrs.issubset(_KNOWN_SELECTION_ATTRS), attrs
1525        # RepoWidget actions act on revisions selected in the graph view, so
1526        # the shortcuts should not be enabled for task tabs.
1527        self.repoview.addAction(act)
1528        self._actionregistry.registerAction(name, act)
1529        self._actions[name] = (act, attrs, exts or set())
1530
1531    def _addNamedActionsToMenu(self, menu, names):
1532        # type: (QMenu, List[Optional[Text]]) -> None
1533        for n in names:
1534            if n:
1535                menu.addAction(self._actions[n][0])
1536            else:
1537                menu.addSeparator()
1538
1539    def _updateNamedActions(self):
1540        selattrs = self._selectionAttributes()
1541        enabledexts = set(map(pycompat.sysstr, self.repo.extensions()))
1542
1543        for act, attrs, exts in self._actions.values():
1544            act.setEnabled(attrs.issubset(selattrs))
1545            act.setVisible(not exts or bool(exts & enabledexts))
1546
1547    def _addCustomToolsSubMenu(self, menu, location):
1548        # type: (QMenu, Text) -> None
1549        tools, toollist = hglib.tortoisehgtools(self.repo.ui,
1550                            selectedlocation=location)
1551
1552        if not tools:
1553            return
1554
1555        selattrs = self._selectionAttributes()
1556
1557        menu.addSeparator()
1558        submenu = menu.addMenu(_('Custom Tools'))
1559        submenu.triggered.connect(self._runCustomCommandByMenu)
1560        for name in toollist:
1561            if name == '|':
1562                submenu.addSeparator()
1563                continue
1564            info = tools.get(name, None)
1565            if info is None:
1566                continue
1567            command = info.get('command', None)
1568            if not command:
1569                continue
1570            workingdir = info.get('workingdir', '')
1571            showoutput = info.get('showoutput', False)
1572            label = info.get('label', name)
1573            icon = info.get('icon', 'tools-spanner-hammer')
1574            enable = info.get('enable', 'istrue').lower()  # pytype: disable=attribute-error
1575            if enable not in _CUSTOM_TOOLS_SELECTION_ATTRS:
1576                continue
1577            a = submenu.addAction(label)
1578            if icon:
1579                a.setIcon(qtlib.geticon(icon))
1580            a.setData((command, showoutput, workingdir))
1581            a.setEnabled(enable in selattrs)
1582
1583    def _setUpNamedActions(self):
1584        entry = self._createNamedAction
1585
1586        SINGLE = _SELECTION_SINGLE
1587        PAIR = _SELECTION_PAIR
1588        SOME = _SELECTION_SOME
1589
1590        INCOMING = _SELECTION_INCOMING
1591        OUTGOING = _SELECTION_OUTGOING
1592
1593        ISREV = _SELECTION_ISREV
1594        ISCTX = _SELECTION_ISCTX
1595        ISPATCH = _SELECTION_ISPATCH
1596        FIXED = _SELECTION_FIXED
1597        APPLIED = _SELECTION_APPLIED
1598        UNAPPLIED = _SELECTION_UNAPPLIED
1599        QFOLD = _SELECTION_QFOLD
1600        QPUSH = _SELECTION_QPUSH
1601        QPUSHMOVE = _SELECTION_QPUSHMOVE
1602        ISDRAFTORWD = _SELECTION_ISDRAFTORWD
1603
1604        entry('Repository.pullToRevision', {SINGLE, INCOMING, ISREV}, None,
1605              'hg-pull-to-here', self.pullBundleToRev)
1606
1607        pushtypeicon = {'all': None, 'branch': None, 'revision': None}
1608        defaultpush = self._repoagent.configString('tortoisehg', 'defaultpush')
1609        pushtypeicon[defaultpush] = 'hg-push'
1610        entry('Repository.pushToRevision', {SINGLE, OUTGOING, ISREV}, None,
1611              pushtypeicon['revision'], self.pushToRevision)
1612        entry('Repository.pushBranch', {SINGLE, OUTGOING, ISREV}, None,
1613              pushtypeicon['branch'], self.pushBranch)
1614        entry('Repository.pushAll', {SINGLE, OUTGOING, ISREV}, None,
1615              pushtypeicon['all'], self.pushAll)
1616
1617        # TODO: unify to Repository.update action of Workbench?
1618        entry('Repository.updateToRevision', {SINGLE, ISREV}, None,
1619              'hg-update', self.updateToRevision)
1620
1621        entry('Repository.visualDiff', {SINGLE, ISCTX}, None,
1622              'visualdiff', self.visualDiffRevision)
1623        entry('Repository.visualDiffToLocal', {SINGLE, ISREV}, None,
1624              'ldiff', self.visualDiffToLocal)
1625        # TODO: visdiff can't handle wdir dest
1626        entry('Repository.visualDiffRevisionsPair', {PAIR, ISREV}, None,
1627              'visualdiff', self.visualDiffRevisionsPair)
1628
1629        entry('Repository.browseRevision', {SINGLE, ISCTX}, None,
1630              'hg-annotate', self.manifestRevision)
1631
1632        self._addNamedAction('RepoView.filterByRevisionsMenu',
1633                             self._createFilterBySelectedRevisionsMenu(),
1634                             {SOME, ISREV})
1635
1636        entry('Repository.mergeWithRevision', {SINGLE, FIXED}, None,
1637              'hg-merge', self.mergeWithRevision)
1638        entry('Repository.closeRevision', {SINGLE, ISREV}, {'closehead'},
1639              'hg-close-head', self.closeRevision)
1640
1641        entry('Repository.tagRevision', {SINGLE, FIXED}, None,
1642              'hg-tag', self.tagToRevision)
1643        entry('Repository.bookmarkRevision', {SINGLE, ISREV}, None,
1644              'hg-bookmarks', self.bookmarkRevision)
1645        entry('Repository.topicRevision', {SINGLE, ISDRAFTORWD}, {'topic'},
1646              'topic', self.topicRevision)
1647        entry('Repository.signRevision', {SINGLE, FIXED}, {'gpg'},
1648              'hg-sign', self.signRevision)
1649
1650        entry('Repository.backoutToRevision', {SINGLE, FIXED}, None,
1651              'hg-revert', self.backoutToRevision)
1652        entry('Repository.revertToRevision', {SINGLE, ISCTX}, None,
1653              'hg-revert', self.revertToRevision)
1654
1655        entry('Repository.copyHash', {SINGLE, ISREV}, None,
1656              'copy-hash', self.copyHash)
1657        entry('Repository.copyShortHash', {SINGLE, ISREV}, None,
1658              None, self.copyShortHash)
1659        entry('Repository.copyGitHash', {SINGLE, ISREV}, {'hggit'}, None,
1660              self.copyGitHash)
1661        entry('Repository.copyShortGitHash', {SINGLE, ISREV}, {'hggit'}, None,
1662              self.copyShortGitHash)
1663
1664        entry('Repository.exportDiff', {PAIR, ISCTX}, None,
1665              'hg-export', self.exportDiff)
1666        entry('Repository.exportRevisions', {SOME, ISREV}, None,
1667              'hg-export', self.exportSelectedRevisions)
1668        entry('Repository.exportDagRangeRevisions', {PAIR, ISREV}, None,
1669              'hg-export', self.exportDagRangeRevisions)
1670        entry('Repository.emailRevisions', {SOME, ISREV}, None,
1671              'mail-forward', self.emailSelectedRevisions)
1672        entry('Repository.emailDagRangeRevisions', {PAIR, ISREV}, None,
1673              'mail-forward', self.emailDagRangeRevisions)
1674        entry('Repository.archiveRevision', {SINGLE, ISREV}, None,
1675              'hg-archive', self.archiveRevision)
1676        entry('Repository.archiveDagRangeRevisions', {PAIR, ISREV}, None,
1677              'hg-archive', self.archiveDagRangeRevisions)
1678        entry('Repository.bundleRevisions', {SINGLE, ISREV}, None,
1679              'hg-bundle', self.bundleRevisions)
1680        entry('Repository.bundleDagRangeRevisions', {PAIR, ISREV}, None,
1681              'hg-bundle', self.bundleDagRangeRevisions)
1682        entry('Repository.copyPatch', {SOME, ISCTX}, None,
1683              'copy-patch', self.copyPatch)
1684
1685        entry('Repository.bisectGoodBadRevisionsPair', {PAIR, ISREV}, None,
1686              'hg-bisect-good-bad', self.bisectGoodBadRevisionsPair)
1687        entry('Repository.bisectBadGoodRevisionsPair', {PAIR, ISREV}, None,
1688              'hg-bisect-bad-good', self.bisectBadGoodRevisionsPair)
1689
1690        entry('RepoView.goToCommonAncestor', {SOME, ISCTX}, None,
1691              'hg-merge', self._gotoAncestor)
1692
1693        submenu = QMenu(self)
1694        submenu.triggered.connect(self._changePhaseByMenu)
1695        # TODO: filter out hidden names better
1696        for pnum, pname in enumerate(phases.cmdphasenames):
1697            a = submenu.addAction(pycompat.sysstr(pname))
1698            a.setData(pnum)
1699        self._addNamedAction('RepoView.changePhaseMenu', submenu.menuAction(),
1700                             {SINGLE, ISREV})
1701
1702        entry('Repository.compressRevisionsPair', {PAIR, ISREV}, None,
1703              'hg-compress', self.compressRevisionsPair)
1704        entry('Repository.graftRevisions', {SOME, ISREV}, None,
1705              'hg-transplant', self.graftRevisions)
1706
1707        entry('PatchQueue.popPatch', {SINGLE, APPLIED}, {'mq'},
1708              'hg-qgoto', self.qgotoParentRevision)
1709        entry('PatchQueue.importRevision', {SINGLE, FIXED}, {'mq'},
1710              'qimport', self.qimportRevision)
1711        entry('PatchQueue.finishRevision', {SINGLE, APPLIED}, {'mq'},
1712              'qfinish', self.qfinishRevision)
1713        entry('PatchQueue.renamePatch', {SINGLE, ISPATCH}, {'mq'},
1714              None, self.qrename)
1715
1716        entry('PatchQueue.pushPatch', {SINGLE, QPUSH}, {'mq'},
1717              'hg-qpush', self.qpushRevision)
1718        entry('PatchQueue.pushExactPatch', {SINGLE, QPUSH}, {'mq'},
1719              None, self.qpushExactRevision)
1720        entry('PatchQueue.pushMovePatch', {SINGLE, QPUSHMOVE}, {'mq'},
1721              None, self.qpushMoveRevision)
1722        entry('PatchQueue.foldPatches', {SOME, QFOLD}, {'mq'},
1723              'hg-qfold', self.qfoldPatches)
1724        entry('PatchQueue.deletePatches', {SOME, UNAPPLIED}, {'mq'},
1725              'hg-qdelete', self.qdeletePatches)
1726
1727        a = entry('PatchQueue.launchOptionsDialog', set(), {'mq'})
1728        if self._mqActions:
1729            a.triggered.connect(self._mqActions.launchOptionsDialog)
1730
1731        entry('Repository.pickRevision', {SINGLE, ISREV}, {'evolve'},
1732              None, self._pickRevision)
1733        entry('Repository.rebaseRevision', {SINGLE, ISREV}, {'rebase'},
1734              'hg-rebase', self.rebaseRevision)
1735        entry('Repository.rebaseSourceDestRevisionsPair', {PAIR, ISREV},
1736              {'rebase'}, 'hg-rebase', self.rebaseSourceDestRevisionsPair)
1737
1738        entry('Repository.pruneRevisions', {SOME, FIXED}, {'evolve'},
1739              'edit-cut', self._pruneSelected)
1740        entry('Repository.stripRevision', {SINGLE, FIXED}, {'mq', 'strip'},
1741              'hg-strip', self.stripRevision)
1742
1743        entry('Repository.sendToReviewBoard', {SOME, ISREV}, {'reviewboard'},
1744              'reviewboard', self.sendToReviewBoard)
1745        entry('Repository.sendToPhabricator', {SOME, ISREV}, {'phabricator'},
1746              'phabricator', self.sendToPhabricator)
1747
1748    @pyqtSlot()
1749    def exportDiff(self):
1750        rev0, rev1 = self._selectedIntRevisionsPair()
1751        root = self.repo.root
1752        filename = b'%s_%d_to_%d.diff' % (os.path.basename(root), rev0, rev1)
1753        file, _filter = QFileDialog.getSaveFileName(
1754            self, _('Write diff file'),
1755            hglib.tounicode(os.path.join(root, filename)))
1756        if not file:
1757            return
1758        f = QFile(file)
1759        if not f.open(QIODevice.WriteOnly | QIODevice.Truncate):
1760            WarningMsgBox(_('Repository Error'),
1761                          _('Unable to write diff file'))
1762            return
1763        cmdline = hglib.buildcmdargs('diff', rev=[rev0, rev1])
1764        sess = self._runCommand(cmdline)
1765        sess.setOutputDevice(f)
1766
1767    @pyqtSlot()
1768    def exportSelectedRevisions(self):
1769        self._exportRevisions(self.repoview.selectedRevisions())
1770
1771    @pyqtSlot()
1772    def exportDagRangeRevisions(self):
1773        l = self._selectedDagRangeRevisions()
1774        if l:
1775            self._exportRevisions(l)
1776
1777    def _exportRevisions(self, revisions):
1778        if not revisions:
1779            return
1780        if len(revisions) == 1:
1781            if isinstance(self.rev, int):
1782                defaultpath = os.path.join(self.repoRootPath(),
1783                                           '%d.patch' % self.rev)
1784            else:
1785                defaultpath = self.repoRootPath()
1786
1787            ret, _filter = QFileDialog.getSaveFileName(
1788                self, _('Export patch'), defaultpath,
1789                _('Patch Files (*.patch)'))
1790            if not ret:
1791                return
1792            epath = pycompat.unicode(ret)
1793            udir = os.path.dirname(epath)
1794            custompath = True
1795        else:
1796            udir = QFileDialog.getExistingDirectory(self, _('Export patch'),
1797                                                   hglib.tounicode(self.repo.root))
1798            if not udir:
1799                return
1800            udir = pycompat.unicode(udir)
1801            ename = self._repoagent.shortName() + '_%r.patch'
1802            epath = os.path.join(udir, ename)
1803            custompath = False
1804
1805        cmdline = hglib.buildcmdargs('export', verbose=True, output=epath,
1806                                     rev=hglib.compactrevs(sorted(revisions)))
1807
1808        existingRevisions = []
1809        for rev in revisions:
1810            if custompath:
1811                path = epath
1812            else:
1813                path = epath % rev
1814            if os.path.exists(path):
1815                if os.path.isfile(path):
1816                    existingRevisions.append(rev)
1817                else:
1818                    QMessageBox.warning(self,
1819                        _('Cannot export revision'),
1820                        (_('Cannot export revision %s into the file named:'
1821                        '\n\n%s\n') % (rev, epath % rev)) + \
1822                        _('There is already an existing folder '
1823                        'with that same name.'))
1824                    return
1825
1826        if existingRevisions:
1827            buttonNames = [_("Replace"), _("Append"), _("Abort")]
1828
1829            warningMessage = \
1830                _('There are existing patch files for %d revisions (%s) '
1831                'in the selected location (%s).\n\n') \
1832                % (len(existingRevisions),
1833                    " ,".join([str(rev) for rev in existingRevisions]),
1834                    udir)
1835
1836            warningMessage += \
1837                _('What do you want to do?\n') + u'\n' + \
1838                u'- ' + _('Replace the existing patch files.\n') + \
1839                u'- ' + _('Append the changes to the existing patch files.\n') + \
1840                u'- ' + _('Abort the export operation.\n')
1841
1842            res = qtlib.CustomPrompt(_('Patch files already exist'),
1843                warningMessage,
1844                self,
1845                buttonNames, 0, 2).run()
1846
1847            if buttonNames[res] == _("Replace"):
1848                # Remove the existing patch files
1849                for rev in existingRevisions:
1850                    if custompath:
1851                        os.remove(epath)
1852                    else:
1853                        os.remove(epath % rev)
1854            elif buttonNames[res] == _("Abort"):
1855                return
1856
1857        self._runCommand(cmdline)
1858
1859        if len(revisions) == 1:
1860            # Show a message box with a link to the export folder and to the
1861            # exported file
1862            rev = revisions[0]
1863            patchfilename = os.path.normpath(epath)
1864            patchdirname = os.path.normpath(os.path.dirname(epath))
1865            patchshortname = os.path.basename(patchfilename)
1866            if patchdirname.endswith(os.path.sep):
1867                patchdirname = patchdirname[:-1]
1868            qtlib.InfoMsgBox(_('Patch exported'),
1869                _('Revision #%d (%s) was exported to:<p>'
1870                '<a href="file:///%s">%s</a>%s'
1871                '<a href="file:///%s">%s</a>') \
1872                % (rev, str(self.repo[rev]),
1873                   patchdirname, patchdirname, os.path.sep,
1874                   patchfilename, patchshortname))
1875        else:
1876            # Show a message box with a link to the export folder
1877            qtlib.InfoMsgBox(_('Patches exported'),
1878                _('%d patches were exported to:<p>'
1879                '<a href="file:///%s">%s</a>') \
1880                % (len(revisions), udir, udir))
1881
1882    def visualDiffRevision(self):
1883        opts = dict(change=self.rev)
1884        dlg = visdiff.visualdiff(self.repo.ui, self.repo, [], opts)
1885        if dlg:
1886            dlg.exec_()
1887
1888    def visualDiffToLocal(self):
1889        if self.rev is None:
1890            return
1891        opts = dict(rev=['rev(%d)' % self.rev])
1892        dlg = visdiff.visualdiff(self.repo.ui, self.repo, [], opts)
1893        if dlg:
1894            dlg.exec_()
1895
1896    @pyqtSlot()
1897    def visualDiffRevisionsPair(self):
1898        revA, revB = self._selectedIntRevisionsPair()
1899        dlg = visdiff.visualdiff(self.repo.ui, self.repo, [],
1900                                 {'rev': (str(revA), str(revB))})
1901        if dlg:
1902            dlg.exec_()
1903
1904    @pyqtSlot()
1905    def updateToRevision(self):
1906        rev = None
1907        if isinstance(self.rev, int):
1908            rev = hglib.getrevisionlabel(self.repo, self.rev)
1909        dlg = update.UpdateDialog(self._repoagent, rev, self)
1910        r = dlg.exec_()
1911        if r in (0, 1):
1912            self.gotoParent()
1913
1914    @pyqtSlot()
1915    def lockTool(self):
1916        from .locktool import LockDialog
1917        dlg = LockDialog(self._repoagent, self)
1918        if dlg:
1919            dlg.exec_()
1920
1921    @pyqtSlot()
1922    def revertToRevision(self):
1923        if not qtlib.QuestionMsgBox(
1924                _('Confirm Revert'),
1925                _('Reverting all files will discard changes and '
1926                  'leave affected files in a modified state.<br>'
1927                  '<br>Are you sure you want to use revert?<br><br>'
1928                  '(use update to checkout another revision)'),
1929                parent=self):
1930            return
1931        cmdline = hglib.buildcmdargs('revert', all=True, rev=self.rev)
1932        sess = self._runCommand(cmdline)
1933        sess.commandFinished.connect(self._refreshCommitTabIfNeeded)
1934
1935    def _createFilterBySelectedRevisionsMenu(self):
1936        menu = QMenu(_('Filter b&y'), self)
1937        menu.setIcon(qtlib.geticon('view-filter'))
1938        menu.triggered.connect(self._filterBySelectedRevisions)
1939        for t, r in [(_('&Ancestors and Descendants'),
1940                      "ancestors({revs}) or descendants({revs})"),
1941                     (_('A&uthor'), "matching({revs}, 'author')"),
1942                     (_('&Branch'), "branch({revs})"),
1943                     ]:
1944            a = menu.addAction(t)
1945            a.setData(r)
1946        menu.addSeparator()
1947        menu.addAction(_('&More Options...'))
1948        return menu.menuAction()
1949
1950    @pyqtSlot(QAction)
1951    def _filterBySelectedRevisions(self, action):
1952        revs = hglib.compactrevs(sorted(self.repoview.selectedRevisions()))
1953        expr = action.data()
1954        if not expr:
1955            self._filterByMatchDialog(revs)
1956            return
1957        self.setFilter(expr.format(revs=revs))
1958
1959    def _filterByMatchDialog(self, revlist):
1960        dlg = matching.MatchDialog(self._repoagent, revlist, self)
1961        if dlg.exec_():
1962            self.setFilter(dlg.revsetexpression)
1963
1964    def pushAll(self):
1965        self.syncDemand.forward('push', False, pushall=True)
1966
1967    def pushToRevision(self):
1968        # Do not ask for confirmation
1969        self.syncDemand.forward('push', False, rev=self.rev)
1970
1971    def pushBranch(self):
1972        # Do not ask for confirmation
1973        self.syncDemand.forward('push', False,
1974            branch=self.repo[self.rev].branch())
1975
1976    def manifestRevision(self):
1977        if QApplication.keyboardModifiers() & Qt.ShiftModifier:
1978            self._dialogs.openNew(RepoWidget._createManifestDialog)
1979        else:
1980            dlg = self._dialogs.open(RepoWidget._createManifestDialog)
1981            dlg.setRev(self.rev)
1982
1983    def _createManifestDialog(self):
1984        return revdetails.createManifestDialog(self._repoagent, self.rev)
1985
1986    def mergeWithOtherHead(self):
1987        """Open dialog to merge with the other head of the current branch"""
1988        cmdline = hglib.buildcmdargs('merge', preview=True,
1989                                     config=r'ui.logtemplate={rev}\n')
1990        sess = self._runCommand(cmdline)
1991        sess.setCaptureOutput(True)
1992        sess.commandFinished.connect(self._onMergePreviewFinished)
1993
1994    @pyqtSlot(int)
1995    def _onMergePreviewFinished(self, ret):
1996        sess = self.sender()
1997        if ret == 255 and 'hg heads' in sess.errorString():
1998            # multiple heads
1999            self.filterbar.setQuery('head() - .')
2000            self.filterbar.runQuery()
2001            msg = '\n'.join(sess.errorString().splitlines()[:-1])  # drop hint
2002            w = self.setInfoBar(infobar.ConfirmInfoBar, msg)
2003            assert w
2004            w.acceptButton.setText(_('Merge'))
2005            w.accepted.connect(self.mergeWithRevision)
2006            w.finished.connect(self.clearRevisionSet)
2007            return
2008        if ret != 0:
2009            return
2010        revs = pycompat.maplist(int, bytes(sess.readAll()).splitlines())
2011        if not revs:
2012            return
2013        self._dialogs.open(RepoWidget._createMergeDialog, revs[-1])
2014
2015    @pyqtSlot()
2016    def mergeWithRevision(self):
2017        # Don't use self.rev (i.e. the current revision.) This is a context
2018        # menu handler, and the menu is open for the selected rows, not for
2019        # the current row.
2020        revisions = self.repoview.selectedRevisions()
2021        if len(revisions) != 1:
2022            QMessageBox.warning(self, _('Unable to merge'),
2023                                _('Please select a revision to merge.'))
2024            return
2025        rev = revisions[0]
2026        if not isinstance(rev, int):
2027            QMessageBox.warning(self, _('Unable to merge'),
2028                                _('Cannot merge with a pseudo revision %r.')
2029                                % rev)
2030            return
2031        pctx = self.repo[b'.']
2032        octx = self.repo[rev]
2033        if pctx == octx:
2034            QMessageBox.warning(self, _('Unable to merge'),
2035                _('You cannot merge a revision with itself'))
2036            return
2037        self._dialogs.open(RepoWidget._createMergeDialog, rev)
2038
2039    def _createMergeDialog(self, rev):
2040        return merge.MergeDialog(self._repoagent, rev, self)
2041
2042    def tagToRevision(self):
2043        dlg = tag.TagDialog(self._repoagent, rev=str(self.rev), parent=self)
2044        dlg.exec_()
2045
2046    def closeRevision(self):
2047        dlg = close_branch.createCloseBranchDialog(self._repoagent, self.rev,
2048                                                   parent=self)
2049        dlg.exec_()
2050
2051    def bookmarkRevision(self):
2052        dlg = bookmark.BookmarkDialog(self._repoagent, self.rev, self)
2053        dlg.exec_()
2054
2055    def topicRevision(self):
2056        dlg = topic.TopicDialog(self._repoagent, self.rev, self)
2057        dlg.exec_()
2058
2059    def signRevision(self):
2060        dlg = sign.SignDialog(self._repoagent, self.rev, self)
2061        dlg.exec_()
2062
2063    def graftRevisions(self):
2064        """Graft selected revision on top of working directory parent"""
2065        revlist = []
2066        for rev in sorted(self.repoview.selectedRevisions()):
2067            revlist.append(str(rev))
2068        if not revlist:
2069            revlist = [self.rev]
2070        dlg = graft.GraftDialog(self._repoagent, self, source=revlist)
2071        if dlg.valid:
2072            dlg.exec_()
2073
2074    def backoutToRevision(self):
2075        msg = backout.checkrev(self._repoagent.rawRepo(), self.rev)
2076        if msg:
2077            qtlib.InfoMsgBox(_('Unable to backout'), msg, parent=self)
2078            return
2079        dlg = backout.BackoutDialog(self._repoagent, self.rev, self)
2080        dlg.finished.connect(dlg.deleteLater)
2081        dlg.exec_()
2082
2083    @pyqtSlot()
2084    def _pruneSelected(self):
2085        revspec = hglib.compactrevs(sorted(self.repoview.selectedRevisions()))
2086        dlg = prune.createPruneDialog(self._repoagent, revspec, self)
2087        dlg.exec_()
2088
2089    def stripRevision(self):
2090        'Strip the selected revision and all descendants'
2091        dlg = thgstrip.createStripDialog(self._repoagent, rev=str(self.rev),
2092                                         parent=self)
2093        dlg.exec_()
2094
2095    def sendToReviewBoard(self):
2096        self._dialogs.open(RepoWidget._createPostReviewDialog,
2097                           tuple(self.repoview.selectedRevisions()))
2098
2099    def _createPostReviewDialog(self, revs):
2100        # type: (Sequence[int]) -> postreview.PostReviewDialog
2101        return postreview.PostReviewDialog(self.repo.ui, self._repoagent, revs)
2102
2103    @pyqtSlot()
2104    def sendToPhabricator(self):
2105        self._dialogs.open(RepoWidget._createPhabReviewDialog,
2106                           tuple(self.repoview.selectedRevisions()))
2107
2108    def _createPhabReviewDialog(self, revs):
2109        return phabreview.PhabReviewDialog(self._repoagent, revs)
2110
2111    @pyqtSlot()
2112    def emailSelectedRevisions(self):
2113        self._emailRevisions(self.repoview.selectedRevisions())
2114
2115    @pyqtSlot()
2116    def emailDagRangeRevisions(self):
2117        l = self._selectedDagRangeRevisions()
2118        if l:
2119            self._emailRevisions(l)
2120
2121    def _emailRevisions(self, revs):
2122        self._dialogs.open(RepoWidget._createEmailDialog, tuple(revs))
2123
2124    def _createEmailDialog(self, revs):
2125        return hgemail.EmailDialog(self._repoagent, revs)
2126
2127    def archiveRevision(self):
2128        rev = hglib.getrevisionlabel(self.repo, self.rev)
2129        dlg = archive.createArchiveDialog(self._repoagent, rev, self)
2130        dlg.exec_()
2131
2132    @pyqtSlot()
2133    def archiveDagRangeRevisions(self):
2134        l = self._selectedDagRangeRevisions()
2135        if l:
2136            self.archiveRevisions(l)
2137
2138    def archiveRevisions(self, revs):
2139        rev = hglib.getrevisionlabel(self.repo, max(revs))
2140        minrev = '%d' % min(revs)
2141        dlg = archive.createArchiveDialog(self._repoagent, rev=rev, minrev=minrev,
2142                                          parent=self)
2143        dlg.exec_()
2144
2145    @pyqtSlot()
2146    def bundleDagRangeRevisions(self):
2147        l = self._selectedDagRangeRevisions()
2148        if l:
2149            self.bundleRevisions(base=l[0], tip=l[-1])
2150
2151    def bundleRevisions(self, base=None, tip=None):
2152        root = self.repoRootPath()
2153        if base is None or base is False:
2154            base = self.rev
2155        data = dict(name=os.path.basename(root), base=base)
2156        if tip is None:
2157            filename = '%(name)s_%(base)s_and_descendants.hg' % data
2158        else:
2159            data.update(rev=tip)
2160            filename = '%(name)s_%(base)s_to_%(rev)s.hg' % data
2161
2162        file, _filter = QFileDialog.getSaveFileName(
2163            self, _('Write bundle'), os.path.join(root, filename))
2164        if not file:
2165            return
2166
2167        cmdline = ['bundle', '--verbose']
2168        parents = [hglib.escaperev(r.rev()) for r in self.repo[base].parents()]
2169        for p in parents:
2170            cmdline.extend(['--base', p])
2171        if tip:
2172            cmdline.extend(['--rev', str(tip)])
2173        else:
2174            cmdline.extend(['--rev', 'heads(descendants(%s))' % base])
2175        cmdline.append(pycompat.unicode(file))
2176        self._runCommand(cmdline)
2177
2178    @pyqtSlot()
2179    def copyPatch(self):
2180        # patches should be in chronological order
2181        revs = sorted(self._selectedIntRevisions())
2182        cmdline = hglib.buildcmdargs('export', rev=hglib.compactrevs(revs))
2183        sess = self._runCommand(cmdline)
2184        sess.setCaptureOutput(True)
2185        sess.commandFinished.connect(self._copyPatchOutputToClipboard)
2186
2187    @pyqtSlot(int)
2188    def _copyPatchOutputToClipboard(self, ret):
2189        if ret == 0:
2190            sess = self.sender()
2191            output = sess.readAll()
2192            mdata = QMimeData()
2193            mdata.setData('text/x-diff', output)  # for lossless import
2194            mdata.setText(hglib.tounicode(bytes(output)))
2195            QApplication.clipboard().setMimeData(mdata)
2196
2197    def copyHash(self):
2198        clip = QApplication.clipboard()
2199        clip.setText(
2200            hglib.tounicode(binascii.hexlify(self.repo[self.rev].node())))
2201
2202    def copyShortHash(self):
2203        clip = QApplication.clipboard()
2204        clip.setText(
2205            hglib.tounicode(nodemod.short(self.repo[self.rev].node())))
2206
2207    @pyqtSlot()
2208    def copyGitHash(self):
2209        fullGitHash = hglib.gitcommit_full(self.repo[self.rev])
2210        if fullGitHash is None:
2211            return
2212        clip = QApplication.clipboard()
2213        clip.setText(fullGitHash)
2214
2215    @pyqtSlot()
2216    def copyShortGitHash(self):
2217        shortGitHash = hglib.gitcommit_short(self.repo[self.rev])
2218        if shortGitHash is None:
2219            return
2220        clip = QApplication.clipboard()
2221        clip.setText(shortGitHash)
2222
2223    def changePhase(self, phase):
2224        currentphase = self.repo[self.rev].phase()
2225        if currentphase == phase:
2226            # There is nothing to do, we are already in the target phase
2227            return
2228        phasestr = pycompat.sysstr(phases.phasenames[phase])
2229        cmdline = ['phase', '--rev', '%s' % self.rev, '--%s' % phasestr]
2230        if currentphase < phase:
2231            # Ask the user if he wants to force the transition
2232            title = _('Backwards phase change requested')
2233            if currentphase == phases.draft and phase == phases.secret:
2234                # Here we are sure that the current phase is draft and the target phase is secret
2235                # Nevertheless we will not hard-code those phase names on the dialog strings to
2236                # make sure that the proper phase name translations are used
2237                main = _('Do you really want to make this revision <i>secret</i>?')
2238                text = _('Making a "<i>draft</i>" revision "<i>secret</i>" '
2239                         'is generally a safe operation.\n\n'
2240                         'However, there are a few caveats:\n\n'
2241                         '- "secret" revisions are not pushed. '
2242                         'This can cause you trouble if you\n'
2243                         'refer to a secret subrepo revision.\n\n'
2244                         '- If you pulled this revision from '
2245                         'a non publishing server it may be\n'
2246                         'moved back to "<i>draft</i>" if you pull '
2247                         'again from that particular server.\n\n'
2248                         'Please be careful!')
2249                labels = ((QMessageBox.Yes, _('&Make secret')),
2250                          (QMessageBox.No, _('&Cancel')))
2251            else:
2252                currentphasestr = pycompat.sysstr(
2253                    phases.phasenames[currentphase])
2254                main = _('Do you really want to <i>force</i> a backwards phase transition?')
2255                text = _('You are trying to move the phase of revision %d backwards,\n'
2256                         'from "<i>%s</i>" to "<i>%s</i>".\n\n'
2257                         'However, "<i>%s</i>" is a lower phase level than "<i>%s</i>".\n\n'
2258                         'Moving the phase backwards is not recommended.\n'
2259                         'For example, it may result in having multiple heads\nif you '
2260                         'modify a revision that you have already pushed\nto a server.\n\n'
2261                         'Please be careful!') % (self.rev, currentphasestr,
2262                                                  phasestr, phasestr,
2263                                                  currentphasestr)
2264                labels = ((QMessageBox.Yes, _('&Force')),
2265                          (QMessageBox.No, _('&Cancel')))
2266            if not qtlib.QuestionMsgBox(title, main, text,
2267                    labels=labels, parent=self):
2268                return
2269            cmdline.append('--force')
2270        self._runCommand(cmdline)
2271
2272    @pyqtSlot(QAction)
2273    def _changePhaseByMenu(self, action):
2274        phasenum = action.data()
2275        self.changePhase(phasenum)
2276
2277    @pyqtSlot()
2278    def compressRevisionsPair(self):
2279        reva, revb = self._selectedIntRevisionsPair()
2280        ctxa, ctxb = map(self.repo.hgchangectx, [reva, revb])
2281        if ctxa.ancestor(ctxb).rev() == ctxb.rev():
2282            revs = [reva, revb]
2283        elif ctxa.ancestor(ctxb).rev() == ctxa.rev():
2284            revs = [revb, reva]
2285        else:
2286            InfoMsgBox(_('Unable to compress history'),
2287                       _('Selected changeset pair not related'))
2288            return
2289        dlg = compress.CompressDialog(self._repoagent, revs, self)
2290        dlg.exec_()
2291
2292    def _pickRevision(self):
2293        """Pick selected revision on top of working directory parent"""
2294        opts = {'rev': self.rev}
2295        dlg = pick.PickDialog(self._repoagent, self, **opts)
2296        dlg.exec_()
2297
2298    def rebaseRevision(self):
2299        """Rebase selected revision on top of working directory parent"""
2300        opts = {'source' : self.rev, 'dest': self.repo[b'.'].rev()}
2301        dlg = rebase.RebaseDialog(self._repoagent, self, **opts)
2302        dlg.exec_()
2303
2304    @pyqtSlot()
2305    def rebaseSourceDestRevisionsPair(self):
2306        source, dest = self._selectedIntRevisionsPair()
2307        dlg = rebase.RebaseDialog(self._repoagent, self,
2308                                  source=source, dest=dest)
2309        dlg.exec_()
2310
2311    def qimportRevision(self):
2312        """QImport revision and all descendents to MQ"""
2313        if b'qparent' in self.repo.tags():
2314            endrev = b'qparent'
2315        else:
2316            endrev = b''
2317
2318        # Check whether there are existing patches in the MQ queue whose name
2319        # collides with the revisions that are going to be imported
2320        revList = self.repo.revs(b'%s::%s and not hidden()' %
2321                                 (hglib.fromunicode(str(self.rev)), endrev))
2322
2323        if endrev and not revList:
2324            # There is a qparent but the revision list is empty
2325            # This means that the qparent is not a descendant of the
2326            # selected revision
2327            QMessageBox.warning(self, _('Cannot import selected revision'),
2328                _('The selected revision (rev #%d) cannot be imported '
2329                'because it is not a descendant of ''qparent'' (rev #%d)') \
2330                % (self.rev, hglib.revsymbol(self.repo, b'qparent').rev()))
2331            return
2332
2333        patchdir = hglib.tounicode(self.repo.vfs.join(b'patches'))
2334        def patchExists(p):
2335            return os.path.exists(os.path.join(patchdir, p))
2336
2337        # Note that the following two arrays are both ordered by "rev"
2338        defaultPatchNames = ['%d.diff' % rev for rev in revList]
2339        defaultPatchesExist = [patchExists(p) for p in defaultPatchNames]
2340        if any(defaultPatchesExist):
2341            # We will qimport each revision one by one, starting from the newest
2342            # To do so, we will find a valid and unique patch name for each
2343            # revision that we must qimport (i.e. a filename that does not
2344            # already exist)
2345            # and then we will import them one by one starting from the newest
2346            # one, using these unique names
2347            def getUniquePatchName(baseName):
2348                maxRetries = 99
2349                for n in range(1, maxRetries):
2350                    patchName = baseName + '_%02d.diff' % n
2351                    if not patchExists(patchName):
2352                        return patchName
2353                return baseName
2354
2355            patchNames = {}
2356            for n, rev in enumerate(revList):
2357                if defaultPatchesExist[n]:
2358                    patchNames[rev] = getUniquePatchName(str(rev))
2359                else:
2360                    # The default name is safe
2361                    patchNames[rev] = defaultPatchNames[n]
2362
2363            # qimport each revision individually, starting from the topmost one
2364            revList.reverse()
2365            cmdlines = []
2366            for rev in revList:
2367                cmdlines.append(['qimport', '--rev', '%s' % rev,
2368                                 '--name', patchNames[rev]])
2369            self._runCommandSequence(cmdlines)
2370        else:
2371            # There were no collisions with existing patch names, we can
2372            # simply qimport the whole revision set in a single go
2373            cmdline = ['qimport', '--rev',
2374                       '%s::%s' % (self.rev, hglib.tounicode(endrev))]
2375            self._runCommand(cmdline)
2376
2377    def qfinishRevision(self):
2378        """Finish applied patches up to and including selected revision"""
2379        self._mqActions.finishRevision(hglib.tounicode(str(self.rev)))
2380
2381    @pyqtSlot()
2382    def qgotoParentRevision(self):
2383        """Apply an unapplied patch, or qgoto the parent of an applied patch"""
2384        self.qgotoRevision(self.repo[self.rev].p1().rev())
2385
2386    @pyqtSlot()
2387    def qgotoSelectedRevision(self):
2388        self.qgotoRevision(self.rev)
2389
2390    def qgotoRevision(self, rev):
2391        """Make REV the top applied patch"""
2392        mqw = self._mqActions
2393        ctx = self.repo[rev]
2394        if b'qparent' in ctx.tags():
2395            mqw.popAllPatches()
2396        else:
2397            mqw.gotoPatch(hglib.tounicode(ctx.thgmqpatchname()))
2398
2399    @pyqtSlot()
2400    def qdeletePatches(self):
2401        """Delete unapplied patch(es)"""
2402        patches = self._selectedUnappliedPatches()
2403        self._mqActions.deletePatches(patches)
2404
2405    @pyqtSlot()
2406    def qfoldPatches(self):
2407        patches = self._selectedUnappliedPatches()
2408        self._mqActions.foldPatches(patches)
2409
2410    def qrename(self):
2411        patches = self._selectedUnappliedPatches()
2412        revs = self._selectedIntRevisions()
2413        if patches:
2414            pname = patches[0]
2415        elif revs:
2416            pname = hglib.tounicode(self.repo[revs[0]].thgmqpatchname())
2417        else:
2418            return
2419        self._mqActions.renamePatch(pname)
2420
2421    def _qpushRevision(self, move=False, exact=False):
2422        """QPush REV with the selected options"""
2423        ctx = self.repo[self.rev]
2424        patchname = hglib.tounicode(ctx.thgmqpatchname())
2425        self._mqActions.pushPatch(patchname, move=move, exact=exact)
2426
2427    def qpushRevision(self):
2428        """Call qpush with no options"""
2429        self._qpushRevision(move=False, exact=False)
2430
2431    def qpushExactRevision(self):
2432        """Call qpush using the exact flag"""
2433        self._qpushRevision(exact=True)
2434
2435    def qpushMoveRevision(self):
2436        """Make REV the top applied patch"""
2437        self._qpushRevision(move=True)
2438
2439    def runCustomCommand(self, command, showoutput=False, workingdir='',
2440            files=None):
2441        # type: (Text, bool, Text, Optional[List[Text]]) -> Optional[Union[int, subprocess.Popen]]
2442        """Execute 'custom commands', on the selected repository"""
2443        # Perform variable expansion
2444        # This is done in two steps:
2445        # 1. Expand environment variables
2446        if not pycompat.ispy3:
2447            command = hglib.fromunicode(command)
2448        command = os.path.expandvars(command).strip()
2449        if not command:
2450            InfoMsgBox(_('Invalid command'),
2451                       _('The selected command is empty'))
2452            return
2453        if not pycompat.ispy3:
2454            workingdir = hglib.fromunicode(workingdir)
2455        if workingdir:
2456            workingdir = os.path.expandvars(workingdir).strip()
2457
2458        # 2. Expand internal workbench variables
2459        def filelist2str(filelist):
2460            # type: (List[Text]) -> Text
2461            return hglib.tounicode(b' '.join(
2462                procutil.shellquote(
2463                os.path.normpath(self.repo.wjoin(hglib.fromunicode(filename))))
2464                for filename in filelist
2465            ))
2466
2467        if files is None:
2468            files = []
2469
2470        selection = self.repoview.selectedRevisions()
2471
2472        def selectionfiles2str(source):
2473            # type: (Text) -> Text
2474            files = set()
2475            for rev in selection:
2476                files.update(
2477                    hglib.tounicode(f)
2478                    for f in getattr(self.repo[rev], source)()
2479                )
2480            return filelist2str(sorted(files))
2481
2482        vars = {
2483            'ROOT': lambda: hglib.tounicode(self.repo.root),
2484            'REVID': lambda: '+'.join(str(self.repo[rev]) for rev in selection),
2485            'REV': lambda: '+'.join(str(rev) for rev in selection),
2486            'FILES': lambda: selectionfiles2str('files'),
2487            'ALLFILES': lambda: selectionfiles2str('manifest'),
2488            'SELECTEDFILES': lambda: filelist2str(files),
2489        }
2490
2491        if len(selection) == 2:
2492            pairvars = {
2493                'REV_A': lambda: selection[0],
2494                'REV_B': lambda: selection[1],
2495                'REVID_A': lambda: str(self.repo[selection[0]]),
2496                'REVID_B': lambda: str(self.repo[selection[1]]),
2497            }
2498            vars.update(pairvars)
2499
2500        for var in vars:
2501            bracedvar = '{%s}' % var
2502            if bracedvar in command:
2503                command = command.replace(bracedvar, str(vars[var]()))
2504            if workingdir and bracedvar in workingdir:
2505                workingdir = workingdir.replace(bracedvar, str(vars[var]()))
2506        if not workingdir:
2507            workingdir = hglib.tounicode(self.repo.root)
2508
2509        # Show the Output Log if configured to do so
2510        if showoutput:
2511            self.makeLogVisible.emit(True)
2512
2513        # If the user wants to run mercurial,
2514        # do so via our usual runCommand method
2515        cmd = shlex.split(command)
2516        cmdtype = cmd[0].lower()
2517        if cmdtype == 'hg':
2518            sess = self._runCommand(pycompat.maplist(hglib.tounicode, cmd[1:]))
2519            sess.commandFinished.connect(self._notifyWorkingDirChanges)
2520            return
2521        elif cmdtype == 'thg':
2522            cmd = cmd[1:]
2523            if '--repository' in cmd:
2524                _ui = hglib.loadui()
2525            else:
2526                cmd += ['--repository', self.repo.root]
2527                _ui = self.repo.ui.copy()
2528            _ui.ferr = pycompat.bytesio()
2529            # avoid circular import of hgqt.run by importing it inplace
2530            from . import run
2531            cmdb = []
2532            for part in cmd:
2533                if isinstance(part, pycompat.unicode):
2534                    cmdb.append(hglib.fromunicode(part))
2535                else:
2536                    cmdb.append(part)
2537            res = run.dispatch(cmdb, u=_ui)
2538            if res:
2539                errormsg = _ui.ferr.getvalue().strip()
2540                if errormsg:
2541                    errormsg = \
2542                        _('The following error message was returned:'
2543                          '\n\n<b>%s</b>') % hglib.tounicode(errormsg)
2544                errormsg +=\
2545                    _('\n\nPlease check that the "thg" command is valid.')
2546                qtlib.ErrorMsgBox(
2547                    _('Failed to execute custom TortoiseHg command'),
2548                    _('The command "%s" failed (code %d).')
2549                    % (hglib.tounicode(command), res), errormsg)
2550            return res
2551
2552        # Otherwise, run the selected command in the background
2553        try:
2554            res = subprocess.Popen(command, cwd=workingdir, shell=True)
2555        except OSError as ex:
2556            res = 1
2557            qtlib.ErrorMsgBox(_('Failed to execute custom command'),
2558                _('The command "%s" could not be executed.') % hglib.tounicode(command),
2559                _('The following error message was returned:\n\n"%s"\n\n'
2560                'Please check that the command path is valid and '
2561                'that it is a valid application') % hglib.tounicode(ex.strerror))
2562        return res
2563
2564    @pyqtSlot(QAction)
2565    def _runCustomCommandByMenu(self, action):
2566        command, showoutput, workingdir = action.data()
2567        self.runCustomCommand(command, showoutput, workingdir)
2568
2569    @pyqtSlot(str, list)
2570    def handleRunCustomCommandRequest(self, toolname, files):
2571        tools, toollist = hglib.tortoisehgtools(self.repo.ui)
2572        if not tools or toolname not in toollist:
2573            return
2574        toolname = str(toolname)
2575        command = tools[toolname].get('command', '')
2576        showoutput = tools[toolname].get('showoutput', False)
2577        workingdir = tools[toolname].get('workingdir', '')
2578        self.runCustomCommand(command, showoutput, workingdir, files)
2579
2580    def _runCommand(self, cmdline):
2581        sess = self._repoagent.runCommand(cmdline, self)
2582        self._handleNewCommand(sess)
2583        return sess
2584
2585    def _runCommandSequence(self, cmdlines):
2586        sess = self._repoagent.runCommandSequence(cmdlines, self)
2587        self._handleNewCommand(sess)
2588        return sess
2589
2590    def _handleNewCommand(self, sess):
2591        self.clearInfoBar()
2592        sess.outputReceived.connect(self._repoviewFrame.showOutput)
2593
2594    @pyqtSlot()
2595    def _notifyWorkingDirChanges(self):
2596        shlib.shell_notify([self.repo.root])
2597
2598    @pyqtSlot()
2599    def _refreshCommitTabIfNeeded(self):
2600        """Refresh the Commit tab if the user settings require it"""
2601        if self.taskTabsWidget.currentIndex() != self._namedTabs['commit']:
2602            return
2603
2604        refreshwd = self._repoagent.configString(
2605            'tortoisehg', 'refreshwdstatus')
2606        # Valid refreshwd values are 'auto', 'always' and 'alwayslocal'
2607        if refreshwd != 'auto':
2608            if refreshwd == 'always' \
2609                    or paths.is_on_fixed_drive(self.repo.root):
2610                self.commitDemand.forward('refreshWctx')
2611
2612
2613class LightRepoWindow(QMainWindow):
2614    def __init__(self, actionregistry, repoagent):
2615        super(LightRepoWindow, self).__init__()
2616        self._repoagent = repoagent
2617        self.setIconSize(qtlib.smallIconSize())
2618
2619        repo = repoagent.rawRepo()
2620        val = repo.ui.config(b'tortoisehg', b'tasktabs').lower()
2621        if val not in (b'east', b'west'):
2622            repo.ui.setconfig(b'tortoisehg', b'tasktabs', b'east')
2623        rw = RepoWidget(actionregistry, repoagent, self)
2624        self.setCentralWidget(rw)
2625
2626        self._edittbar = tbar = self.addToolBar(_('&Edit Toolbar'))
2627        tbar.setObjectName('edittbar')
2628        a = tbar.addAction(qtlib.geticon('view-refresh'), _('&Refresh'))
2629        a.setShortcuts(QKeySequence.Refresh)
2630        a.triggered.connect(self.refresh)
2631
2632        tbar = rw.filterBar()
2633        tbar.setObjectName('filterbar')
2634        tbar.setWindowTitle(_('&Filter Toolbar'))
2635        self.addToolBar(tbar)
2636
2637        stbar = cmdui.ThgStatusBar(self)
2638        repoagent.progressReceived.connect(stbar.setProgress)
2639        rw.showMessageSignal.connect(stbar.showMessage)
2640        rw.progress.connect(stbar.progress)
2641        self.setStatusBar(stbar)
2642
2643        s = QSettings()
2644        s.beginGroup('LightRepoWindow')
2645        self.restoreGeometry(qtlib.readByteArray(s, 'geometry'))
2646        self.restoreState(qtlib.readByteArray(s, 'windowState'))
2647        stbar.setVisible(qtlib.readBool(s, 'statusBar', True))
2648        s.endGroup()
2649
2650        self.setWindowTitle(_('TortoiseHg: %s') % repoagent.displayName())
2651
2652    def createPopupMenu(self):
2653        menu = super(LightRepoWindow, self).createPopupMenu()
2654        assert menu  # should have toolbar
2655        stbar = self.statusBar()
2656        a = menu.addAction(_('S&tatus Bar'))
2657        a.setCheckable(True)
2658        a.setChecked(stbar.isVisibleTo(self))
2659        a.triggered.connect(stbar.setVisible)
2660        menu.addSeparator()
2661        menu.addAction(_('&Settings'), self._editSettings)
2662        return menu
2663
2664    def closeEvent(self, event):
2665        rw = self.centralWidget()
2666        if not rw.closeRepoWidget():
2667            event.ignore()
2668            return
2669        s = QSettings()
2670        s.beginGroup('LightRepoWindow')
2671        s.setValue('geometry', self.saveGeometry())
2672        s.setValue('windowState', self.saveState())
2673        s.setValue('statusBar', self.statusBar().isVisibleTo(self))
2674        s.endGroup()
2675        event.accept()
2676
2677    @pyqtSlot()
2678    def refresh(self):
2679        self._repoagent.pollStatus()
2680        rw = self.centralWidget()
2681        rw.reload()
2682
2683    def setSyncUrl(self, url):
2684        rw = self.centralWidget()
2685        rw.setSyncUrl(url)
2686
2687    @pyqtSlot()
2688    def _editSettings(self):
2689        dlg = settings.SettingsDialog(parent=self)
2690        dlg.exec_()
2691