This instance will be upgraded to Heptapod 0.21.0rc2 on 2021-04-16 at 11:00 UTC+2 (a few minutes of down time)

guess.py 16.9 KB
Newer Older
1 2 3 4 5 6 7
# guess.py - TortoiseHg's dialogs for detecting copies and renames
#
# Copyright 2010 Steve Borho <steve@borho.org>
#
# This software may be used and distributed according to the terms of the
# GNU General Public License version 2, incorporated herein by reference.

8
from __future__ import absolute_import
9

10
import os
11

12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
from .qtcore import (
    QAbstractTableModel,
    QModelIndex,
    QSettings,
    QThread,
    QTimer,
    Qt,
    pyqtSignal,
)
from .qtgui import (
    QAbstractItemView,
    QCheckBox,
    QDialog,
    QFrame,
    QHBoxLayout,
    QLabel,
    QListWidget,
    QListWidgetItem,
    QMessageBox,
    QPushButton,
    QSizePolicy,
    QSlider,
    QSplitter,
    QTextBrowser,
    QToolButton,
    QTreeView,
    QVBoxLayout,
)
40

41 42 43 44
from hgext.largefiles import (
    lfutil,
)

45 46 47
from mercurial import (
    hg,
    patch,
48
    pycompat,
49 50
    similar,
)
51

52 53 54 55 56 57 58 59 60 61
from ..util import (
    hglib,
    thread2,
)
from ..util.i18n import _
from . import (
    cmdui,
    htmlui,
    qtlib,
)
62

Steve Borho's avatar
Steve Borho committed
63 64 65 66
# Techincal debt
# Try to cut down on the jitter when findRenames is pressed.  May
# require a splitter.

67 68 69 70
class DetectRenameDialog(QDialog):
    'Detect renames after they occur'
    matchAccepted = pyqtSignal()

71
    def __init__(self, repoagent, parent, *pats):
72 73
        QDialog.__init__(self, parent)

74
        self._repoagent = repoagent
75
        self.pats = pats
Steve Borho's avatar
Steve Borho committed
76
        self.thread = None
77

78 79
        self.setWindowTitle(_('Detect Copies/Renames in %s')
                            % repoagent.displayName())
80
        self.setWindowIcon(qtlib.geticon('thg-guess'))
81
        self.setWindowFlags(Qt.Window)
82 83

        layout = QVBoxLayout()
Steve Borho's avatar
Steve Borho committed
84
        layout.setContentsMargins(*(2,)*4)
85 86 87 88 89 90 91 92
        self.setLayout(layout)

        # vsplit for top & diff
        vsplit = QSplitter(Qt.Horizontal)
        utframe = QFrame(vsplit)
        matchframe = QFrame(vsplit)

        utvbox = QVBoxLayout()
Steve Borho's avatar
Steve Borho committed
93
        utvbox.setContentsMargins(*(2,)*4)
94 95
        utframe.setLayout(utvbox)
        matchvbox = QVBoxLayout()
Steve Borho's avatar
Steve Borho committed
96
        matchvbox.setContentsMargins(*(2,)*4)
97 98 99 100 101
        matchframe.setLayout(matchvbox)

        hsplit = QSplitter(Qt.Vertical)
        layout.addWidget(hsplit)
        hsplit.addWidget(vsplit)
Angel Ezquerra's avatar
Angel Ezquerra committed
102 103
        utheader = QHBoxLayout()
        utvbox.addLayout(utheader)
104 105

        utlbl = QLabel(_('<b>Unrevisioned Files</b>'))
Angel Ezquerra's avatar
Angel Ezquerra committed
106 107 108 109 110 111 112 113
        utheader.addWidget(utlbl)

        self.refreshBtn = tb = QToolButton()
        tb.setToolTip(_('Refresh file list'))
        tb.setIcon(qtlib.geticon('view-refresh'))
        tb.clicked.connect(self.refresh)
        utheader.addWidget(tb)

114
        self.unrevlist = QListWidget()
Steve Borho's avatar
Steve Borho committed
115
        self.unrevlist.setSelectionMode(QAbstractItemView.ExtendedSelection)
116
        self.unrevlist.doubleClicked.connect(self.onUnrevDoubleClicked)
117 118
        utvbox.addWidget(self.unrevlist)

Steve Borho's avatar
Steve Borho committed
119 120 121 122 123 124 125 126 127
        simhbox = QHBoxLayout()
        utvbox.addLayout(simhbox)
        lbl = QLabel()
        slider = QSlider(Qt.Horizontal)
        slider.setRange(0, 100)
        slider.setTickInterval(10)
        slider.setPageStep(10)
        slider.setTickPosition(QSlider.TicksBelow)
        slider.changefunc = lambda v: lbl.setText(
Wagner Bruna's avatar
Wagner Bruna committed
128
                            _('Min Similarity: %d%%') % v)
Steve Borho's avatar
Steve Borho committed
129 130 131 132 133 134 135 136 137
        slider.valueChanged.connect(slider.changefunc)
        self.simslider = slider
        lbl.setBuddy(slider)
        simhbox.addWidget(lbl)
        simhbox.addWidget(slider, 1)

        buthbox = QHBoxLayout()
        utvbox.addLayout(buthbox)
        copycheck = QCheckBox(_('Only consider deleted files'))
138 139
        copycheck.setToolTip(_('Uncheck to consider all revisioned files '
                               'for copy sources'))
Steve Borho's avatar
Steve Borho committed
140
        copycheck.setChecked(True)
141
        findrenames = QPushButton(_('Find Renames'))
Steve Borho's avatar
Steve Borho committed
142
        findrenames.setToolTip(_('Find copy and/or rename sources'))
143
        findrenames.setEnabled(False)
Steve Borho's avatar
Steve Borho committed
144 145 146 147 148 149
        findrenames.clicked.connect(self.findRenames)
        buthbox.addWidget(copycheck)
        buthbox.addStretch(1)
        buthbox.addWidget(findrenames)
        self.findbtn, self.copycheck = findrenames, copycheck

150 151
        matchlbl = QLabel(_('<b>Candidate Matches</b>'))
        matchvbox.addWidget(matchlbl)
Steve Borho's avatar
Steve Borho committed
152 153 154 155 156
        matchtv = QTreeView()
        matchtv.setSelectionMode(QTreeView.ExtendedSelection)
        matchtv.setItemsExpandable(False)
        matchtv.setRootIsDecorated(False)
        matchtv.setModel(MatchModel())
157
        matchtv.setSortingEnabled(True)
158
        matchtv.selectionModel().selectionChanged.connect(self.showDiff)
Steve Borho's avatar
Steve Borho committed
159
        buthbox = QHBoxLayout()
160
        matchbtn = QPushButton(_('Accept All Matches'))
Steve Borho's avatar
Steve Borho committed
161 162 163 164
        matchbtn.clicked.connect(self.acceptMatch)
        matchbtn.setEnabled(False)
        buthbox.addStretch(1)
        buthbox.addWidget(matchbtn)
Steve Borho's avatar
Steve Borho committed
165
        matchvbox.addWidget(matchtv)
Steve Borho's avatar
Steve Borho committed
166
        matchvbox.addLayout(buthbox)
167 168 169
        self.matchtv, self.matchbtn = matchtv, matchbtn
        def matchselect(s, d):
            count = len(matchtv.selectedIndexes())
170 171 172 173
            if count:
                self.matchbtn.setText(_('Accept Selected Matches'))
            else:
                self.matchbtn.setText(_('Accept All Matches'))
174 175
        selmodel = matchtv.selectionModel()
        selmodel.selectionChanged.connect(matchselect)
176

177 178 179 180
        sp = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
        sp.setHorizontalStretch(1)
        matchframe.setSizePolicy(sp)

181 182
        diffframe = QFrame(hsplit)
        diffvbox = QVBoxLayout()
Steve Borho's avatar
Steve Borho committed
183
        diffvbox.setContentsMargins(*(2,)*4)
184 185 186 187 188
        diffframe.setLayout(diffvbox)

        difflabel = QLabel(_('<b>Differences from Source to Dest</b>'))
        diffvbox.addWidget(difflabel)
        difftb = QTextBrowser()
189
        difftb.document().setDefaultStyleSheet(qtlib.thgstylesheet)
190 191 192
        diffvbox.addWidget(difftb)
        self.difftb = difftb

193 194
        self.stbar = cmdui.ThgStatusBar()
        layout.addWidget(self.stbar)
195

196
        s = QSettings()
197 198 199
        self.restoreGeometry(qtlib.readByteArray(s, 'guess/geom'))
        hsplit.restoreState(qtlib.readByteArray(s, 'guess/hsplit-state'))
        vsplit.restoreState(qtlib.readByteArray(s, 'guess/vsplit-state'))
200
        slider.setValue(qtlib.readInt(s, 'guess/simslider') or 50)
201 202 203
        self.vsplit, self.hsplit = vsplit, hsplit
        QTimer.singleShot(0, self.refresh)

204 205 206 207
    @property
    def repo(self):
        return self._repoagent.rawRepo()

208
    def refresh(self):
Steve Borho's avatar
Steve Borho committed
209
        self.repo.thginvalidate()
210 211 212
        with lfutil.lfstatus(self.repo):
            wctx = self.repo[None]
            ws = wctx.status(listunknown=True)
213
        self.unrevlist.clear()
Steve Borho's avatar
Steve Borho committed
214
        dests = []
215
        for u in ws.unknown:
Steve Borho's avatar
Steve Borho committed
216
            dests.append(u)
217
        for a in ws.added:
218
            if not wctx[a].renamed():
Steve Borho's avatar
Steve Borho committed
219 220 221 222 223
                dests.append(a)
        for x in dests:
            item = QListWidgetItem(hglib.tounicode(x))
            item.orig = x
            self.unrevlist.addItem(item)
224
            item.setSelected(x in self.pats)
225 226 227 228
        if dests:
            self.findbtn.setEnabled(True)
        else:
            self.findbtn.setEnabled(False)
Steve Borho's avatar
Steve Borho committed
229
        self.difftb.clear()
230
        self.pats = []
231 232
        model = self.matchtv.model()
        assert model is not None
233
        self.matchbtn.setEnabled(bool(model.rows))
Steve Borho's avatar
Steve Borho committed
234 235

    def findRenames(self):
236
        'User pressed "find renames" button'
Steve Borho's avatar
Steve Borho committed
237 238 239 240
        if self.thread and self.thread.isRunning():
            QMessageBox.information(self, _('Search already in progress'),
                                    _('Cannot start a new search'))
            return
241

242 243 244
        # TODO: better to use data(role) instead
        ulist = [it.orig
                 for it in self.unrevlist.selectedItems()]  # pytype: disable=attribute-error
245 246 247
        if not ulist:
            # When no files are selected, look for all files
            ulist = [self.unrevlist.item(n).orig
248
                     for n in range(self.unrevlist.count())]  # pytype: disable=attribute-error
249

Steve Borho's avatar
Steve Borho committed
250
        if not ulist:
251 252
            QMessageBox.information(self, _('No files to find'),
                _('There are no files that may have been renamed'))
Steve Borho's avatar
Steve Borho committed
253 254
            return

255
        pct = self.simslider.value() / 100.0
Steve Borho's avatar
Steve Borho committed
256 257 258
        copies = not self.copycheck.isChecked()
        self.findbtn.setEnabled(False)

259 260 261
        model = self.matchtv.model()
        assert model is not None
        model.clear()
Steve Borho's avatar
Steve Borho committed
262
        self.thread = RenameSearchThread(self.repo, ulist, pct, copies)
Steve Borho's avatar
Steve Borho committed
263
        self.thread.match.connect(self.rowReceived)
264
        self.thread.progress.connect(self.stbar.progress)
265
        self.thread.showMessage.connect(self.stbar.showMessage)
266
        self.thread.finished.connect(self.searchfinished)
Steve Borho's avatar
Steve Borho committed
267
        self.thread.start()
268

269
    def searchfinished(self):
270
        self.stbar.clearProgress()
271
        for col in pycompat.xrange(3):
Steve Borho's avatar
Steve Borho committed
272
            self.matchtv.resizeColumnToContents(col)
273
        self.findbtn.setEnabled(bool(self.unrevlist.count()))
274 275
        model = self.matchtv.model()
        assert model is not None
276
        self.matchbtn.setEnabled(bool(model.rows))
Steve Borho's avatar
Steve Borho committed
277 278

    def rowReceived(self, args):
279 280 281
        model = self.matchtv.model()
        assert model is not None
        model.appendRow(*args)
Steve Borho's avatar
Steve Borho committed
282

283 284
    def acceptMatch(self):
        'User pressed "accept match" button'
285
        remdests = {}
286
        wctx = self.repo[None]
287 288
        model = self.matchtv.model()
        assert model is not None
289 290 291

        # If no rows are selected, ask the user if he'd like to accept all renames
        if self.matchtv.selectionModel().hasSelection():
292
            itemList = [model.getRow(index) \
293
                for index in self.matchtv.selectionModel().selectedRows()]
294
        else:
295
            itemList = model.rows
296 297 298

        for item in itemList:
            src, dest, percent = item
299
            if dest in remdests:
300
                udest = hglib.tounicode(dest)
301 302
                QMessageBox.warning(self, _('Multiple sources chosen'),
                    _('You have multiple renames selected for '
303
                      'destination file:\n%s. Aborting!') % udest)
304 305
                return
            remdests[dest] = src
306
        for dest, src in remdests.items():
307
            if not os.path.exists(self.repo.wjoin(src)):
308
                wctx.forget([src]) # !->R
309
            wctx.copy(src, dest)
310
            model.remove(dest)
Steve Borho's avatar
Steve Borho committed
311
        self.matchAccepted.emit()
312 313
        self.refresh()

314
    def showDiff(self, index):
315
        'User selected a row in the candidate tree'
316 317 318 319
        indexes = index.indexes()
        if not indexes:
            return
        index = indexes[0]
320
        ctx = self.repo[b'.']
321
        hu = htmlui.htmlui(self.repo.ui)
322 323 324 325
        model = self.matchtv.model()
        assert model is not None
        row = model.getRow(index)
        src, dest, percent = model.getRow(index)
326 327
        aa = self.repo.wread(dest)
        rr = ctx.filectx(src).data()
Steve Borho's avatar
Steve Borho committed
328
        date = hglib.displaytime(ctx.date())
329
        difftext = hglib.unidifftext(rr, date, aa, date, src, dest)
330
        if not difftext:
331 332
            t = _('%s and %s have identical contents\n\n') % \
                    (hglib.tounicode(src), hglib.tounicode(dest))
kiilerix's avatar
kiilerix committed
333
            hu.write(t, label=b'ui.error')
334
        else:
335
            for t, l in patch.difflabel(difftext.splitlines, True):
336
                hu.write(t, label=l)
337
        self.difftb.setHtml(hglib.tounicode(hu.getdata()[0]))
338

339
    def onUnrevDoubleClicked(self, index):
340 341 342
        model = self.unrevlist.model()
        assert model is not None
        file = hglib.fromunicode(model.data(index))
343 344
        qtlib.editfiles(self.repo, [file])

345 346 347 348 349
    def accept(self):
        s = QSettings()
        s.setValue('guess/geom', self.saveGeometry())
        s.setValue('guess/vsplit-state', self.vsplit.saveState())
        s.setValue('guess/hsplit-state', self.hsplit.saveState())
Steve Borho's avatar
Steve Borho committed
350
        s.setValue('guess/simslider', self.simslider.value())
351 352 353
        QDialog.accept(self)

    def reject(self):
354
        if self.thread and self.thread.isRunning():
355 356 357
            self.thread.cancel()
            if self.thread.wait(2000):
                self.thread = None
358 359 360 361 362
        else:
            s = QSettings()
            s.setValue('guess/geom', self.saveGeometry())
            s.setValue('guess/vsplit-state', self.vsplit.saveState())
            s.setValue('guess/hsplit-state', self.hsplit.saveState())
Steve Borho's avatar
Steve Borho committed
363
            s.setValue('guess/simslider', self.simslider.value())
364
            QDialog.reject(self)
365 366


367
def _aspercent(s):
368 369
    # i18n: percent format
    return _('%d%%') % (s * 100)
370

Steve Borho's avatar
Steve Borho committed
371 372 373 374 375
class MatchModel(QAbstractTableModel):
    def __init__(self, parent=None):
        QAbstractTableModel.__init__(self, parent)
        self.rows = []
        self.headers = (_('Source'), _('Dest'), _('% Match'))
376
        self.displayformats = (hglib.tounicode, hglib.tounicode, _aspercent)
Steve Borho's avatar
Steve Borho committed
377 378 379 380 381 382 383 384 385

    def rowCount(self, parent):
        return len(self.rows)

    def columnCount(self, parent):
        return len(self.headers)

    def data(self, index, role):
        if not index.isValid():
Steve Borho's avatar
Steve Borho committed
386
            return None
Steve Borho's avatar
Steve Borho committed
387 388
        if role == Qt.DisplayRole:
            s = self.rows[index.row()][index.column()]
389
            f = self.displayformats[index.column()]
Steve Borho's avatar
Steve Borho committed
390
            return f(s)
Steve Borho's avatar
Steve Borho committed
391 392 393 394 395 396 397 398 399 400
        '''
        elif role == Qt.TextColorRole:
            src, dst, pct = self.rows[index.row()]
            if pct == 1.0:
                return QColor('green')
            else:
                return QColor('black')
        elif role == Qt.ToolTipRole:
            # explain what row means?
        '''
Steve Borho's avatar
Steve Borho committed
401
        return None
Steve Borho's avatar
Steve Borho committed
402 403 404

    def headerData(self, col, orientation, role):
        if role != Qt.DisplayRole or orientation != Qt.Horizontal:
Steve Borho's avatar
Steve Borho committed
405
            return None
Steve Borho's avatar
Steve Borho committed
406
        else:
Steve Borho's avatar
Steve Borho committed
407
            return self.headers[col]
Steve Borho's avatar
Steve Borho committed
408 409 410 411 412 413 414 415 416 417 418 419

    def flags(self, index):
        return Qt.ItemIsSelectable | Qt.ItemIsEnabled

    # Custom methods

    def getRow(self, index):
        assert index.isValid()
        return self.rows[index.row()]

    def appendRow(self, *args):
        self.beginInsertRows(QModelIndex(), len(self.rows), len(self.rows))
Steve Borho's avatar
Steve Borho committed
420
        self.rows.append(args)
Steve Borho's avatar
Steve Borho committed
421
        self.endInsertRows()
Steve Borho's avatar
Steve Borho committed
422
        self.layoutChanged.emit()
Steve Borho's avatar
Steve Borho committed
423 424 425 426 427

    def clear(self):
        self.beginRemoveRows(QModelIndex(), 0, len(self.rows)-1)
        self.rows = []
        self.endRemoveRows()
Steve Borho's avatar
Steve Borho committed
428
        self.layoutChanged.emit()
Steve Borho's avatar
Steve Borho committed
429

430 431 432 433 434 435 436 437 438
    def remove(self, dest):
        i = 0
        while i < len(self.rows):
            if self.rows[i][1] == dest:
                self.beginRemoveRows(QModelIndex(), i, i)
                self.rows.pop(i)
                self.endRemoveRows()
            else:
                i += 1
Steve Borho's avatar
Steve Borho committed
439
        self.layoutChanged.emit()
440

Steve Borho's avatar
Steve Borho committed
441
    def sort(self, col, order):
442
        self.beginResetModel()
Steve Borho's avatar
Steve Borho committed
443
        self.layoutAboutToBeChanged.emit()
444 445
        self.rows.sort(key=lambda x: x[col],
                       reverse=(order == Qt.DescendingOrder))
Steve Borho's avatar
Steve Borho committed
446
        self.layoutChanged.emit()
447
        self.endResetModel()
Steve Borho's avatar
Steve Borho committed
448 449 450 451

    def isEmpty(self):
        return not bool(self.rows)

452 453
class RenameSearchThread(QThread):
    '''Background thread for searching repository history'''
Steve Borho's avatar
Steve Borho committed
454
    match = pyqtSignal(object)
455
    progress = pyqtSignal(str, object, str, str, object)
456
    showMessage = pyqtSignal(str)
457 458 459

    def __init__(self, repo, ufiles, minpct, copies):
        super(RenameSearchThread, self).__init__()
460
        self.repo = hg.repository(hglib.loadui(), repo.root)
461 462 463
        self.ufiles = ufiles
        self.minpct = minpct
        self.copies = copies
464
        self.threadid = None
465 466

    def run(self):
Steve Borho's avatar
Steve Borho committed
467
        def emit(topic, pos, item='', unit='', total=None):
468 469 470
            topic = hglib.tounicode(topic or '')
            item = hglib.tounicode(item or '')
            unit = hglib.tounicode(unit or '')
Steve Borho's avatar
Steve Borho committed
471 472
            self.progress.emit(topic, pos, item, unit, total)
        self.repo.ui.progress = emit
473
        self.threadid = int(self.currentThreadId())
474
        try:
Yuya Nishihara's avatar
Yuya Nishihara committed
475 476 477
            self.search(self.repo)
        except KeyboardInterrupt:
            pass
478
        except Exception as e:
Yuya Nishihara's avatar
Yuya Nishihara committed
479
            self.showMessage.emit(hglib.tounicode(str(e)))
480 481
        finally:
            self.threadid = None
482

483
    def cancel(self):
484 485 486 487 488 489 490
        tid = self.threadid
        if tid is None:
            return
        try:
            thread2._async_raise(tid, KeyboardInterrupt)
        except ValueError:
            pass
491

Steve Borho's avatar
Steve Borho committed
492
    def search(self, repo):
493
        wctx = repo[None]
494
        pctx = repo[b'.']
495
        if self.copies:
496 497 498
            ws = wctx.status(listclean=True)
            srcs = ws.removed + ws.deleted
            srcs += ws.modified + ws.clean
499
        else:
500 501
            ws = wctx.status()
            srcs = ws.removed + ws.deleted
502 503
        added = [wctx[a] for a in sorted(self.ufiles)]
        removed = [pctx[a] for a in sorted(srcs) if a in pctx]
504
        # do not consider files of zero length
505 506
        added = [fctx for fctx in added if fctx.size() > 0]
        removed = [fctx for fctx in removed if fctx.size() > 0]
507
        exacts = []
Steve Borho's avatar
Steve Borho committed
508 509
        gen = similar._findexactmatches(repo, added, removed)
        for o, n in gen:
510
            old, new = o.path(), n.path()
511
            exacts.append(old)
512
            self.match.emit([old, new, 1.0])
Steve Borho's avatar
Steve Borho committed
513 514
        if self.minpct == 1.0:
            return
515
        removed = [r for r in removed if r.path() not in exacts]
Steve Borho's avatar
Steve Borho committed
516 517
        gen = similar._findsimilarmatches(repo, added, removed, self.minpct)
        for o, n, s in gen:
518
            old, new, sim = o.path(), n.path(), s
Steve Borho's avatar
Steve Borho committed
519
            self.match.emit([old, new, sim])