1# shelve.py - TortoiseHg shelve and patch tool 2# 3# Copyright 2011 Steve Borho <steve@borho.org> 4# 5# This software may be used and distributed according to the terms 6# of the GNU General Public License, incorporated herein by reference. 7 8from __future__ import absolute_import 9 10import os 11import time 12 13from .qtcore import ( 14 QSettings, 15 Qt, 16 pyqtSlot, 17) 18from .qtgui import ( 19 QAction, 20 QComboBox, 21 QDialog, 22 QFrame, 23 QHBoxLayout, 24 QKeySequence, 25 QPushButton, 26 QSplitter, 27 QToolBar, 28 QVBoxLayout, 29) 30 31from mercurial import ( 32 commands, 33 error, 34 util, 35) 36 37from ..util import hglib 38from ..util.i18n import _ 39from ..util.patchctx import patchctx 40from . import ( 41 cmdui, 42 chunks, 43 qtlib, 44) 45 46class ShelveDialog(QDialog): 47 48 wdir = _('Working Directory') 49 50 def __init__(self, repoagent, parent=None): 51 QDialog.__init__(self, parent) 52 self.setWindowFlags(Qt.Window) 53 54 self.setWindowIcon(qtlib.geticon('hg-shelve')) 55 56 self._repoagent = repoagent 57 self.shelves = [] 58 self.patches = [] 59 self._patchnames = {} # path: mq patch name 60 61 layout = QVBoxLayout() 62 layout.setContentsMargins(2, 2, 2, 2) 63 layout.setSpacing(0) 64 self.setLayout(layout) 65 66 self.tbarhbox = hbox = QHBoxLayout() 67 hbox.setContentsMargins(0, 0, 0, 0) 68 hbox.setSpacing(0) 69 layout.addLayout(self.tbarhbox) 70 71 self.splitter = QSplitter(self) 72 self.splitter.setOrientation(Qt.Horizontal) 73 self.splitter.setChildrenCollapsible(False) 74 self.splitter.setObjectName('splitter') 75 layout.addWidget(self.splitter, 1) 76 77 aframe = QFrame(self.splitter) 78 avbox = QVBoxLayout() 79 avbox.setSpacing(2) 80 avbox.setContentsMargins(2, 2, 2, 2) 81 aframe.setLayout(avbox) 82 ahbox = QHBoxLayout() 83 ahbox.setSpacing(2) 84 ahbox.setContentsMargins(2, 2, 2, 2) 85 avbox.addLayout(ahbox) 86 self.comboa = QComboBox(self) 87 self.comboa.setMinimumContentsLength(10) # allow to cut long content 88 self.comboa.currentIndexChanged.connect(self.comboAChanged) 89 self.clearShelfButtonA = QPushButton(_('Clear')) 90 self.clearShelfButtonA.setToolTip(_('Clear the current shelf file')) 91 self.clearShelfButtonA.clicked.connect(self.clearShelfA) 92 self.delShelfButtonA = QPushButton(_('Delete')) 93 self.delShelfButtonA.setToolTip(_('Delete the current shelf file')) 94 self.delShelfButtonA.clicked.connect(self.deleteShelfA) 95 ahbox.addWidget(self.comboa, 1) 96 ahbox.addWidget(self.clearShelfButtonA) 97 ahbox.addWidget(self.delShelfButtonA) 98 99 self.browsea = chunks.ChunksWidget(self._repoagent, self) 100 self.browsea.splitter.splitterMoved.connect(self.linkSplitters) 101 self.browsea.linkActivated.connect(self.linkActivated) 102 self.browsea.showMessage.connect(self.showMessage) 103 avbox.addWidget(self.browsea) 104 105 bframe = QFrame(self.splitter) 106 bvbox = QVBoxLayout() 107 bvbox.setSpacing(2) 108 bvbox.setContentsMargins(2, 2, 2, 2) 109 bframe.setLayout(bvbox) 110 bhbox = QHBoxLayout() 111 bhbox.setSpacing(2) 112 bhbox.setContentsMargins(2, 2, 2, 2) 113 bvbox.addLayout(bhbox) 114 self.combob = QComboBox(self) 115 self.combob.setMinimumContentsLength(10) # allow to cut long content 116 self.combob.currentIndexChanged.connect(self.comboBChanged) 117 self.clearShelfButtonB = QPushButton(_('Clear')) 118 self.clearShelfButtonB.setToolTip(_('Clear the current shelf file')) 119 self.clearShelfButtonB.clicked.connect(self.clearShelfB) 120 self.delShelfButtonB = QPushButton(_('Delete')) 121 self.delShelfButtonB.setToolTip(_('Delete the current shelf file')) 122 self.delShelfButtonB.clicked.connect(self.deleteShelfB) 123 bhbox.addWidget(self.combob, 1) 124 bhbox.addWidget(self.clearShelfButtonB) 125 bhbox.addWidget(self.delShelfButtonB) 126 127 self.browseb = chunks.ChunksWidget(self._repoagent, self) 128 self.browseb.splitter.splitterMoved.connect(self.linkSplitters) 129 self.browseb.linkActivated.connect(self.linkActivated) 130 self.browseb.showMessage.connect(self.showMessage) 131 bvbox.addWidget(self.browseb) 132 133 self.lefttbar = QToolBar(_('Left Toolbar'), objectName='lefttbar') 134 self.lefttbar.setIconSize(qtlib.toolBarIconSize()) 135 self.lefttbar.setStyleSheet(qtlib.tbstylesheet) 136 self.tbarhbox.addWidget(self.lefttbar) 137 self.deletea = a = QAction(_('Delete selected chunks'), self) 138 self.deletea.triggered.connect(self.deleteChunksA) 139 a.setIcon(qtlib.geticon('thg-shelve-delete-left')) 140 self.lefttbar.addAction(self.deletea) 141 self.allright = a = QAction(_('Move all files right'), self) 142 self.allright.triggered.connect(self.moveFilesRight) 143 a.setIcon(qtlib.geticon('thg-shelve-move-right-all')) 144 self.lefttbar.addAction(self.allright) 145 self.fileright = a = QAction(_('Move selected file right'), self) 146 self.fileright.triggered.connect(self.moveFileRight) 147 a.setIcon(qtlib.geticon('thg-shelve-move-right-file')) 148 self.lefttbar.addAction(self.fileright) 149 self.editfilea = a = QAction(_('Edit file'), self) 150 a.setIcon(qtlib.geticon('edit-file')) 151 self.lefttbar.addAction(self.editfilea) 152 self.chunksright = a = QAction(_('Move selected chunks right'), self) 153 self.chunksright.triggered.connect(self.moveChunksRight) 154 a.setIcon(qtlib.geticon('thg-shelve-move-right-chunks')) 155 self.lefttbar.addAction(self.chunksright) 156 157 self.rbar = QToolBar(_('Refresh Toolbar'), objectName='rbar') 158 self.rbar.setIconSize(qtlib.toolBarIconSize()) 159 self.rbar.setStyleSheet(qtlib.tbstylesheet) 160 self.tbarhbox.addStretch(1) 161 self.tbarhbox.addWidget(self.rbar) 162 self.refreshAction = a = QAction(_('Refresh'), self) 163 a.setIcon(qtlib.geticon('view-refresh')) 164 a.setShortcuts(QKeySequence.Refresh) 165 a.triggered.connect(self.refreshCombos) 166 self.rbar.addAction(self.refreshAction) 167 self.actionNew = a = QAction(_('New Shelf'), self) 168 a.setIcon(qtlib.geticon('document-new')) 169 a.triggered.connect(self.newShelfPressed) 170 self.rbar.addAction(self.actionNew) 171 172 self.righttbar = QToolBar(_('Right Toolbar'), objectName='righttbar') 173 self.righttbar.setIconSize(qtlib.toolBarIconSize()) 174 self.righttbar.setStyleSheet(qtlib.tbstylesheet) 175 self.tbarhbox.addStretch(1) 176 self.tbarhbox.addWidget(self.righttbar) 177 self.chunksleft = a = QAction(_('Move selected chunks left'), self) 178 self.chunksleft.triggered.connect(self.moveChunksLeft) 179 a.setIcon(qtlib.geticon('thg-shelve-move-left-chunks')) 180 self.righttbar.addAction(self.chunksleft) 181 self.editfileb = a = QAction(_('Edit file'), self) 182 a.setIcon(qtlib.geticon('edit-file')) 183 self.righttbar.addAction(self.editfileb) 184 self.fileleft = a = QAction(_('Move selected file left'), self) 185 self.fileleft.triggered.connect(self.moveFileLeft) 186 a.setIcon(qtlib.geticon('thg-shelve-move-left-file')) 187 self.righttbar.addAction(self.fileleft) 188 self.allleft = a = QAction(_('Move all files left'), self) 189 self.allleft.triggered.connect(self.moveFilesLeft) 190 a.setIcon(qtlib.geticon('thg-shelve-move-left-all')) 191 self.righttbar.addAction(self.allleft) 192 self.deleteb = a = QAction(_('Delete selected chunks'), self) 193 self.deleteb.triggered.connect(self.deleteChunksB) 194 a.setIcon(qtlib.geticon('thg-shelve-delete-right')) 195 self.righttbar.addAction(self.deleteb) 196 197 self.editfilea.triggered.connect(self.browsea.editCurrentFile) 198 self.editfileb.triggered.connect(self.browseb.editCurrentFile) 199 200 self.browsea.chunksSelected.connect(self.chunksright.setEnabled) 201 self.browsea.chunksSelected.connect(self.deletea.setEnabled) 202 self.browsea.fileSelected.connect(self.fileright.setEnabled) 203 self.browsea.fileSelected.connect(self.editfilea.setEnabled) 204 self.browsea.fileModified.connect(self.refreshCombos) 205 self.browsea.fileModelEmpty.connect(self.allright.setDisabled) 206 self.browseb.chunksSelected.connect(self.chunksleft.setEnabled) 207 self.browseb.chunksSelected.connect(self.deleteb.setEnabled) 208 self.browseb.fileSelected.connect(self.fileleft.setEnabled) 209 self.browseb.fileSelected.connect(self.editfileb.setEnabled) 210 self.browseb.fileModified.connect(self.refreshCombos) 211 self.browseb.fileModelEmpty.connect(self.allleft.setDisabled) 212 213 self.statusbar = cmdui.ThgStatusBar(self) 214 layout.addWidget(self.statusbar) 215 self.showMessage(_('Backup copies of modified files can be found ' 216 'in .hg/Trashcan/')) 217 218 self.refreshCombos() 219 repoagent.repositoryChanged.connect(self.refreshCombos) 220 221 self.setWindowTitle(_('TortoiseHg Shelve - %s') 222 % repoagent.displayName()) 223 self.restoreSettings() 224 225 @property 226 def repo(self): 227 return self._repoagent.rawRepo() 228 229 @pyqtSlot() 230 def moveFileRight(self): 231 if self.combob.currentIndex() == -1: 232 self.newShelf(False) 233 for file in self.browsea.getSelectedFiles(): 234 chunks = self.browsea.getChunksForFile(file) 235 if chunks and self.browseb.mergeChunks(file, chunks): 236 self.browsea.removeFile(file) 237 238 @pyqtSlot() 239 def moveFileLeft(self): 240 for file in self.browseb.getSelectedFiles(): 241 chunks = self.browseb.getChunksForFile(file) 242 if chunks and self.browsea.mergeChunks(file, chunks): 243 self.browseb.removeFile(file) 244 245 @pyqtSlot() 246 def moveFilesRight(self): 247 if self.combob.currentIndex() == -1: 248 self.newShelf(False) 249 for file in self.browsea.getFileList(): 250 chunks = self.browsea.getChunksForFile(file) 251 if chunks and self.browseb.mergeChunks(file, chunks): 252 self.browsea.removeFile(file) 253 254 @pyqtSlot() 255 def moveFilesLeft(self): 256 for file in self.browseb.getFileList(): 257 chunks = self.browseb.getChunksForFile(file) 258 if chunks and self.browsea.mergeChunks(file, chunks): 259 self.browseb.removeFile(file) 260 261 @pyqtSlot() 262 def moveChunksRight(self): 263 if self.combob.currentIndex() == -1: 264 self.newShelf(False) 265 file, chunks = self.browsea.getSelectedFileAndChunks() 266 if self.browseb.mergeChunks(file, chunks): 267 self.browsea.deleteSelectedChunks() 268 269 @pyqtSlot() 270 def moveChunksLeft(self): 271 file, chunks = self.browseb.getSelectedFileAndChunks() 272 if self.browsea.mergeChunks(file, chunks): 273 self.browseb.deleteSelectedChunks() 274 275 @pyqtSlot() 276 def deleteChunksA(self): 277 if self.comboa.currentIndex() == 0: 278 msg = _('Delete selected chunks from working copy?') 279 else: 280 f = hglib.tounicode(os.path.basename(self.currentPatchA())) 281 msg = _('Delete selected chunks from shelf file %s?') % f 282 if qtlib.QuestionMsgBox(_('Are you sure?'), msg, parent=self): 283 self.browsea.deleteSelectedChunks() 284 285 @pyqtSlot() 286 def deleteChunksB(self): 287 f = hglib.tounicode(os.path.basename(self.currentPatchB())) 288 msg = _('Delete selected chunks from shelf file %s?') % f 289 if qtlib.QuestionMsgBox(_('Are you sure?'), msg, parent=self): 290 self.browseb.deleteSelectedChunks() 291 292 @pyqtSlot() 293 def newShelfPressed(self): 294 self.newShelf(True) 295 296 def newShelf(self, interactive): 297 shelve = time.strftime('%Y-%m-%d_%H-%M-%S') + \ 298 '_parent_rev_%d' % self.repo[b'.'].rev() 299 if interactive: 300 shelve, ok = qtlib.getTextInput(self, 301 _('TortoiseHg New Shelf Name'), 302 _('Specify name of new shelf'), 303 text=shelve) 304 if not ok: 305 return 306 invalids = (':', '#', '/', '\\') 307 bads = [c for c in shelve if c in invalids] 308 if bads: 309 qtlib.ErrorMsgBox(_('Bad filename'), 310 _('A shelf name cannot contain %s') 311 % ''.join(bads)) 312 return 313 badmsg = util.checkosfilename(hglib.fromunicode(shelve)) 314 if badmsg: 315 qtlib.ErrorMsgBox(_('Bad filename'), hglib.tounicode(badmsg)) 316 return 317 try: 318 fn = os.path.join('shelves', shelve) 319 shelfpath = self.repo.vfs.join(hglib.fromunicode(fn)) # type: bytes 320 if os.path.exists(shelfpath): 321 qtlib.ErrorMsgBox(_('File already exists'), 322 _('A shelf file of that name already exists')) 323 return 324 self.repo.makeshelf(hglib.fromunicode(shelve)) 325 self.showMessage(_('New shelf created')) 326 self.refreshCombos() 327 if shelfpath in self.shelves: 328 self.combob.setCurrentIndex(self.shelves.index(shelfpath)) 329 except EnvironmentError as e: 330 self.showMessage(hglib.tounicode(str(e))) 331 332 @pyqtSlot() 333 def deleteShelfA(self): 334 shelf = self.currentPatchA() 335 ushelf = hglib.tounicode(os.path.basename(shelf)) 336 if not qtlib.QuestionMsgBox(_('Are you sure?'), 337 _('Delete shelf file %s?') % ushelf): 338 return 339 try: 340 os.unlink(shelf) 341 self.showMessage(_('Shelf deleted')) 342 except EnvironmentError as e: 343 self.showMessage(hglib.tounicode(str(e))) 344 self.refreshCombos() 345 346 @pyqtSlot() 347 def clearShelfA(self): 348 if self.comboa.currentIndex() == 0: 349 if not qtlib.QuestionMsgBox(_('Are you sure?'), 350 _('Revert all working copy changes?')): 351 return 352 try: 353 self.repo.ui.quiet = True 354 commands.revert(self.repo.ui, self.repo, all=True) 355 self.repo.ui.quiet = False 356 except (EnvironmentError, error.Abort) as e: 357 self.showMessage(hglib.tounicode(str(e))) 358 self.refreshCombos() 359 return 360 shelf = self.currentPatchA() 361 ushelf = hglib.tounicode(os.path.basename(shelf)) 362 if not qtlib.QuestionMsgBox(_('Are you sure?'), 363 _('Clear contents of shelf file %s?') % ushelf): 364 return 365 try: 366 f = open(shelf, "w") 367 f.close() 368 self.showMessage(_('Shelf cleared')) 369 except EnvironmentError as e: 370 self.showMessage(hglib.tounicode(str(e))) 371 self.refreshCombos() 372 373 @pyqtSlot() 374 def deleteShelfB(self): 375 shelf = self.currentPatchB() 376 ushelf = hglib.tounicode(os.path.basename(shelf)) 377 if not qtlib.QuestionMsgBox(_('Are you sure?'), 378 _('Delete shelf file %s?') % ushelf): 379 return 380 try: 381 os.unlink(shelf) 382 self.showMessage(_('Shelf deleted')) 383 except EnvironmentError as e: 384 self.showMessage(hglib.tounicode(str(e))) 385 self.refreshCombos() 386 387 @pyqtSlot() 388 def clearShelfB(self): 389 shelf = self.currentPatchB() 390 ushelf = hglib.tounicode(os.path.basename(shelf)) 391 if not qtlib.QuestionMsgBox(_('Are you sure?'), 392 _('Clear contents of shelf file %s?') % ushelf): 393 return 394 try: 395 f = open(shelf, "w") 396 f.close() 397 self.showMessage(_('Shelf cleared')) 398 except EnvironmentError as e: 399 self.showMessage(hglib.tounicode(str(e))) 400 self.refreshCombos() 401 402 def currentPatchA(self): 403 idx = self.comboa.currentIndex() 404 if idx == -1: 405 return None 406 if idx == 0: 407 return self.wdir 408 idx -= 1 409 if idx < len(self.shelves): 410 return self.shelves[idx] 411 idx -= len(self.shelves) 412 if idx < len(self.patches): 413 return self.patches[idx] 414 return None 415 416 def currentPatchB(self): 417 idx = self.combob.currentIndex() 418 if idx == -1: 419 return None 420 if idx < len(self.shelves): 421 return self.shelves[idx] 422 idx -= len(self.shelves) 423 if idx < len(self.patches): 424 return self.patches[idx] 425 return None 426 427 @pyqtSlot() 428 def refreshCombos(self): 429 shelvea, shelveb = self.currentPatchA(), self.currentPatchB() 430 431 # Note that thgshelves returns the shelve list ordered from newest to 432 # oldest 433 shelves = self.repo.thgshelves() 434 disp = [_('Shelf: %s') % hglib.tounicode(s) for s in shelves] 435 436 patches = self.repo.thgmqunappliedpatches 437 disp += [_('Patch: %s') % hglib.tounicode(p) for p in patches] 438 439 # store fully qualified paths 440 self.shelves = [os.path.join(self.repo.shelfdir, s) for s in shelves] 441 self.patches = [self.repo.mq.join(p) for p in patches] 442 self._patchnames = dict(zip(self.patches, patches)) 443 444 self.comboRefreshInProgress = True 445 self.comboa.clear() 446 self.combob.clear() 447 self.comboa.addItems([self.wdir] + disp) 448 self.combob.addItems(disp) 449 450 # attempt to restore selection 451 if shelvea == self.wdir: 452 idxa = 0 453 elif shelvea in self.shelves: 454 idxa = self.shelves.index(shelvea) + 1 455 elif shelvea in self.patches: 456 idxa = len(self.shelves) + self.patches.index(shelvea) + 1 457 else: 458 idxa = 0 459 self.comboa.setCurrentIndex(idxa) 460 461 if shelveb in self.shelves: 462 idxb = self.shelves.index(shelveb) 463 elif shelveb in self.patches: 464 idxb = len(self.shelves) + self.patches.index(shelveb) 465 else: 466 idxb = 0 467 self.combob.setCurrentIndex(idxb) 468 self.comboRefreshInProgress = False 469 470 self.comboAChanged(idxa) 471 self.comboBChanged(idxb) 472 if not patches and not shelves: 473 self.delShelfButtonB.setEnabled(False) 474 self.clearShelfButtonB.setEnabled(False) 475 self.browseb.setContext(patchctx('', self.repo, None)) 476 477 @pyqtSlot(int) 478 def comboAChanged(self, index): 479 if self.comboRefreshInProgress: 480 return 481 assert index >= 0 # side A should always have "Working Directory" 482 if index == 0: 483 rev = None 484 self.delShelfButtonA.setEnabled(False) 485 self.clearShelfButtonA.setEnabled(True) 486 else: 487 rev = self.currentPatchA() 488 rev = self._patchnames.get(rev, rev) 489 self.delShelfButtonA.setEnabled(index <= len(self.shelves)) 490 self.clearShelfButtonA.setEnabled(index <= len(self.shelves)) 491 self.browsea.setContext(self.repo[rev]) 492 self._deselectSamePatch(self.combob) 493 494 @pyqtSlot(int) 495 def comboBChanged(self, index): 496 if self.comboRefreshInProgress: 497 return 498 rev = self.currentPatchB() 499 rev = self._patchnames.get(rev, rev) 500 self.delShelfButtonB.setEnabled(0 <= index < len(self.shelves)) 501 self.clearShelfButtonB.setEnabled(0 <= index < len(self.shelves)) 502 self.browseb.setContext(self.repo[rev]) 503 self._deselectSamePatch(self.comboa) 504 505 def _deselectSamePatch(self, combo): 506 # if the same patch or shelve is selected by both sides, "move" action 507 # will corrupt patch content. 508 if self.currentPatchA() != self.currentPatchB(): 509 return 510 if combo.count() > 1: 511 combo.setCurrentIndex((combo.currentIndex() + 1) % combo.count()) 512 else: 513 combo.setCurrentIndex(-1) 514 515 @pyqtSlot(int, int) 516 def linkSplitters(self, pos, index): 517 if self.browsea.splitter.sizes()[0] != pos: 518 self.browsea.splitter.moveSplitter(pos, index) 519 if self.browseb.splitter.sizes()[0] != pos: 520 self.browseb.splitter.moveSplitter(pos, index) 521 522 @pyqtSlot(str) 523 def linkActivated(self, linktext): 524 pass 525 526 @pyqtSlot(str) 527 def showMessage(self, message): 528 self.statusbar.showMessage(message) 529 530 def storeSettings(self): 531 s = QSettings() 532 wb = "shelve/" 533 s.setValue(wb + 'geometry', self.saveGeometry()) 534 s.setValue(wb + 'panesplitter', self.splitter.saveState()) 535 s.setValue(wb + 'filesplitter', self.browsea.splitter.saveState()) 536 self.browsea.saveSettings(s, wb + 'fileviewa') 537 self.browseb.saveSettings(s, wb + 'fileviewb') 538 539 def restoreSettings(self): 540 s = QSettings() 541 wb = "shelve/" 542 self.restoreGeometry(qtlib.readByteArray(s, wb + 'geometry')) 543 self.splitter.restoreState(qtlib.readByteArray(s, wb + 'panesplitter')) 544 self.browsea.splitter.restoreState( 545 qtlib.readByteArray(s, wb + 'filesplitter')) 546 self.browseb.splitter.restoreState( 547 qtlib.readByteArray(s, wb + 'filesplitter')) 548 self.browsea.loadSettings(s, wb + 'fileviewa') 549 self.browseb.loadSettings(s, wb + 'fileviewb') 550 551 def reject(self): 552 self.storeSettings() 553 super(ShelveDialog, self).reject() 554