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