1# repotab.py - stack of repository widgets
2#
3# Copyright (C) 2007-2010 Logilab. All rights reserved.
4# Copyright 2014 Yuya Nishihara <yuya@tcha.org>
5#
6# This software may be used and distributed according to the terms of the
7# GNU General Public License version 2 or any later version.
8
9from __future__ import absolute_import
10
11import os
12
13from .qtcore import (
14    QPoint,
15    QSignalMapper,
16    Qt,
17    pyqtSignal,
18    pyqtSlot,
19)
20from .qtgui import (
21    QAction,
22    QActionGroup,
23    QKeySequence,
24    QMenu,
25    QShortcut,
26    QStackedLayout,
27    QTabBar,
28    QVBoxLayout,
29    QWidget,
30)
31
32from mercurial import (
33    error,
34    pycompat,
35)
36
37from ..util import hglib
38from ..util.i18n import _
39from . import (
40    cmdcore,
41    qtlib,
42    repowidget,
43)
44
45class _TabBar(QTabBar):
46
47    def mouseReleaseEvent(self, event):
48        if event.button() == Qt.MidButton:
49            self.tabCloseRequested.emit(self.tabAt(event.pos()))
50        super(_TabBar, self).mouseReleaseEvent(event)
51
52
53class RepoTabWidget(QWidget):
54    """Manage stack of RepoWidgets of open repositories"""
55
56    currentRepoChanged = pyqtSignal(str, str)  # curpath, prevpath
57    currentTabChanged = pyqtSignal(int)
58    currentTaskTabChanged = pyqtSignal()
59    currentTitleChanged = pyqtSignal()
60    historyChanged = pyqtSignal()
61    makeLogVisible = pyqtSignal(bool)
62    progressReceived = pyqtSignal(str, cmdcore.ProgressMessage)
63    showMessageSignal = pyqtSignal(str)
64    taskTabVisibilityChanged = pyqtSignal(bool)
65    toolbarVisibilityChanged = pyqtSignal(bool)
66
67    # look-up of tab-index and stack-index:
68    # 1. tabbar[tab-index] -> {tabData: rw, tabToolTip: root}
69    # 2. stack[rw] -> stack-index
70    #
71    # tab-index is the master, so do not use stack.setCurrentIndex().
72
73    def __init__(self, config, actionregistry, repomanager, parent=None):
74        super(RepoTabWidget, self).__init__(parent)
75        self._config = config
76        self._actionregistry = actionregistry
77        self._repomanager = repomanager
78        # delay until the next event loop so that the current tab won't be
79        # gone in the middle of switching tabs (issue #4253)
80        repomanager.repositoryDestroyed.connect(self.closeRepo,
81                                                Qt.QueuedConnection)
82
83        vbox = QVBoxLayout(self)
84        vbox.setContentsMargins(0, 0, 0, 0)
85        vbox.setSpacing(0)
86
87        self._tabbar = tabbar = _TabBar(self)
88
89        if qtlib.IS_RETINA:
90            tabbar.setIconSize(qtlib.barRetinaIconSize())
91        tabbar.setDocumentMode(True)
92        tabbar.setExpanding(False)
93        tabbar.setTabsClosable(True)
94        tabbar.setUsesScrollButtons(True)
95        tabbar.setMovable(True)
96        tabbar.currentChanged.connect(self._onCurrentTabChanged)
97        tabbar.tabCloseRequested.connect(self.closeTab)
98        tabbar.hide()
99        vbox.addWidget(tabbar)
100
101        self._initTabMenuActions()
102        tabbar.setContextMenuPolicy(Qt.CustomContextMenu)
103        tabbar.customContextMenuRequested.connect(self._onTabMenuRequested)
104
105        self._initTabSwitchActions()
106        tabbar.tabMoved.connect(self._updateTabSwitchActions)
107
108        self._stack = QStackedLayout()
109        vbox.addLayout(self._stack, 1)
110
111        self._curpath = ''  # != currentRepoRootPath until _onCurrentTabChanged
112        self._lastclickedindex = -1
113        self._lastclosedpaths = []
114
115        self._iconmapper = QSignalMapper(self)
116        self._iconmapper.mapped[QWidget].connect(self._updateIcon)
117        self._titlemapper = QSignalMapper(self)
118        self._titlemapper.mapped[QWidget].connect(self._updateTitle)
119
120        self._updateTabSwitchActions()
121
122        QShortcut(QKeySequence.NextChild, self, self._next_tab)
123        QShortcut(QKeySequence.PreviousChild, self, self._prev_tab)
124
125    def openRepo(self, root, bundle=None):
126        """Open the specified repository in new tab"""
127        rw = self._createRepoWidget(root, bundle)
128        if not rw:
129            return False
130        # do not emit currentChanged until tab properties are fully set up.
131        # the first tab is automatically selected.
132        tabbar = self._tabbar
133        tabbar.blockSignals(True)
134        index = tabbar.insertTab(self._newTabIndex(), rw.title())
135        tabbar.setTabData(index, rw)
136        tabbar.setTabToolTip(index, rw.repoRootPath())
137        self.setCurrentIndex(index)
138        tabbar.blockSignals(False)
139        self._updateTabSwitchActions()
140        self._updateTabVisibility()
141        self._onCurrentTabChanged(index)
142        return True
143
144    def _addUnloadedRepos(self, rootpaths):
145        """Add tabs of the specified repositories without loading them"""
146        tabbar = self._tabbar
147        tabbar.blockSignals(True)
148        for index, root in enumerate(rootpaths, self._newTabIndex()):
149            root = hglib.normreporoot(root)
150            index = tabbar.insertTab(index, os.path.basename(root))
151            tabbar.setTabToolTip(index, root)
152        tabbar.blockSignals(False)
153        self._updateTabSwitchActions()
154        self._updateTabVisibility()
155        # must call _onCurrentTabChanged() appropriately
156
157    def _newTabIndex(self):
158        if self._config.configBool('tortoisehg', 'opentabsaftercurrent'):
159            return self.currentIndex() + 1
160        else:
161            return self.count()
162
163    @pyqtSlot(str)
164    def closeRepo(self, root):
165        """Close tabs of the specified repository"""
166        root = hglib.normreporoot(root)
167        return self._closeTabs(list(self._findIndexesByRepoRootPath(root)))
168
169    @pyqtSlot(int)
170    def closeTab(self, index):
171        if 0 <= index < self.count():
172            return self._closeTabs([index])
173        return False
174
175    def closeAllTabs(self):
176        return self._closeTabs(list(range(self.count())))
177
178    def _closeTabs(self, indexes):
179        if not self._checkTabsClosable(indexes):
180            return False
181        self._lastclosedpaths = pycompat.maplist(self.repoRootPath, indexes)
182        self._removeTabs(indexes)
183        return True
184
185    def _checkTabsClosable(self, indexes):
186        for i in indexes:
187            rw = self._widget(i)
188            if rw and not rw.closeRepoWidget():
189                self.setCurrentIndex(i)
190                return False
191        return True
192
193    def _removeTabs(self, indexes):
194        # must call _checkRepoTabsClosable() before
195        indexes = sorted(indexes, reverse=True)
196        tabchange = indexes and indexes[-1] <= self.currentIndex()
197        self._tabbar.blockSignals(True)
198        for i in indexes:
199            rw = self._widget(i)
200            self._tabbar.removeTab(i)
201            if rw:
202                self._stack.removeWidget(rw)
203                self._repomanager.releaseRepoAgent(rw.repoRootPath())
204                rw.deleteLater()
205        self._tabbar.blockSignals(False)
206        self._updateTabSwitchActions()
207        self._updateTabVisibility()
208        if tabchange:
209            self._onCurrentTabChanged(self.currentIndex())
210
211    def selectRepo(self, root):
212        """Find the tab for the specified repository and make it current"""
213        root = hglib.normreporoot(root)
214        if self.currentRepoRootPath() == root:
215            return True
216        for i in self._findIndexesByRepoRootPath(root):
217            self.setCurrentIndex(i)
218            return True
219        return False
220
221    def restoreRepos(self, rootpaths, activepath):
222        """Restore tabs of the last open repositories"""
223        if not rootpaths:
224            return
225        self._addUnloadedRepos(rootpaths)
226        self._tabbar.blockSignals(True)
227        self.selectRepo(activepath)
228        self._tabbar.blockSignals(False)
229        self._onCurrentTabChanged(self.currentIndex())
230
231    def _initTabMenuActions(self):
232        actiondefs = [
233            ('closetab', _('Close tab'),
234             _('Close tab'), self._closeLastClickedTab),
235            ('closeothertabs', _('Close other tabs'),
236             _('Close other tabs'), self._closeNotLastClickedTabs),
237            ('reopenlastclosed', _('Undo close tab'),
238             _('Reopen last closed tab'), self._reopenLastClosedTabs),
239            ('reopenlastclosedgroup', _('Undo close other tabs'),
240             _('Reopen last closed tab group'), self._reopenLastClosedTabs),
241            ]
242        self._actions = {}
243        for name, desc, tip, cb in actiondefs:
244            self._actions[name] = act = QAction(desc, self)
245            act.setStatusTip(tip)
246            act.triggered.connect(cb)
247            self.addAction(act)
248
249    @pyqtSlot(QPoint)
250    def _onTabMenuRequested(self, point):
251        index = self._tabbar.tabAt(point)
252        if index >= 0:
253            self._lastclickedindex = index
254        else:
255            self._lastclickedindex = self.currentIndex()
256
257        menu = QMenu(self)
258        menu.addAction(self._actions['closetab'])
259        menu.addAction(self._actions['closeothertabs'])
260        menu.addSeparator()
261        if len(self._lastclosedpaths) > 1:
262            menu.addAction(self._actions['reopenlastclosedgroup'])
263        elif self._lastclosedpaths:
264            menu.addAction(self._actions['reopenlastclosed'])
265        menu.setAttribute(Qt.WA_DeleteOnClose)
266        menu.popup(self._tabbar.mapToGlobal(point))
267
268    @pyqtSlot()
269    def _closeLastClickedTab(self):
270        self.closeTab(self._lastclickedindex)
271
272    @pyqtSlot()
273    def _closeNotLastClickedTabs(self):
274        if self._lastclickedindex >= 0:
275            self._closeTabs([i for i in pycompat.xrange(self.count())
276                             if i != self._lastclickedindex])
277
278    @pyqtSlot()
279    def _reopenLastClosedTabs(self):
280        origindex = self.currentIndex()
281        self._addUnloadedRepos(self._lastclosedpaths)
282        del self._lastclosedpaths[:]
283        if origindex != self.currentIndex():
284            self._onCurrentTabChanged(self.currentIndex())
285
286    def tabSwitchActions(self):
287        """List of actions to switch current tabs; should be registered to
288        the main window or menu"""
289        return self._swactions.actions()
290
291    def _initTabSwitchActions(self):
292        self._swactions = QActionGroup(self)
293        self._swactions.triggered.connect(self._setCurrentTabByAction)
294        for i in pycompat.xrange(9):
295            a = self._swactions.addAction('')
296            a.setCheckable(True)
297            a.setData(i)
298            a.setShortcut('Ctrl+%d' % (i + 1))
299
300    @pyqtSlot()
301    def _updateTabSwitchActions(self):
302        self._swactions.setVisible(self._tabbar.count() > 1)
303        if not self._swactions.isVisible():
304            return
305        for i, a in enumerate(self._swactions.actions()):
306            a.setVisible(i < self.count())
307            if not a.isVisible():
308                continue
309            a.setChecked(i == self.currentIndex())
310            a.setText(self._tabbar.tabText(i))
311
312    @pyqtSlot(QAction)
313    def _setCurrentTabByAction(self, action):
314        index = action.data()
315        self.setCurrentIndex(index)
316
317    @pyqtSlot()
318    def _prev_tab(self):
319        self._tabbar.setCurrentIndex(self._tabbar.currentIndex() - 1)
320
321    @pyqtSlot()
322    def _next_tab(self):
323        self._tabbar.setCurrentIndex(self._tabbar.currentIndex() + 1)
324
325    def currentRepoRootPath(self):
326        return self.repoRootPath(self.currentIndex())
327
328    def repoRootPath(self, index):
329        return pycompat.unicode(self._tabbar.tabToolTip(index))
330
331    def _findIndexesByRepoRootPath(self, root):
332        for i in pycompat.xrange(self.count()):
333            if self.repoRootPath(i) == root:
334                yield i
335
336    def count(self):
337        """Number of tabs including repositories of not-yet opened"""
338        return self._tabbar.count()
339
340    def currentIndex(self):
341        return self._tabbar.currentIndex()
342
343    def currentWidget(self):
344        return self._stack.currentWidget()
345
346    @pyqtSlot(int)
347    def setCurrentIndex(self, index):
348        self._tabbar.setCurrentIndex(index)
349
350    @pyqtSlot(int)
351    def _onCurrentTabChanged(self, index):
352        rw = self._widget(index)
353        if not rw and index >= 0:
354            tabbar = self._tabbar
355            rw = self._createRepoWidget(self.repoRootPath(index))
356            if not rw:
357                tabbar.removeTab(index)  # may reenter
358                self._updateTabSwitchActions()
359                self._updateTabVisibility()
360                return
361            tabbar.setTabData(index, rw)
362            tabbar.setTabText(index, rw.title())
363            # update path in case filesystem changed after tab was added
364            tabbar.setTabToolTip(index, rw.repoRootPath())
365        if rw:
366            self._stack.setCurrentWidget(rw)
367        self._updateTabSwitchActions()
368
369        prevpath = self._curpath
370        self._curpath = self.repoRootPath(index)
371        self.currentTabChanged.emit(index)
372        # there may be more than one tabs of the same repo
373        if self._curpath != prevpath:
374            self._onCurrentRepoChanged(self._curpath, prevpath)
375
376    def _onCurrentRepoChanged(self, curpath, prevpath):
377        prevrepoagent = currepoagent = None
378        if prevpath:
379            prevrepoagent = self._repomanager.repoAgent(prevpath)  # may be None
380        if curpath:
381            currepoagent = self._repomanager.repoAgent(curpath)
382        if prevrepoagent:
383            prevrepoagent.suspendMonitoring()
384        if currepoagent:
385            currepoagent.resumeMonitoring()
386        self.currentRepoChanged.emit(curpath, prevpath)
387
388    def _indexOf(self, rw):
389        if self.currentWidget() is rw:
390            return self.currentIndex()  # fast path
391        for i in pycompat.xrange(self.count()):
392            if self._widget(i) is rw:
393                return i
394        return -1
395
396    def _widget(self, index):
397        return self._tabbar.tabData(index)
398
399    def _createRepoWidget(self, root, bundle=None):
400        try:
401            repoagent = self._repomanager.openRepoAgent(root)
402        except (error.Abort, error.RepoError) as e:
403            qtlib.WarningMsgBox(_('Failed to open repository'),
404                                hglib.tounicode(bytes(e)), parent=self)
405            return
406        rw = repowidget.RepoWidget(self._actionregistry, repoagent, self,
407                                   bundle=bundle)
408        rw.currentTaskTabChanged.connect(self.currentTaskTabChanged)
409        rw.makeLogVisible.connect(self.makeLogVisible)
410        rw.progress.connect(self._mapProgressReceived)
411        rw.repoLinkClicked.connect(self._openLinkedRepo)
412        rw.revisionSelected.connect(self.historyChanged)
413        rw.showMessageSignal.connect(self.showMessageSignal)
414        rw.taskTabVisibilityChanged.connect(self.taskTabVisibilityChanged)
415        rw.toolbarVisibilityChanged.connect(self.toolbarVisibilityChanged)
416        rw.busyIconChanged.connect(self._iconmapper.map)
417        self._iconmapper.setMapping(rw, rw)
418        rw.titleChanged.connect(self._titlemapper.map)
419        self._titlemapper.setMapping(rw, rw)
420        self._stack.addWidget(rw)
421        return rw
422
423    @pyqtSlot(str, object, str, str, object)
424    def _mapProgressReceived(self, topic, pos, item, unit, total):
425        rw = self.sender()
426        assert isinstance(rw, repowidget.RepoWidget), repr(rw)
427        progress = cmdcore.ProgressMessage(
428            pycompat.unicode(topic), pos, pycompat.unicode(item),
429            pycompat.unicode(unit), total)
430        self.progressReceived.emit(rw.repoRootPath(), progress)
431
432    @pyqtSlot(str)
433    def _openLinkedRepo(self, path):
434        uri = pycompat.unicode(path).split('?', 1)
435        path = hglib.normreporoot(uri[0])
436        rev = None
437        if len(uri) > 1:
438            rev = hglib.fromunicode(uri[1])
439        if self.selectRepo(path) or self.openRepo(path):
440            rw = self.currentWidget()
441            if rev:
442                rw.goto(rev)
443            else:
444                # assumes that the request comes from commit widget; in this
445                # case, the user is going to commit changes to this repo.
446                rw.switchToNamedTaskTab('commit')
447
448    @pyqtSlot('QWidget*')
449    def _updateIcon(self, rw):
450        index = self._indexOf(rw)
451        self._tabbar.setTabIcon(index, rw.busyIcon())
452
453    @pyqtSlot('QWidget*')
454    def _updateTitle(self, rw):
455        index = self._indexOf(rw)
456        self._tabbar.setTabText(index, rw.title())
457        self._updateTabSwitchActions()
458        if index == self.currentIndex():
459            self.currentTitleChanged.emit()
460
461    def _updateTabVisibility(self):
462        forcetab = self._config.configBool('tortoisehg', 'forcerepotab')
463        self._tabbar.setVisible(self.count() > 1
464                                or (self.count() == 1 and forcetab))
465