/tortoisehg/hgqt/filedialogs.py

https://bitbucket.org/tortoisehg/hgtk/ · Python · 551 lines · 417 code · 84 blank · 50 comment · 61 complexity · 6c07dc8693f09d6cef064893c4758b04 MD5 · raw file

  1. # Copyright (c) 2003-2010 LOGILAB S.A. (Paris, FRANCE).
  2. # http://www.logilab.fr/ -- mailto:contact@logilab.fr
  3. #
  4. # This program is free software; you can redistribute it and/or modify it under
  5. # the terms of the GNU General Public License as published by the Free Software
  6. # Foundation; either version 2 of the License, or (at your option) any later
  7. # version.
  8. #
  9. # This program is distributed in the hope that it will be useful, but WITHOUT
  10. # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
  11. # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
  12. #
  13. # You should have received a copy of the GNU General Public License along with
  14. # this program; if not, write to the Free Software Foundation, Inc.,
  15. # 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
  16. """
  17. Qt4 dialogs to display hg revisions of a file
  18. """
  19. import difflib
  20. from PyQt4.QtCore import *
  21. from PyQt4.QtGui import *
  22. from PyQt4.Qsci import QsciScintilla
  23. from mercurial import hg
  24. from tortoisehg.util import hglib
  25. from tortoisehg.hgqt.i18n import _
  26. from tortoisehg.hgqt.qtlib import geticon, getfont
  27. from tortoisehg.hgqt.filerevmodel import FileRevModel
  28. from tortoisehg.hgqt.blockmatcher import BlockList, BlockMatch
  29. from tortoisehg.hgqt.lexers import get_lexer
  30. from tortoisehg.hgqt.fileview import HgFileView
  31. from tortoisehg.hgqt.repoview import HgRepoView
  32. from tortoisehg.hgqt.revpanel import RevPanelWidget
  33. sides = ('left', 'right')
  34. otherside = {'left': 'right', 'right': 'left'}
  35. class _AbstractFileDialog(QMainWindow):
  36. def __init__(self, repo, filename, repoviewer=None):
  37. QMainWindow.__init__(self)
  38. self.repo = repo
  39. self._font = getfont('fontdiff').font()
  40. self.setupUi(self)
  41. self.setRepoViewer(repoviewer)
  42. self._show_rev = None
  43. self.filename = filename
  44. self.findLexer()
  45. self.createActions()
  46. self.setupToolbars()
  47. self.setupViews()
  48. self.setupModels()
  49. def setRepoViewer(self, repoviewer=None):
  50. self.repoviewer = repoviewer
  51. if repoviewer:
  52. repoviewer.finished.connect(lambda x: self.setRepoViewer(None))
  53. def reload(self):
  54. 'Reload toolbar action handler'
  55. self.repo.thginvalidate()
  56. self.setupModels()
  57. def findLexer(self):
  58. # try to find a lexer for our file.
  59. f = self.repo.file(self.filename)
  60. head = f.heads()[0]
  61. if f.size(f.rev(head)) < 1e6:
  62. data = f.read(head)
  63. else:
  64. data = '' # too big
  65. lexer = get_lexer(self.filename, data, self)
  66. if lexer:
  67. lexer.setDefaultFont(self._font)
  68. lexer.setFont(self._font)
  69. self.lexer = lexer
  70. def revisionActivated(self, rev):
  71. """
  72. Callback called when a revision is double-clicked in the revisions table
  73. """
  74. if self.repoviewer is None:
  75. # prevent recursive import
  76. from workbench import Workbench
  77. self.repoviewer = Workbench()
  78. self.repoviewer.show()
  79. self.repoviewer.activateWindow()
  80. self.repoviewer.raise_()
  81. self.repoviewer.showRepo(hglib.tounicode(self.repo.root))
  82. self.repoviewer.goto(self.repo.root, rev)
  83. class FileLogDialog(_AbstractFileDialog):
  84. """
  85. A dialog showing a revision graph for a file.
  86. """
  87. def __init__(self, repo, filename, repoviewer=None):
  88. super(FileLogDialog, self).__init__(repo, filename, repoviewer)
  89. self._readSettings()
  90. def closeEvent(self, event):
  91. self._writeSettings()
  92. super(FileLogDialog, self).closeEvent(event)
  93. def _readSettings(self):
  94. s = QSettings()
  95. s.beginGroup('filelog')
  96. try:
  97. self.textView.loadSettings(s, 'fileview')
  98. self.restoreGeometry(s.value('geom').toByteArray())
  99. self.splitter.restoreState(s.value('splitter').toByteArray())
  100. self.revpanel.set_expanded(s.value('revpanel.expanded').toBool())
  101. finally:
  102. s.endGroup()
  103. def _writeSettings(self):
  104. s = QSettings()
  105. s.beginGroup('filelog')
  106. try:
  107. self.textView.saveSettings(s, 'fileview')
  108. s.setValue('revpanel.expanded', self.revpanel.is_expanded())
  109. s.setValue('geom', self.saveGeometry())
  110. s.setValue('splitter', self.splitter.saveState())
  111. finally:
  112. s.endGroup()
  113. def setupUi(self, o):
  114. self.editToolbar = QToolBar(self)
  115. self.addToolBar(Qt.ToolBarArea(Qt.TopToolBarArea), self.editToolbar)
  116. self.actionClose = QAction(self, shortcut=QKeySequence.Close)
  117. self.actionReload = QAction(self, shortcut=QKeySequence.Refresh)
  118. self.editToolbar.addAction(self.actionReload)
  119. self.addAction(self.actionClose)
  120. self.splitter = QSplitter(Qt.Vertical)
  121. self.setCentralWidget(self.splitter)
  122. self.repoview = HgRepoView(self.repo, self.splitter)
  123. self.contentframe = QFrame(self.splitter)
  124. vbox = QVBoxLayout()
  125. vbox.setSpacing(0)
  126. vbox.setMargin(0)
  127. self.contentframe.setLayout(vbox)
  128. self.revpanel = RevPanelWidget(self.repo)
  129. self.revpanel.linkActivated.connect(self.linkActivated)
  130. vbox.addWidget(self.revpanel, 0)
  131. self.textView = HgFileView(self.repo, self)
  132. self.textView.forceMode('file')
  133. vbox.addWidget(self.textView, 1)
  134. @pyqtSlot(unicode)
  135. def linkActivated(self, link):
  136. link = unicode(link)
  137. if ':' in link:
  138. scheme, param = link.split(':', 1)
  139. if scheme == 'cset':
  140. rev = self.repo[param].rev()
  141. return self.goto(rev)
  142. QDesktopServices.openUrl(QUrl(link))
  143. def setupViews(self):
  144. self.textView.setFont(self._font)
  145. self.textView.showMessage.connect(self.statusBar().showMessage)
  146. def setupToolbars(self):
  147. self.editToolbar.addSeparator()
  148. self.editToolbar.addAction(self.actionBack)
  149. self.editToolbar.addAction(self.actionForward)
  150. def setupModels(self):
  151. self.filerevmodel = FileRevModel(self.repo, parent=self)
  152. self.repoview.setModel(self.filerevmodel)
  153. self.repoview.revisionSelected.connect(self.revisionSelected)
  154. self.repoview.revisionActivated.connect(self.revisionActivated)
  155. self.filerevmodel.showMessage.connect(self.statusBar().showMessage)
  156. self.filerevmodel.filled.connect(self.modelFilled)
  157. self.filerevmodel.setFilename(self.filename)
  158. def createActions(self):
  159. self.actionClose.triggered.connect(self.close)
  160. self.actionReload.triggered.connect(self.reload)
  161. self.actionReload.setIcon(geticon('reload'))
  162. self.actionBack = QAction(_('Back'), self, enabled=False,
  163. icon=geticon('back'))
  164. self.actionForward = QAction(_('Forward'), self, enabled=False,
  165. icon=geticon('forward'))
  166. self.repoview.revisionSelected.connect(self._updateHistoryActions)
  167. self.actionBack.triggered.connect(self.repoview.back)
  168. self.actionForward.triggered.connect(self.repoview.forward)
  169. @pyqtSlot()
  170. def _updateHistoryActions(self):
  171. self.actionBack.setEnabled(self.repoview.canGoBack())
  172. self.actionForward.setEnabled(self.repoview.canGoForward())
  173. def modelFilled(self):
  174. self.repoview.resizeColumns()
  175. if self._show_rev is not None:
  176. index = self.filerevmodel.indexFromRev(self._show_rev)
  177. self._show_rev = None
  178. else:
  179. index = self.filerevmodel.index(0,0)
  180. if index is not None:
  181. self.repoview.setCurrentIndex(index)
  182. def revisionSelected(self, rev):
  183. pos = self.textView.verticalScrollBar().value()
  184. ctx = self.filerevmodel.repo.changectx(rev)
  185. self.textView.setContext(ctx)
  186. self.textView.displayFile(self.filerevmodel.graph.filename(rev))
  187. self.textView.verticalScrollBar().setValue(pos)
  188. self.revpanel.set_revision(rev)
  189. self.revpanel.update(repo = self.repo)
  190. def goto(self, rev):
  191. index = self.filerevmodel.indexFromRev(rev)
  192. if index is not None:
  193. self.repoview.setCurrentIndex(index)
  194. else:
  195. self._show_rev = rev
  196. class FileDiffDialog(_AbstractFileDialog):
  197. """
  198. Qt4 dialog to display diffs between different mercurial revisions of a file.
  199. """
  200. def __init__(self, repo, filename, repoviewer=None):
  201. super(FileDiffDialog, self).__init__(repo, filename, repoviewer)
  202. self._readSettings()
  203. def closeEvent(self, event):
  204. self._writeSettings()
  205. super(FileDiffDialog, self).closeEvent(event)
  206. def _readSettings(self):
  207. s = QSettings()
  208. s.beginGroup('filediff')
  209. try:
  210. self.restoreGeometry(s.value('geom').toByteArray())
  211. self.splitter.restoreState(s.value('splitter').toByteArray())
  212. finally:
  213. s.endGroup()
  214. def _writeSettings(self):
  215. s = QSettings()
  216. s.beginGroup('filediff')
  217. try:
  218. s.setValue('geom', self.saveGeometry())
  219. s.setValue('splitter', self.splitter.saveState())
  220. finally:
  221. s.endGroup()
  222. def setupUi(self, o):
  223. self.editToolbar = QToolBar(self)
  224. self.addToolBar(Qt.ToolBarArea(Qt.TopToolBarArea), self.editToolbar)
  225. self.actionClose = QAction(self, shortcut=QKeySequence.Close)
  226. self.actionReload = QAction(self, shortcut=QKeySequence.Refresh)
  227. self.editToolbar.addAction(self.actionReload)
  228. self.addAction(self.actionClose)
  229. def layouttowidget(layout):
  230. w = QWidget()
  231. w.setLayout(layout)
  232. return w
  233. self.splitter = QSplitter(Qt.Vertical)
  234. self.setCentralWidget(self.splitter)
  235. self.horizontalLayout = QHBoxLayout()
  236. self.tableView_revisions_left = HgRepoView(self.repo, self)
  237. self.tableView_revisions_right = HgRepoView(self.repo, self)
  238. self.horizontalLayout.addWidget(self.tableView_revisions_left)
  239. self.horizontalLayout.addWidget(self.tableView_revisions_right)
  240. self.frame = QFrame()
  241. self.splitter.addWidget(layouttowidget(self.horizontalLayout))
  242. self.splitter.addWidget(self.frame)
  243. def setupViews(self):
  244. self.tableViews = {'left': self.tableView_revisions_left,
  245. 'right': self.tableView_revisions_right}
  246. # viewers are Scintilla editors
  247. self.viewers = {}
  248. # block are diff-block displayers
  249. self.block = {}
  250. self.diffblock = BlockMatch(self.frame)
  251. lay = QHBoxLayout(self.frame)
  252. lay.setSpacing(0)
  253. lay.setContentsMargins(0, 0, 0, 0)
  254. for side, idx in (('left', 0), ('right', 3)):
  255. sci = QsciScintilla(self.frame)
  256. sci.setFont(self._font)
  257. sci.verticalScrollBar().setFocusPolicy(Qt.StrongFocus)
  258. sci.setFocusProxy(sci.verticalScrollBar())
  259. sci.verticalScrollBar().installEventFilter(self)
  260. sci.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
  261. sci.setFrameShape(QFrame.NoFrame)
  262. sci.setMarginLineNumbers(1, True)
  263. sci.SendScintilla(sci.SCI_SETSELEOLFILLED, True)
  264. if self.lexer:
  265. sci.setLexer(self.lexer)
  266. sci.setReadOnly(True)
  267. lay.addWidget(sci)
  268. # hide margin 0 (markers)
  269. sci.SendScintilla(sci.SCI_SETMARGINTYPEN, 0, 0)
  270. sci.SendScintilla(sci.SCI_SETMARGINWIDTHN, 0, 0)
  271. # setup margin 1 for line numbers only
  272. sci.SendScintilla(sci.SCI_SETMARGINTYPEN, 1, 1)
  273. sci.SendScintilla(sci.SCI_SETMARGINWIDTHN, 1, 20)
  274. sci.SendScintilla(sci.SCI_SETMARGINMASKN, 1, 0)
  275. # define markers for colorize zones of diff
  276. self.markerplus = sci.markerDefine(QsciScintilla.Background)
  277. sci.SendScintilla(sci.SCI_MARKERSETBACK, self.markerplus, 0xB0FFA0)
  278. self.markerminus = sci.markerDefine(QsciScintilla.Background)
  279. sci.SendScintilla(sci.SCI_MARKERSETBACK, self.markerminus, 0xA0A0FF)
  280. self.markertriangle = sci.markerDefine(QsciScintilla.Background)
  281. sci.SendScintilla(sci.SCI_MARKERSETBACK, self.markertriangle, 0xFFA0A0)
  282. self.viewers[side] = sci
  283. blk = BlockList(self.frame)
  284. blk.linkScrollBar(sci.verticalScrollBar())
  285. self.diffblock.linkScrollBar(sci.verticalScrollBar(), side)
  286. lay.insertWidget(idx, blk)
  287. self.block[side] = blk
  288. lay.insertWidget(2, self.diffblock)
  289. for side in sides:
  290. table = getattr(self, 'tableView_revisions_%s' % side)
  291. table.setTabKeyNavigation(False)
  292. #table.installEventFilter(self)
  293. table.revisionSelected.connect(self.revisionSelected)
  294. table.revisionActivated.connect(self.revisionActivated)
  295. self.viewers[side].verticalScrollBar().valueChanged.connect(
  296. lambda value, side=side: self.vbar_changed(value, side))
  297. self.setTabOrder(table, self.viewers['left'])
  298. self.setTabOrder(self.viewers['left'], self.viewers['right'])
  299. # timer used to fill viewers with diff block markers during GUI idle time
  300. self.timer = QTimer()
  301. self.timer.setSingleShot(False)
  302. self.timer.timeout.connect(self.idle_fill_files)
  303. def setupModels(self):
  304. self.filedata = {'left': None, 'right': None}
  305. self._invbarchanged = False
  306. self.filerevmodel = FileRevModel(self.repo, self.filename, parent=self)
  307. self.filerevmodel.filled.connect(self.modelFilled)
  308. self.tableView_revisions_left.setModel(self.filerevmodel)
  309. self.tableView_revisions_right.setModel(self.filerevmodel)
  310. def createActions(self):
  311. self.actionClose.triggered.connect(self.close)
  312. self.actionReload.triggered.connect(self.reload)
  313. self.actionReload.setIcon(geticon('reload'))
  314. self.actionNextDiff = QAction(geticon('down'), 'Next diff', self)
  315. self.actionNextDiff.setShortcut('Alt+Down')
  316. self.actionNextDiff.triggered.connect(self.nextDiff)
  317. self.actionPrevDiff = QAction(geticon('up'), 'Previous diff', self)
  318. self.actionPrevDiff.setShortcut('Alt+Up')
  319. self.actionPrevDiff.triggered.connect(self.prevDiff)
  320. self.actionNextDiff.setEnabled(False)
  321. self.actionPrevDiff.setEnabled(False)
  322. def setupToolbars(self):
  323. self.editToolbar.addSeparator()
  324. self.editToolbar.addAction(self.actionNextDiff)
  325. self.editToolbar.addAction(self.actionPrevDiff)
  326. def modelFilled(self):
  327. self.tableView_revisions_left.resizeColumns()
  328. self.tableView_revisions_right.resizeColumns()
  329. if self._show_rev is not None:
  330. rev = self._show_rev
  331. self._show_rev = None
  332. else:
  333. rev = self.filerevmodel.graph[0].rev
  334. self.goto(rev)
  335. def revisionSelected(self, rev):
  336. if self.sender() is self.tableView_revisions_right:
  337. side = 'right'
  338. else:
  339. side = 'left'
  340. path = self.filerevmodel.graph.nodesdict[rev].extra[0]
  341. fc = self.repo.changectx(rev).filectx(path)
  342. self.filedata[side] = fc.data().splitlines()
  343. self.update_diff(keeppos=otherside[side])
  344. def goto(self, rev):
  345. index = self.filerevmodel.indexFromRev(rev)
  346. if index is not None:
  347. if index.row() == 0:
  348. index = self.filerevmodel.index(1, 0)
  349. self.tableView_revisions_left.setCurrentIndex(index)
  350. index = self.filerevmodel.index(0, 0)
  351. self.tableView_revisions_right.setCurrentIndex(index)
  352. else:
  353. self._show_rev = rev
  354. def setDiffNavActions(self, pos=0):
  355. hasdiff = (self.diffblock.nDiffs() > 0)
  356. self.actionNextDiff.setEnabled(hasdiff and pos != 1)
  357. self.actionPrevDiff.setEnabled(hasdiff and pos != -1)
  358. def nextDiff(self):
  359. self.setDiffNavActions(self.diffblock.nextDiff())
  360. def prevDiff(self):
  361. self.setDiffNavActions(self.diffblock.prevDiff())
  362. def update_page_steps(self, keeppos=None):
  363. for side in sides:
  364. self.block[side].syncPageStep()
  365. self.diffblock.syncPageStep()
  366. if keeppos:
  367. side, pos = keeppos
  368. self.viewers[side].verticalScrollBar().setValue(pos)
  369. def idle_fill_files(self):
  370. # we make a burst of diff-lines computed at once, but we
  371. # disable GUI updates for efficiency reasons, then only
  372. # refresh GUI at the end of the burst
  373. for side in sides:
  374. self.viewers[side].setUpdatesEnabled(False)
  375. self.block[side].setUpdatesEnabled(False)
  376. self.diffblock.setUpdatesEnabled(False)
  377. for n in range(30): # burst pool
  378. if self._diff is None or not self._diff.get_opcodes():
  379. self._diff = None
  380. self.timer.stop()
  381. self.setDiffNavActions(-1)
  382. break
  383. tag, alo, ahi, blo, bhi = self._diff.get_opcodes().pop(0)
  384. w = self.viewers['left']
  385. cposl = w.SendScintilla(w.SCI_GETENDSTYLED)
  386. w = self.viewers['right']
  387. cposr = w.SendScintilla(w.SCI_GETENDSTYLED)
  388. if tag == 'replace':
  389. self.block['left'].addBlock('x', alo, ahi)
  390. self.block['right'].addBlock('x', blo, bhi)
  391. self.diffblock.addBlock('x', alo, ahi, blo, bhi)
  392. w = self.viewers['left']
  393. for i in range(alo, ahi):
  394. w.markerAdd(i, self.markertriangle)
  395. w = self.viewers['right']
  396. for i in range(blo, bhi):
  397. w.markerAdd(i, self.markertriangle)
  398. elif tag == 'delete':
  399. self.block['left'].addBlock('-', alo, ahi)
  400. self.diffblock.addBlock('-', alo, ahi, blo, bhi)
  401. w = self.viewers['left']
  402. for i in range(alo, ahi):
  403. w.markerAdd(i, self.markerminus)
  404. elif tag == 'insert':
  405. self.block['right'].addBlock('+', blo, bhi)
  406. self.diffblock.addBlock('+', alo, ahi, blo, bhi)
  407. w = self.viewers['right']
  408. for i in range(blo, bhi):
  409. w.markerAdd(i, self.markerplus)
  410. elif tag == 'equal':
  411. pass
  412. else:
  413. raise ValueError, 'unknown tag %r' % (tag,)
  414. # ok, let's enable GUI refresh for code viewers and diff-block displayers
  415. for side in sides:
  416. self.viewers[side].setUpdatesEnabled(True)
  417. self.block[side].setUpdatesEnabled(True)
  418. self.diffblock.setUpdatesEnabled(True)
  419. def update_diff(self, keeppos=None):
  420. """
  421. Recompute the diff, display files and starts the timer
  422. responsible for filling diff markers
  423. """
  424. if keeppos:
  425. pos = self.viewers[keeppos].verticalScrollBar().value()
  426. keeppos = (keeppos, pos)
  427. for side in sides:
  428. self.viewers[side].clear()
  429. self.block[side].clear()
  430. self.diffblock.clear()
  431. if None not in self.filedata.values():
  432. if self.timer.isActive():
  433. self.timer.stop()
  434. for side in sides:
  435. self.viewers[side].setMarginWidth(1, "00%s" % len(self.filedata[side]))
  436. self._diff = difflib.SequenceMatcher(None, self.filedata['left'],
  437. self.filedata['right'])
  438. blocks = self._diff.get_opcodes()[:]
  439. self._diffmatch = {'left': [x[1:3] for x in blocks],
  440. 'right': [x[3:5] for x in blocks]}
  441. for side in sides:
  442. self.viewers[side].setText('\n'.join(self.filedata[side]))
  443. self.update_page_steps(keeppos)
  444. self.timer.start()
  445. def vbar_changed(self, value, side):
  446. """
  447. Callback called when the vertical scrollbar of a file viewer
  448. is changed, so we can update the position of the other file
  449. viewer.
  450. """
  451. if self._invbarchanged:
  452. # prevent loops in changes (left -> right -> left ...)
  453. return
  454. self._invbarchanged = True
  455. oside = otherside[side]
  456. for i, (lo, hi) in enumerate(self._diffmatch[side]):
  457. if lo <= value < hi:
  458. break
  459. dv = value - lo
  460. blo, bhi = self._diffmatch[oside][i]
  461. vbar = self.viewers[oside].verticalScrollBar()
  462. if (dv) < (bhi - blo):
  463. bvalue = blo + dv
  464. else:
  465. bvalue = bhi
  466. vbar.setValue(bvalue)
  467. self._invbarchanged = False