/tortoisehg/hgqt/guess.py
https://bitbucket.org/tortoisehg/hgtk/ · Python · 405 lines · 335 code · 53 blank · 17 comment · 49 complexity · 3bd5822bff07643d95f4aa2d13076a28 MD5 · raw file
- # 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.
- import os
- from mercurial import hg, ui, mdiff, similar, patch
- from tortoisehg.util import hglib, shlib
- from tortoisehg.hgqt.i18n import _
- from tortoisehg.hgqt import qtlib, htmlui, cmdui
- from PyQt4.QtCore import *
- from PyQt4.QtGui import *
- # Techincal debt
- # Try to cut down on the jitter when findRenames is pressed. May
- # require a splitter.
- class DetectRenameDialog(QDialog):
- 'Detect renames after they occur'
- matchAccepted = pyqtSignal()
- def __init__(self, repo, parent, *pats):
- QDialog.__init__(self, parent)
- self.repo = repo
- self.pats = pats
- self.thread = None
- self.setWindowTitle(_('Detect Copies/Renames in %s') % repo.displayname)
- f = self.windowFlags()
- self.setWindowFlags(f & ~Qt.WindowContextHelpButtonHint)
- layout = QVBoxLayout()
- layout.setContentsMargins(*(2,)*4)
- self.setLayout(layout)
- # vsplit for top & diff
- vsplit = QSplitter(Qt.Horizontal)
- utframe = QFrame(vsplit)
- matchframe = QFrame(vsplit)
- utvbox = QVBoxLayout()
- utvbox.setContentsMargins(*(2,)*4)
- utframe.setLayout(utvbox)
- matchvbox = QVBoxLayout()
- matchvbox.setContentsMargins(*(2,)*4)
- matchframe.setLayout(matchvbox)
- hsplit = QSplitter(Qt.Vertical)
- layout.addWidget(hsplit)
- hsplit.addWidget(vsplit)
- utlbl = QLabel(_('<b>Unrevisioned Files</b>'))
- utvbox.addWidget(utlbl)
- self.unrevlist = QListWidget()
- self.unrevlist.setSelectionMode(QAbstractItemView.ExtendedSelection)
- utvbox.addWidget(self.unrevlist)
- 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(
- _('Min Simularity: %d%%') % v)
- 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'))
- copycheck.setToolTip(_('Uncheck to consider all revisioned files'
- ' for copy sources'))
- copycheck.setChecked(True)
- findrenames = QPushButton(_('Find Rename'))
- findrenames.setToolTip(_('Find copy and/or rename sources'))
- findrenames.setEnabled(False)
- findrenames.clicked.connect(self.findRenames)
- buthbox.addWidget(copycheck)
- buthbox.addStretch(1)
- buthbox.addWidget(findrenames)
- self.findbtn, self.copycheck = findrenames, copycheck
- def itemselect():
- self.findbtn.setEnabled(len(self.unrevlist.selectedItems()))
- self.unrevlist.itemSelectionChanged.connect(itemselect)
- matchlbl = QLabel(_('<b>Candidate Matches</b>'))
- matchvbox.addWidget(matchlbl)
- matchtv = QTreeView()
- matchtv.setSelectionMode(QTreeView.ExtendedSelection)
- matchtv.setItemsExpandable(False)
- matchtv.setRootIsDecorated(False)
- matchtv.setModel(MatchModel())
- matchtv.setSortingEnabled(True)
- matchtv.clicked.connect(self.showDiff)
- buthbox = QHBoxLayout()
- matchbtn = QPushButton(_('Accept Selected Matches'))
- matchbtn.clicked.connect(self.acceptMatch)
- matchbtn.setEnabled(False)
- buthbox.addStretch(1)
- buthbox.addWidget(matchbtn)
- matchvbox.addWidget(matchtv)
- matchvbox.addLayout(buthbox)
- self.matchtv, self.matchbtn = matchtv, matchbtn
- def matchselect(s, d):
- count = len(matchtv.selectedIndexes())
- self.matchbtn.setEnabled(count > 0)
- selmodel = matchtv.selectionModel()
- selmodel.selectionChanged.connect(matchselect)
- sp = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
- sp.setHorizontalStretch(1)
- matchframe.setSizePolicy(sp)
- diffframe = QFrame(hsplit)
- diffvbox = QVBoxLayout()
- diffvbox.setContentsMargins(*(2,)*4)
- diffframe.setLayout(diffvbox)
- difflabel = QLabel(_('<b>Differences from Source to Dest</b>'))
- diffvbox.addWidget(difflabel)
- difftb = QTextBrowser()
- difftb.document().setDefaultStyleSheet(qtlib.thgstylesheet)
- diffvbox.addWidget(difftb)
- self.difftb = difftb
- self.stbar = cmdui.ThgStatusBar()
- layout.addWidget(self.stbar)
- s = QSettings()
- self.restoreGeometry(s.value('guess/geom').toByteArray())
- hsplit.restoreState(s.value('guess/hsplit-state').toByteArray())
- vsplit.restoreState(s.value('guess/vsplit-state').toByteArray())
- slider.setValue(s.value('guess/simslider').toInt()[0] or 50)
- self.vsplit, self.hsplit = vsplit, hsplit
- QTimer.singleShot(0, self.refresh)
- def refresh(self):
- self.repo.thginvalidate()
- wctx = self.repo[None]
- wctx.status(unknown=True)
- self.unrevlist.clear()
- dests = []
- for u in wctx.unknown():
- dests.append(u)
- for a in wctx.added():
- if not wctx[a].renamed():
- dests.append(a)
- for x in dests:
- item = QListWidgetItem(hglib.tounicode(x))
- item.orig = x
- self.unrevlist.addItem(item)
- self.unrevlist.setItemSelected(item, x in self.pats)
- self.difftb.clear()
- self.pats = []
- def findRenames(self):
- 'User pressed "find renames" button'
- if self.thread and self.thread.isRunning():
- QMessageBox.information(self, _('Search already in progress'),
- _('Cannot start a new search'))
- return
- ulist = []
- for item in self.unrevlist.selectedItems():
- ulist.append(item.orig)
- if not ulist:
- QMessageBox.information(self, _('No rows selected'),
- _('Select one or more rows for search'))
- return
- pct = self.simslider.value() / 100.0
- copies = not self.copycheck.isChecked()
- self.findbtn.setEnabled(False)
- self.matchtv.model().clear()
- self.thread = RenameSearchThread(self.repo, ulist, pct, copies)
- self.thread.match.connect(self.rowReceived)
- self.thread.progress.connect(self.stbar.progress)
- self.thread.showMessage.connect(self.stbar.showMessage)
- self.thread.finished.connect(self.searchfinished)
- self.thread.start()
- def searchfinished(self):
- self.stbar.clear()
- for col in xrange(3):
- self.matchtv.resizeColumnToContents(col)
- self.findbtn.setEnabled(len(self.unrevlist.selectedItems()))
- def rowReceived(self, args):
- self.matchtv.model().appendRow(*args)
- def acceptMatch(self):
- 'User pressed "accept match" button'
- remdests = {}
- wctx = self.repo[None]
- for index in self.matchtv.selectionModel().selectedRows():
- src, dest, percent = self.matchtv.model().getRow(index)
- if dest in remdests:
- QMessageBox.warning(self, _('Multiple sources chosen'),
- _('You have multiple renames selected for '
- 'destination file:\n%s. Aborting!') % dest)
- return
- remdests[dest] = src
- for dest, src in remdests.iteritems():
- if not os.path.exists(self.repo.wjoin(src)):
- wctx.remove([src]) # !->R
- wctx.copy(src, dest)
- self.matchtv.model().remove(dest)
- self.matchAccepted.emit()
- self.refresh()
- def showDiff(self, index):
- 'User selected a row in the candidate tree'
- ctx = self.repo['.']
- hu = htmlui.htmlui()
- row = self.matchtv.model().getRow(index)
- src, dest, percent = self.matchtv.model().getRow(index)
- aa = self.repo.wread(dest)
- rr = ctx.filectx(src).data()
- date = hglib.displaytime(ctx.date())
- difftext = mdiff.unidiff(rr, date, aa, date, src, dest, None)
- if not difftext:
- t = _('%s and %s have identical contents\n\n') % (src, dest)
- hu.write(t, label='ui.error')
- else:
- for t, l in patch.difflabel(difftext.splitlines, True):
- hu.write(t, label=l)
- self.difftb.setHtml(hu.getdata()[0])
- 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())
- s.setValue('guess/simslider', self.simslider.value())
- QDialog.accept(self)
- def reject(self):
- if self.thread and self.thread.isRunning():
- self.thread.cancel()
- if self.thread.wait(2000):
- self.thread = None
- 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())
- s.setValue('guess/simslider', self.simslider.value())
- QDialog.reject(self)
- class MatchModel(QAbstractTableModel):
- def __init__(self, parent=None):
- QAbstractTableModel.__init__(self, parent)
- self.rows = []
- self.headers = (_('Source'), _('Dest'), _('% Match'))
- 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():
- return QVariant()
- if role == Qt.DisplayRole:
- s = self.rows[index.row()][index.column()]
- return QVariant(hglib.tounicode(s))
- '''
- 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?
- '''
- return QVariant()
- def headerData(self, col, orientation, role):
- if role != Qt.DisplayRole or orientation != Qt.Horizontal:
- return QVariant()
- else:
- return QVariant(self.headers[col])
- 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))
- self.rows.append(args)
- self.endInsertRows()
- self.layoutChanged.emit()
- def clear(self):
- self.beginRemoveRows(QModelIndex(), 0, len(self.rows)-1)
- self.rows = []
- self.endRemoveRows()
- self.layoutChanged.emit()
- 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
- self.layoutChanged.emit()
- def sort(self, col, order):
- self.layoutAboutToBeChanged.emit()
- self.rows.sort(lambda x, y: cmp(x[col], y[col]))
- if order == Qt.DescendingOrder:
- self.rows.reverse()
- self.layoutChanged.emit()
- self.reset()
- def isEmpty(self):
- return not bool(self.rows)
- class RenameSearchThread(QThread):
- '''Background thread for searching repository history'''
- match = pyqtSignal(object)
- progress = pyqtSignal(QString, object, QString, QString, object)
- showMessage = pyqtSignal(unicode)
- def __init__(self, repo, ufiles, minpct, copies):
- super(RenameSearchThread, self).__init__()
- self.repo = hg.repository(ui.ui(), repo.root)
- self.ufiles = ufiles
- self.minpct = minpct
- self.copies = copies
- self.stopped = False
- def run(self):
- def emit(topic, pos, item='', unit='', total=None):
- self.progress.emit(topic, pos, item, unit, total)
- self.repo.ui.progress = emit
- try:
- self.search(self.repo)
- except Exception, e:
- self.showMessage.emit(hglib.tounicode(str(e)))
- def cancel(self):
- self.stopped = True
- def search(self, repo):
- wctx = repo[None]
- pctx = repo['.']
- if self.copies:
- wctx.status(clean=True)
- srcs = wctx.removed() + wctx.deleted()
- srcs += wctx.modified() + wctx.clean()
- else:
- srcs = wctx.removed() + wctx.deleted()
- added = [wctx[a] for a in self.ufiles]
- removed = [pctx[a] for a in srcs if a in pctx]
- # do not consider files of zero length
- added = sorted([fctx for fctx in added if fctx.size() > 0])
- removed = sorted([fctx for fctx in removed if fctx.size() > 0])
- exacts = []
- gen = similar._findexactmatches(repo, added, removed)
- for o, n in gen:
- if self.stopped:
- return
- old, new = o.path(), n.path()
- exacts.append(old)
- self.match.emit([old, new, '100%'])
- if self.minpct == 1.0:
- return
- removed = [r for r in removed if r.path() not in exacts]
- gen = similar._findsimilarmatches(repo, added, removed, self.minpct)
- for o, n, s in gen:
- if self.stopped:
- return
- old, new, sim = o.path(), n.path(), '%d%%' % (s*100)
- self.match.emit([old, new, sim])
- def run(ui, *pats, **opts):
- from tortoisehg.util import paths
- from tortoisehg.hgqt import thgrepo
- repo = thgrepo.repository(None, path=paths.find_root())
- return DetectRenameDialog(repo, None, *pats)