# -*- coding: utf-8 -*- # Copyright (c) 2003-2012 LOGILAB S.A. (Paris, FRANCE). # http://www.logilab.fr/ -- mailto:contact@logilab.fr # # This program is free software; you can redistribute it and/or modify it under # the terms of the GNU General Public License as published by the Free Software # Foundation; either version 2 of the License, or (at your option) any later # version. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along with # this program. If not, see . """ Qt4 dialogs to display hg revisions of a file """ import os import os.path as osp import difflib from PyQt4 import QtGui, QtCore from PyQt4.QtCore import Qt, pyqtSignal from hgviewlib.application import (FileViewer as _FileViewer, FileDiffViewer as _FileDiffViewer) from hgviewlib.util import tounicode, tohg, build_repo from hgviewlib.qt4.mixins import HgDialogMixin, ActionsMixin, ui2cls from hgviewlib.qt4.hgrepomodel import FileRevModel from hgviewlib.qt4.blockmatcher import BlockList, BlockMatch from hgviewlib.qt4.quickbar import FindInGraphlogQuickBar from hgviewlib.qt4.widgets import SourceViewer sides = ('left', 'right') otherside = {'left': 'right', 'right': 'left'} class AbstractFileDialog(ActionsMixin, HgDialogMixin, QtGui.QMainWindow): def __init__(self, repo, filename, repoviewer=None): self.repo = repo super(AbstractFileDialog, self).__init__() self.load_ui() self.load_config(self.repo) self.setRepoViewer(repoviewer) self._show_rev = None self.filename = filename self.createActions() self.setupToolbars() self.setupViews() self.setupModels() def setRepoViewer(self, repoviewer=None): self.repoviewer = repoviewer if repoviewer: repoviewer.destroyed.connect(lambda x: self.setRepoViewer()) def reload(self): self.repo = build_repo(self.repo.ui, tounicode(self.repo.root)) self.setupModels() def modelFilled(self): self.filerevmodel.filled.disconnect(self.modelFilled) if isinstance(self._show_rev, int): index = self.filerevmodel.indexFromRev(self._show_rev) self._show_rev = None else: index = self.filerevmodel.index(0,0) self.tableView_revisions.setCurrentIndex(index) def revisionActivated(self, rev): """ Callback called when a revision is double-clicked in the revisions table """ if self.repoviewer is None: # prevent recursive import from hgviewlib.qt4.hgrepoviewer import HgRepoViewer self.repoviewer = HgRepoViewer(self.repo) self.repoviewer.goto(rev) self.repoviewer.show() self.repoviewer.activateWindow() self.repoviewer.raise_() class FileViewer(AbstractFileDialog, ui2cls('fileviewer.ui'), _FileViewer): """ A dialog showing a revision graph for a file. """ def setupViews(self): self.textView.setFont(self._font) self.setWindowTitle('hgview filelog: %s' % os.path.abspath(self.filename)) self.textView.message_logged.connect(self.statusBar().showMessage) def setupToolbars(self): self.find_toolbar = FindInGraphlogQuickBar(self) self.find_toolbar.attachFileView(self.textView) self.find_toolbar.revision_selected.connect( self.tableView_revisions.goto) self.find_toolbar.revision_selected[int].connect( self.tableView_revisions.goto) self.find_toolbar.message_logged.connect(self.statusBar().showMessage) self.attachQuickBar(self.find_toolbar) add_actions = self.toolBar_edit.addActions self.toolBar_edit.addSeparator() add_actions(self.tableView_revisions.get_actions('back', 'forward')) self.toolBar_edit.addSeparator() add_actions(self.get_actions('diffmode', 'annmode', 'next', 'prev')) self.attachQuickBar(self.tableView_revisions.goto_toolbar) def setupModels(self): self.filerevmodel = FileRevModel(self.repo) self.tableView_revisions.setModel(self.filerevmodel) self.tableView_revisions.revision_selected.connect( self.revisionSelected) self.tableView_revisions.revision_selected[int].connect( self.revisionSelected) self.tableView_revisions.revision_activated.connect( self.revisionActivated) self.tableView_revisions.revision_activated[int].connect( self.revisionActivated) self.filerevmodel.message_logged.connect( self.statusBar().showMessage, Qt.QueuedConnection) self.filerevmodel.filled.connect(self.modelFilled) self.textView.setMode('file') self.textView.setModel(self.filerevmodel) self.find_toolbar.setModel(self.filerevmodel) self.find_toolbar.setFilterFiles([self.filename]) self.find_toolbar.setMode('file') self.filerevmodel.setFilename(self.filename) def createActions(self): self.add_action( 'close', self.actionClose, icon='quit', callback=lambda: self.close(), ) self.add_action( 'reload', self.actionReload, icon='reload', callback=lambda: self.reload(), ) self.add_action( 'diffmode', self.tr("Diff mode"), menu=self.tr("Mode"), icon='diffmode' , tip=self.tr("Enable/Disable Diff mode"), callback=self.setMode, checked=False, ) self.add_action( 'annmode', self.tr("Annotate mode"), tip=self.tr("Enable/Disable Annotate mode"), callback=self.textView.setAnnotate, checked=False, ) self.add_action( 'next', self.tr("Next hunk"), menu=self.tr("Moves"), icon='down', tip=self.tr("Jump to the next hunk"), keys=[Qt.ALT + Qt.Key_Down], callback=lambda: self.nextDiff(), ) self.add_action( 'prev', self.tr("Prior hunk"), menu=self.tr("Moves"), icon='up', tip=self.tr("Jump to the previous hunk"), keys=[Qt.ALT + Qt.Key_Up], callback=lambda: self.prevDiff(), ) def revisionSelected(self, rev): pos = self.textView.verticalScrollBar().value() ctx = self.filerevmodel.repo[rev] self.textView.setContext(ctx) self.textView.displayFile(self.filerevmodel.graph.filename(rev)) self.textView.verticalScrollBar().setValue(pos) self.set_action('prev', enabled=False) self.textView.filled.connect( lambda self=self: self.set_action('next', enabled=self.textView.fileMode() and self.textView.nDiffs())) def goto(self, rev): index = self.filerevmodel.indexFromRev(rev) if index is not None: self.tableView_revisions.setCurrentIndex(index) else: self._show_rev = rev def setMode(self, mode): self.textView.setMode(mode) self.set_actions('annmode', 'next', 'prev', enabled=not mode) def nextDiff(self): notlast = self.textView.nextDiff() enabled = self.textView.fileMode() and notlast and self.textView.nDiffs() self.set_action('next', enabled=(enabled if enabled is not None else True)) enabled = self.textView.fileMode() and self.textView.nDiffs() self.set_action('prev', enable=(enabled if enabled is not None else True)) def prevDiff(self): notfirst = self.textView.prevDiff() self.set_action('prev', enabled=self.textView.fileMode() and notfirst and self.textView.nDiffs()) self.set_action('next', enabled=self.textView.fileMode() and self.textView.nDiffs()) class FileDiffViewer(AbstractFileDialog, ui2cls('filediffviewer.ui'), _FileDiffViewer): """ Qt4 dialog to display diffs between different mercurial revisions of a file. """ diff_filled = pyqtSignal() def setupViews(self): self.tableView_revisions = self.tableView_revisions_left self.tableViews = {'left': self.tableView_revisions_left, 'right': self.tableView_revisions_right} # viewers are Scintilla editors self.viewers = {} # block are diff-block displayers self.block = {} self.diffblock = BlockMatch(self.frame) lay = QtGui.QHBoxLayout(self.frame) lay.setSpacing(0) lay.setContentsMargins(0, 0, 0, 0) for side, idx in (('left', 0), ('right', 3)): textview = SourceViewer(self.frame) textview.setFont(self._font) textview.verticalScrollBar().setFocusPolicy(Qt.StrongFocus) textview.setFocusProxy(textview.verticalScrollBar()) textview.verticalScrollBar().installEventFilter(self) lay.addWidget(textview) self.viewers[side] = textview blk = BlockList(self.frame) blk.linkScrollBar(textview.verticalScrollBar()) self.diffblock.linkScrollBar(textview.verticalScrollBar(), side) lay.insertWidget(idx, blk) self.block[side] = blk lay.insertWidget(2, self.diffblock) for side in sides: table = getattr(self, 'tableView_revisions_%s' % side) table.setTabKeyNavigation(False) #table.installEventFilter(self) table.revision_selected.connect(self.revisionSelected) table.revision_selected[int].connect(self.revisionSelected) table.revision_activated.connect(self.revisionActivated) table.revision_activated[int].connect(self.revisionActivated) self.viewers[side].verticalScrollBar().valueChanged[int].connect( lambda value, side=side: self.vbar_changed(value, side)) self.attachQuickBar(table.goto_toolbar) self.setTabOrder(table, self.viewers['left']) self.setTabOrder(self.viewers['left'], self.viewers['right']) self.setWindowTitle('hgview diff: %s' % os.path.abspath(self.filename)) # timer used to fill viewers with diff block markers during GUI idle time self.timer = QtCore.QTimer() self.timer.setSingleShot(False) self.timer.timeout.connect(self.idle_fill_files) def setupModels(self): self.filedata = {'left': None, 'right': None} self._invbarchanged = False self.filerevmodel = FileRevModel(self.repo, self.filename) self.filerevmodel.filled.connect(self.modelFilled) self.tableView_revisions_left.setModel(self.filerevmodel) self.tableView_revisions_right.setModel(self.filerevmodel) def createActions(self): self.add_action( 'close', self.actionClose, icon='quit', callback=lambda: self.close(), ) self.add_action( 'reload', self.actionReload, icon='reload', callback=lambda: self.reload(), ) self.add_action( 'next', self.tr("Next hunk"), menu=self.tr("Moves"), icon='down', tip=self.tr("Jump to the next hunk"), keys=[Qt.ALT + Qt.Key_Down], callback=lambda: self.nextDiff(), enabled=False, ) self.add_action( 'prev', self.tr("Prior hunk"), menu=self.tr("Moves"), icon='up', tip=self.tr("Jump to the previous hunk"), keys=[Qt.ALT + Qt.Key_Up], callback=lambda: self.prevDiff(), enabled=False, ) def setupToolbars(self): self.toolBar_edit.addSeparator() self.toolBar_edit.addActions(self.get_actions('next', 'prev')) def modelFilled(self): self.filerevmodel.filled.disconnect(self.modelFilled) if self._show_rev is not None: rev = self._show_rev self._show_rev = None else: rev = self.filerevmodel.graph[0].rev self.goto(rev) def revisionSelected(self, rev): if self.sender() is self.tableView_revisions_right: side = 'right' else: side = 'left' path = self.filerevmodel.graph.nodesdict[rev].extra[0] fc = self.repo[rev].filectx(tohg(path)) self.filedata[side] = tounicode(fc.data()).splitlines() self.update_diff(keeppos=otherside[side]) def goto(self, rev): index = self.filerevmodel.indexFromRev(rev) if index is not None: if index.row() == 0: index = self.filerevmodel.index(1, 0) self.tableView_revisions_left.setCurrentIndex(index) index = self.filerevmodel.index(0, 0) self.tableView_revisions_right.setCurrentIndex(index) else: self._show_rev = rev def setDiffNavActions(self, pos=0): hasdiff = (self.diffblock.nDiffs() > 0) self.set_action('next', enabled=hasdiff and pos != 1) self.set_action('prev', enabled=hasdiff and pos != -1) def nextDiff(self): self.setDiffNavActions(self.diffblock.nextDiff()) def prevDiff(self): self.setDiffNavActions(self.diffblock.prevDiff()) def update_page_steps(self, keeppos=None): for side in sides: self.block[side].syncPageStep() self.diffblock.syncPageStep() if keeppos: side, pos = keeppos self.viewers[side].verticalScrollBar().setValue(pos) def idle_fill_files(self): # we make a burst of diff-lines computed at once, but we # disable GUI updates for efficiency reasons, then only # refresh GUI at the end of the burst for side in sides: self.viewers[side].setUpdatesEnabled(False) self.block[side].setUpdatesEnabled(False) self.diffblock.setUpdatesEnabled(False) for n in range(30): # burst pool if self._diff is None or not self._diff.get_opcodes(): self._diff = None self.timer.stop() self.setDiffNavActions(-1) self.diff_filled.emit() break tag, alo, ahi, blo, bhi = self._diff.get_opcodes().pop(0) if tag == 'replace': self.block['left'].addBlock('x', alo, ahi) self.block['right'].addBlock('x', blo, bhi) self.diffblock.addBlock('x', alo, ahi, blo, bhi) w = self.viewers['left'] for i in range(alo, ahi): w.markerAdd(i, w.markertriangle) w = self.viewers['right'] for i in range(blo, bhi): w.markerAdd(i, w.markertriangle) elif tag == 'delete': self.block['left'].addBlock('-', alo, ahi) self.diffblock.addBlock('-', alo, ahi, blo, bhi) w = self.viewers['left'] for i in range(alo, ahi): w.markerAdd(i, w.markerminus) elif tag == 'insert': self.block['right'].addBlock('+', blo, bhi) self.diffblock.addBlock('+', alo, ahi, blo, bhi) w = self.viewers['right'] for i in range(blo, bhi): w.markerAdd(i, w.markerplus) elif tag == 'equal': pass else: raise ValueError('unknown tag %r' % (tag,)) # ok, let's enable GUI refresh for code viewers and diff-block displayers for side in sides: self.viewers[side].setUpdatesEnabled(True) self.block[side].setUpdatesEnabled(True) self.diffblock.setUpdatesEnabled(True) def update_diff(self, keeppos=None): """ Recompute the diff, display files and starts the timer responsible for filling diff markers """ if keeppos: pos = self.viewers[keeppos].verticalScrollBar().value() keeppos = (keeppos, pos) for side in sides: self.viewers[side].clear() self.block[side].clear() self.diffblock.clear() if None not in self.filedata.values(): if self.timer.isActive(): self.timer.stop() for side in sides: self.viewers[side].setMarginWidth(1, "00%s" % len(self.filedata[side])) self._diff = difflib.SequenceMatcher(None, self.filedata['left'], self.filedata['right']) blocks = self._diff.get_opcodes()[:] self._diffmatch = {'left': [x[1:3] for x in blocks], 'right': [x[3:5] for x in blocks]} for side in sides: self.viewers[side].set_text(self.filename, '\n'.join(self.filedata[side]), flag='+', cfg=self.cfg) self.update_page_steps(keeppos) self.timer.start() def vbar_changed(self, value, side): """ Callback called when the vertical scrollbar of a file viewer is changed, so we can update the position of the other file viewer. """ if self._invbarchanged: # prevent loops in changes (left -> right -> left ...) return self._invbarchanged = True oside = otherside[side] for i, (lo, hi) in enumerate(self._diffmatch[side]): if lo <= value < hi: break dv = value - lo blo, bhi = self._diffmatch[oside][i] vbar = self.viewers[oside].verticalScrollBar() if (dv) < (bhi - blo): bvalue = blo + dv else: bvalue = bhi vbar.setValue(bvalue) self._invbarchanged = False