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