PageRenderTime 45ms CodeModel.GetById 16ms app.highlight 25ms RepoModel.GetById 1ms app.codeStats 0ms

/tortoisehg/hgqt/guess.py

https://bitbucket.org/tortoisehg/hgtk/
Python | 405 lines | 353 code | 38 blank | 14 comment | 37 complexity | 3bd5822bff07643d95f4aa2d13076a28 MD5 | raw file
  1# guess.py - TortoiseHg's dialogs for detecting copies and renames
  2#
  3# Copyright 2010 Steve Borho <steve@borho.org>
  4#
  5# This software may be used and distributed according to the terms of the
  6# GNU General Public License version 2, incorporated herein by reference.
  7
  8import os
  9
 10from mercurial import hg, ui, mdiff, similar, patch
 11
 12from tortoisehg.util import hglib, shlib
 13
 14from tortoisehg.hgqt.i18n import _
 15from tortoisehg.hgqt import qtlib, htmlui, cmdui
 16
 17from PyQt4.QtCore import *
 18from PyQt4.QtGui import *
 19
 20# Techincal debt
 21# Try to cut down on the jitter when findRenames is pressed.  May
 22# require a splitter.
 23
 24class DetectRenameDialog(QDialog):
 25    'Detect renames after they occur'
 26    matchAccepted = pyqtSignal()
 27
 28    def __init__(self, repo, parent, *pats):
 29        QDialog.__init__(self, parent)
 30
 31        self.repo = repo
 32        self.pats = pats
 33        self.thread = None
 34
 35        self.setWindowTitle(_('Detect Copies/Renames in %s') % repo.displayname)
 36        f = self.windowFlags()
 37        self.setWindowFlags(f & ~Qt.WindowContextHelpButtonHint)
 38
 39        layout = QVBoxLayout()
 40        layout.setContentsMargins(*(2,)*4)
 41        self.setLayout(layout)
 42
 43        # vsplit for top & diff
 44        vsplit = QSplitter(Qt.Horizontal)
 45        utframe = QFrame(vsplit)
 46        matchframe = QFrame(vsplit)
 47
 48        utvbox = QVBoxLayout()
 49        utvbox.setContentsMargins(*(2,)*4)
 50        utframe.setLayout(utvbox)
 51        matchvbox = QVBoxLayout()
 52        matchvbox.setContentsMargins(*(2,)*4)
 53        matchframe.setLayout(matchvbox)
 54
 55        hsplit = QSplitter(Qt.Vertical)
 56        layout.addWidget(hsplit)
 57        hsplit.addWidget(vsplit)
 58
 59        utlbl = QLabel(_('<b>Unrevisioned Files</b>'))
 60        utvbox.addWidget(utlbl)
 61        self.unrevlist = QListWidget()
 62        self.unrevlist.setSelectionMode(QAbstractItemView.ExtendedSelection)
 63        utvbox.addWidget(self.unrevlist)
 64
 65        simhbox = QHBoxLayout()
 66        utvbox.addLayout(simhbox)
 67        lbl = QLabel()
 68        slider = QSlider(Qt.Horizontal)
 69        slider.setRange(0, 100)
 70        slider.setTickInterval(10)
 71        slider.setPageStep(10)
 72        slider.setTickPosition(QSlider.TicksBelow)
 73        slider.changefunc = lambda v: lbl.setText(
 74                            _('Min Simularity: %d%%') % v)
 75        slider.valueChanged.connect(slider.changefunc)
 76        self.simslider = slider
 77        lbl.setBuddy(slider)
 78        simhbox.addWidget(lbl)
 79        simhbox.addWidget(slider, 1)
 80
 81        buthbox = QHBoxLayout()
 82        utvbox.addLayout(buthbox)
 83        copycheck = QCheckBox(_('Only consider deleted files'))
 84        copycheck.setToolTip(_('Uncheck to consider all revisioned files'
 85                               ' for copy sources'))
 86        copycheck.setChecked(True)
 87        findrenames = QPushButton(_('Find Rename'))
 88        findrenames.setToolTip(_('Find copy and/or rename sources'))
 89        findrenames.setEnabled(False)
 90        findrenames.clicked.connect(self.findRenames)
 91        buthbox.addWidget(copycheck)
 92        buthbox.addStretch(1)
 93        buthbox.addWidget(findrenames)
 94        self.findbtn, self.copycheck = findrenames, copycheck
 95        def itemselect():
 96            self.findbtn.setEnabled(len(self.unrevlist.selectedItems()))
 97        self.unrevlist.itemSelectionChanged.connect(itemselect)
 98
 99        matchlbl = QLabel(_('<b>Candidate Matches</b>'))
100        matchvbox.addWidget(matchlbl)
101        matchtv = QTreeView()
102        matchtv.setSelectionMode(QTreeView.ExtendedSelection)
103        matchtv.setItemsExpandable(False)
104        matchtv.setRootIsDecorated(False)
105        matchtv.setModel(MatchModel())
106        matchtv.setSortingEnabled(True)
107        matchtv.clicked.connect(self.showDiff)
108        buthbox = QHBoxLayout()
109        matchbtn = QPushButton(_('Accept Selected Matches'))
110        matchbtn.clicked.connect(self.acceptMatch)
111        matchbtn.setEnabled(False)
112        buthbox.addStretch(1)
113        buthbox.addWidget(matchbtn)
114        matchvbox.addWidget(matchtv)
115        matchvbox.addLayout(buthbox)
116        self.matchtv, self.matchbtn = matchtv, matchbtn
117        def matchselect(s, d):
118            count = len(matchtv.selectedIndexes())
119            self.matchbtn.setEnabled(count > 0)
120        selmodel = matchtv.selectionModel()
121        selmodel.selectionChanged.connect(matchselect)
122
123        sp = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
124        sp.setHorizontalStretch(1)
125        matchframe.setSizePolicy(sp)
126
127        diffframe = QFrame(hsplit)
128        diffvbox = QVBoxLayout()
129        diffvbox.setContentsMargins(*(2,)*4)
130        diffframe.setLayout(diffvbox)
131
132        difflabel = QLabel(_('<b>Differences from Source to Dest</b>'))
133        diffvbox.addWidget(difflabel)
134        difftb = QTextBrowser()
135        difftb.document().setDefaultStyleSheet(qtlib.thgstylesheet)
136        diffvbox.addWidget(difftb)
137        self.difftb = difftb
138
139        self.stbar = cmdui.ThgStatusBar()
140        layout.addWidget(self.stbar)
141
142        s = QSettings()
143        self.restoreGeometry(s.value('guess/geom').toByteArray())
144        hsplit.restoreState(s.value('guess/hsplit-state').toByteArray())
145        vsplit.restoreState(s.value('guess/vsplit-state').toByteArray())
146        slider.setValue(s.value('guess/simslider').toInt()[0] or 50)
147        self.vsplit, self.hsplit = vsplit, hsplit
148        QTimer.singleShot(0, self.refresh)
149
150    def refresh(self):
151        self.repo.thginvalidate()
152        wctx = self.repo[None]
153        wctx.status(unknown=True)
154        self.unrevlist.clear()
155        dests = []
156        for u in wctx.unknown():
157            dests.append(u)
158        for a in wctx.added():
159            if not wctx[a].renamed():
160                dests.append(a)
161        for x in dests:
162            item = QListWidgetItem(hglib.tounicode(x))
163            item.orig = x
164            self.unrevlist.addItem(item)
165            self.unrevlist.setItemSelected(item, x in self.pats)
166        self.difftb.clear()
167        self.pats = []
168
169    def findRenames(self):
170        'User pressed "find renames" button'
171        if self.thread and self.thread.isRunning():
172            QMessageBox.information(self, _('Search already in progress'),
173                                    _('Cannot start a new search'))
174            return
175        ulist = []
176        for item in self.unrevlist.selectedItems():
177            ulist.append(item.orig)
178        if not ulist:
179            QMessageBox.information(self, _('No rows selected'),
180                                    _('Select one or more rows for search'))
181            return
182
183        pct = self.simslider.value() / 100.0
184        copies = not self.copycheck.isChecked()
185        self.findbtn.setEnabled(False)
186
187        self.matchtv.model().clear()
188        self.thread = RenameSearchThread(self.repo, ulist, pct, copies)
189        self.thread.match.connect(self.rowReceived)
190        self.thread.progress.connect(self.stbar.progress)
191        self.thread.showMessage.connect(self.stbar.showMessage)
192        self.thread.finished.connect(self.searchfinished)
193        self.thread.start()
194
195    def searchfinished(self):
196        self.stbar.clear()
197        for col in xrange(3):
198            self.matchtv.resizeColumnToContents(col)
199        self.findbtn.setEnabled(len(self.unrevlist.selectedItems()))
200
201    def rowReceived(self, args):
202        self.matchtv.model().appendRow(*args)
203
204    def acceptMatch(self):
205        'User pressed "accept match" button'
206        remdests = {}
207        wctx = self.repo[None]
208        for index in self.matchtv.selectionModel().selectedRows():
209            src, dest, percent = self.matchtv.model().getRow(index)
210            if dest in remdests:
211                QMessageBox.warning(self, _('Multiple sources chosen'),
212                    _('You have multiple renames selected for '
213                      'destination file:\n%s. Aborting!') % dest)
214                return
215            remdests[dest] = src
216        for dest, src in remdests.iteritems():
217            if not os.path.exists(self.repo.wjoin(src)):
218                wctx.remove([src]) # !->R
219            wctx.copy(src, dest)
220            self.matchtv.model().remove(dest)
221        self.matchAccepted.emit()
222        self.refresh()
223
224    def showDiff(self, index):
225        'User selected a row in the candidate tree'
226        ctx = self.repo['.']
227        hu = htmlui.htmlui()
228        row = self.matchtv.model().getRow(index)
229        src, dest, percent = self.matchtv.model().getRow(index)
230        aa = self.repo.wread(dest)
231        rr = ctx.filectx(src).data()
232        date = hglib.displaytime(ctx.date())
233        difftext = mdiff.unidiff(rr, date, aa, date, src, dest, None)
234        if not difftext:
235            t = _('%s and %s have identical contents\n\n') % (src, dest)
236            hu.write(t, label='ui.error')
237        else:
238            for t, l in patch.difflabel(difftext.splitlines, True):
239                hu.write(t, label=l)
240        self.difftb.setHtml(hu.getdata()[0])
241
242    def accept(self):
243        s = QSettings()
244        s.setValue('guess/geom', self.saveGeometry())
245        s.setValue('guess/vsplit-state', self.vsplit.saveState())
246        s.setValue('guess/hsplit-state', self.hsplit.saveState())
247        s.setValue('guess/simslider', self.simslider.value())
248        QDialog.accept(self)
249
250    def reject(self):
251        if self.thread and self.thread.isRunning():
252            self.thread.cancel()
253            if self.thread.wait(2000):
254                self.thread = None
255        else:
256            s = QSettings()
257            s.setValue('guess/geom', self.saveGeometry())
258            s.setValue('guess/vsplit-state', self.vsplit.saveState())
259            s.setValue('guess/hsplit-state', self.hsplit.saveState())
260            s.setValue('guess/simslider', self.simslider.value())
261            QDialog.reject(self)
262
263
264class MatchModel(QAbstractTableModel):
265    def __init__(self, parent=None):
266        QAbstractTableModel.__init__(self, parent)
267        self.rows = []
268        self.headers = (_('Source'), _('Dest'), _('% Match'))
269
270    def rowCount(self, parent):
271        return len(self.rows)
272
273    def columnCount(self, parent):
274        return len(self.headers)
275
276    def data(self, index, role):
277        if not index.isValid():
278            return QVariant()
279        if role == Qt.DisplayRole:
280            s = self.rows[index.row()][index.column()]
281            return QVariant(hglib.tounicode(s))
282        '''
283        elif role == Qt.TextColorRole:
284            src, dst, pct = self.rows[index.row()]
285            if pct == 1.0:
286                return QColor('green')
287            else:
288                return QColor('black')
289        elif role == Qt.ToolTipRole:
290            # explain what row means?
291        '''
292        return QVariant()
293
294    def headerData(self, col, orientation, role):
295        if role != Qt.DisplayRole or orientation != Qt.Horizontal:
296            return QVariant()
297        else:
298            return QVariant(self.headers[col])
299
300    def flags(self, index):
301        return Qt.ItemIsSelectable | Qt.ItemIsEnabled
302
303    # Custom methods
304
305    def getRow(self, index):
306        assert index.isValid()
307        return self.rows[index.row()]
308
309    def appendRow(self, *args):
310        self.beginInsertRows(QModelIndex(), len(self.rows), len(self.rows))
311        self.rows.append(args)
312        self.endInsertRows()
313        self.layoutChanged.emit()
314
315    def clear(self):
316        self.beginRemoveRows(QModelIndex(), 0, len(self.rows)-1)
317        self.rows = []
318        self.endRemoveRows()
319        self.layoutChanged.emit()
320
321    def remove(self, dest):
322        i = 0
323        while i < len(self.rows):
324            if self.rows[i][1] == dest:
325                self.beginRemoveRows(QModelIndex(), i, i)
326                self.rows.pop(i)
327                self.endRemoveRows()
328            else:
329                i += 1
330        self.layoutChanged.emit()
331
332    def sort(self, col, order):
333        self.layoutAboutToBeChanged.emit()
334        self.rows.sort(lambda x, y: cmp(x[col], y[col]))
335        if order == Qt.DescendingOrder:
336            self.rows.reverse()
337        self.layoutChanged.emit()
338        self.reset()
339
340    def isEmpty(self):
341        return not bool(self.rows)
342
343class RenameSearchThread(QThread):
344    '''Background thread for searching repository history'''
345    match = pyqtSignal(object)
346    progress = pyqtSignal(QString, object, QString, QString, object)
347    showMessage = pyqtSignal(unicode)
348
349    def __init__(self, repo, ufiles, minpct, copies):
350        super(RenameSearchThread, self).__init__()
351        self.repo = hg.repository(ui.ui(), repo.root)
352        self.ufiles = ufiles
353        self.minpct = minpct
354        self.copies = copies
355        self.stopped = False
356
357    def run(self):
358        def emit(topic, pos, item='', unit='', total=None):
359            self.progress.emit(topic, pos, item, unit, total)
360        self.repo.ui.progress = emit
361        try:
362            self.search(self.repo)
363        except Exception, e:
364            self.showMessage.emit(hglib.tounicode(str(e)))
365
366    def cancel(self):
367        self.stopped = True
368
369    def search(self, repo):
370        wctx = repo[None]
371        pctx = repo['.']
372        if self.copies:
373            wctx.status(clean=True)
374            srcs = wctx.removed() + wctx.deleted()
375            srcs += wctx.modified() + wctx.clean()
376        else:
377            srcs = wctx.removed() + wctx.deleted()
378        added = [wctx[a] for a in self.ufiles]
379        removed = [pctx[a] for a in srcs if a in pctx]
380        # do not consider files of zero length
381        added = sorted([fctx for fctx in added if fctx.size() > 0])
382        removed = sorted([fctx for fctx in removed if fctx.size() > 0])
383        exacts = []
384        gen = similar._findexactmatches(repo, added, removed)
385        for o, n in gen:
386            if self.stopped:
387                return
388            old, new = o.path(), n.path()
389            exacts.append(old)
390            self.match.emit([old, new, '100%'])
391        if self.minpct == 1.0:
392            return
393        removed = [r for r in removed if r.path() not in exacts]
394        gen = similar._findsimilarmatches(repo, added, removed, self.minpct)
395        for o, n, s in gen:
396            if self.stopped:
397                return
398            old, new, sim = o.path(), n.path(), '%d%%' % (s*100)
399            self.match.emit([old, new, sim])
400
401def run(ui, *pats, **opts):
402    from tortoisehg.util import paths
403    from tortoisehg.hgqt import thgrepo
404    repo = thgrepo.repository(None, path=paths.find_root())
405    return DetectRenameDialog(repo, None, *pats)