/tortoisehg/hgqt/guess.py

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