1# chunks.py - TortoiseHg patch/diff browser and editor
2#
3# Copyright 2010 Steve Borho <steve@borho.org>
4#
5# This software may be used and distributed according to the terms
6# of the GNU General Public License, incorporated herein by reference.
7
8from __future__ import absolute_import
9
10import os
11import re
12
13from . import qsci as Qsci
14from .qtcore import (
15    QPoint,
16    QTimer,
17    Qt,
18    pyqtSignal,
19    pyqtSlot,
20)
21from .qtgui import (
22    QAction,
23    QColor,
24    QDialog,
25    QFontMetrics,
26    QFrame,
27    QHBoxLayout,
28    QKeySequence,
29    QLabel,
30    QMenu,
31    QPainter,
32    QSplitter,
33    QStyle,
34    QToolBar,
35    QToolButton,
36    QVBoxLayout,
37    QWidget,
38)
39
40from mercurial import (
41    commands,
42    patch,
43    pycompat,
44    scmutil,
45    util,
46)
47
48from ..util import hglib
49from ..util.patchctx import patchctx
50from ..util.i18n import _
51from . import (
52    blockmatcher,
53    filedata,
54    filelistview,
55    lexers,
56    manifestmodel,
57    qscilib,
58    qtlib,
59    rejects,
60    revert,
61    visdiff,
62)
63
64# TODO
65# Add support for tools like TortoiseMerge that help resolve rejected chunks
66
67qsci = Qsci.QsciScintilla
68
69class ChunksWidget(QWidget):
70
71    linkActivated = pyqtSignal(str)
72    showMessage = pyqtSignal(str)
73    chunksSelected = pyqtSignal(bool)
74    fileSelected = pyqtSignal(bool)
75    fileModelEmpty = pyqtSignal(bool)
76    fileModified = pyqtSignal()
77
78    contextmenu = None
79
80    def __init__(self, repoagent, parent):
81        QWidget.__init__(self, parent)
82
83        self._repoagent = repoagent
84        self.currentFile = None
85
86        layout = QVBoxLayout(self)
87        layout.setSpacing(0)
88        layout.setContentsMargins(2, 2, 2, 2)
89        self.setLayout(layout)
90
91        self.splitter = QSplitter(self)
92        self.splitter.setOrientation(Qt.Vertical)
93        self.splitter.setChildrenCollapsible(False)
94        self.layout().addWidget(self.splitter)
95
96        repo = self._repoagent.rawRepo()
97        self.filelist = filelistview.HgFileListView(self)
98        model = manifestmodel.ManifestModel(
99            repoagent, self, statusfilter='MAR', flat=True)
100        self.filelist.setModel(model)
101        self.filelist.setContextMenuPolicy(Qt.CustomContextMenu)
102        self.filelist.customContextMenuRequested.connect(self.menuRequest)
103        self.filelist.doubleClicked.connect(self.vdiff)
104
105        self.fileListFrame = QFrame(self.splitter)
106        self.fileListFrame.setFrameShape(QFrame.NoFrame)
107        vbox = QVBoxLayout()
108        vbox.setSpacing(0)
109        vbox.setContentsMargins(0, 0, 0, 0)
110        vbox.addWidget(self.filelist)
111        self.fileListFrame.setLayout(vbox)
112
113        self.diffbrowse = DiffBrowser(self.splitter)
114        self.diffbrowse.showMessage.connect(self.showMessage)
115        self.diffbrowse.linkActivated.connect(self.linkActivated)
116        self.diffbrowse.chunksSelected.connect(self.chunksSelected)
117
118        self.filelist.fileSelected.connect(self.displayFile)
119        self.filelist.clearDisplay.connect(self.diffbrowse.clearDisplay)
120
121        self.splitter.setStretchFactor(0, 0)
122        self.splitter.setStretchFactor(1, 3)
123        self.timerevent = self.startTimer(500)
124
125        self._actions = {}
126        for name, desc, icon, key, tip, cb in [
127            ('diff', _('Visual Diff'), 'visualdiff', 'Ctrl+D',
128              _('View file changes in external diff tool'), self.vdiff),
129            ('edit', _('Edit Local'), 'edit-file', 'Shift+Ctrl+L',
130              _('Edit current file in working copy'), self.editCurrentFile),
131            ('revert', _('Revert to Revision'), 'hg-revert', 'Shift+Ctrl+R',
132              _('Revert file(s) to contents at this revision'),
133              self.revertfile),
134            ]:
135            act = QAction(desc, self)
136            if icon:
137                act.setIcon(qtlib.geticon(icon))
138            if key:
139                qtlib.setContextMenuShortcut(act, key)
140            if tip:
141                act.setStatusTip(tip)
142            if cb:
143                act.triggered.connect(cb)
144            self._actions[name] = act
145            self.addAction(act)
146
147    @property
148    def repo(self):
149        return self._repoagent.rawRepo()
150
151    @pyqtSlot(QPoint)
152    def menuRequest(self, point):
153        actionlist = ['diff', 'edit', 'revert']
154        if not self.contextmenu:
155            menu = QMenu(self)
156            for act in actionlist:
157                menu.addAction(self._actions[act])
158            self.contextmenu = menu
159        self.contextmenu.exec_(self.filelist.viewport().mapToGlobal(point))
160
161    def vdiff(self):
162        filenames = self.getSelectedFiles()
163        if len(filenames) == 0:
164            return
165        opts = {'change':self.ctx.rev()}
166        dlg = visdiff.visualdiff(self.repo.ui, self.repo, filenames, opts)
167        if dlg:
168            dlg.exec_()
169
170    def revertfile(self):
171        filenames = [hglib.tounicode(f) for f in self.getSelectedFiles()]
172        if len(filenames) == 0:
173            return
174        rev = self.ctx.rev()
175        if rev is None:
176            rev = self.ctx.p1().rev()
177        dlg = revert.RevertDialog(self._repoagent, filenames, rev, self)
178        dlg.exec_()
179        dlg.deleteLater()
180
181    def timerEvent(self, event):
182        'Periodic poll of currently displayed patch or working file'
183        if not hasattr(self, 'filelist'):
184            return
185        ctx = self.ctx
186        if ctx is None:
187            return
188        if isinstance(ctx, patchctx):
189            path = ctx._path
190            mtime = ctx._mtime
191        elif self.currentFile:
192            path = self.repo.wjoin(self.currentFile)
193            mtime = self.mtime
194        else:
195            return
196        try:
197            if os.path.exists(path):
198                newmtime = os.path.getmtime(path)
199                if mtime != newmtime:
200                    self.mtime = newmtime
201                    self.refresh()
202        except EnvironmentError:
203            pass
204
205    def runPatcher(self, fp, wfile, updatestate):
206        # don't repo.ui.copy(), which is protected to clone baseui since hg 2.9
207        ui = self.repo.ui
208        class warncapt(ui.__class__):
209            def warn(self, msg, *args, **opts):
210                self.write(msg)
211        ui = warncapt(ui)
212
213        ok = True
214        repo = self.repo
215        ui.pushbuffer()
216        try:
217            eolmode = ui.config(b'patch', b'eol')
218            if eolmode.lower() not in patch.eolmodes:
219                eolmode = b'strict'
220            else:
221                eolmode = eolmode.lower()
222            # 'updatestate' flag has no effect since hg 1.9
223            try:
224                ret = patch.internalpatch(ui, repo, fp, 1, files=None,
225                                          eolmode=eolmode, similarity=0)
226            except ValueError:
227                ret = -1
228            if ret < 0:
229                ok = False
230                self.showMessage.emit(_('Patch failed to apply'))
231        except (patch.PatchError, EnvironmentError) as err:
232            ok = False
233            self.showMessage.emit(hglib.tounicode(str(err)))
234        rejfilere = re.compile(br'\b%s\.rej\b' % re.escape(wfile))
235        for line in ui.popbuffer().splitlines():
236            if rejfilere.search(line):
237                if qtlib.QuestionMsgBox(_('Manually resolve rejected chunks?'),
238                                        hglib.tounicode(line) + u'<br><br>' +
239                                        _('Edit patched file and rejects?'),
240                                       parent=self):
241                    dlg = rejects.RejectsDialog(repo.ui, repo.wjoin(wfile),
242                                                self)
243                    if dlg.exec_() == QDialog.Accepted:
244                        ok = True
245                    break
246        return ok
247
248    def editCurrentFile(self):
249        ctx = self.ctx
250        if isinstance(ctx, patchctx):
251            paths = [ctx._path]
252        else:
253            paths = self.getSelectedFiles()
254        qtlib.editfiles(self.repo, paths, parent=self)
255
256    def getSelectedFileAndChunks(self):
257        chunks = self.diffbrowse.curchunks
258        if chunks:
259            dchunks = [c for c in chunks[1:] if c.selected]
260            return self.currentFile, [chunks[0]] + dchunks
261        else:
262            return self.currentFile, []
263
264    def getSelectedFiles(self):
265        return self.filelist.getSelectedFiles()
266
267    def deleteSelectedChunks(self):
268        'delete currently selected chunks'
269        repo = self.repo
270        chunks = self.diffbrowse.curchunks
271        dchunks = [c for c in chunks[1:] if c.selected]
272        if not dchunks:
273            self.showMessage.emit(_('No deletable chunks'))
274            return
275        ctx = self.ctx
276        kchunks = [c for c in chunks[1:] if not c.selected]
277        revertall = False
278        if not kchunks:
279            if isinstance(ctx, patchctx):
280                revertmsg = _('Completely remove file from patch?')
281            else:
282                revertmsg = _('Revert all file changes?')
283            revertall = qtlib.QuestionMsgBox(_('No chunks remain'), revertmsg)
284        if isinstance(ctx, patchctx):
285            repo.thgbackup(ctx._path)
286            fp = util.atomictempfile(ctx._path, b'wb')
287            buf = pycompat.bytesio()
288            try:
289                if ctx._ph.comments:
290                    buf.write(b'\n'.join(ctx._ph.comments))
291                    buf.write(b'\n\n')
292                needsnewline = False
293                for wfile in ctx._fileorder:
294                    if wfile == self.currentFile:
295                        if revertall:
296                            continue
297                        chunks[0].write(buf)
298                        for chunk in kchunks:
299                            chunk.write(buf)
300                    else:
301                        if buf.tell() and not buf.getvalue().endswith(b'\n'):
302                            buf.write(b'\n')
303                        for chunk in ctx._files[wfile]:
304                            chunk.write(buf)
305                fp.write(buf.getvalue())
306                fp.close()
307            finally:
308                del fp
309            ctx.invalidate()
310            self.fileModified.emit()
311        else:
312            path = repo.wjoin(self.currentFile)
313            if not os.path.exists(path):
314                self.showMessage.emit(_('file has been deleted, refresh'))
315                return
316            if self.mtime != os.path.getmtime(path):
317                self.showMessage.emit(_('file has been modified, refresh'))
318                return
319            repo.thgbackup(path)
320            if revertall:
321                commands.revert(repo.ui, repo, path, no_backup=True)
322            else:
323                wlock = repo.wlock()
324                try:
325                    # atomictemp can preserve file permission
326                    wf = repo.wvfs(self.currentFile, b'wb', atomictemp=True)
327                    wf.write(self.diffbrowse.origcontents)
328                    wf.close()
329                    fp = pycompat.bytesio()
330                    chunks[0].write(fp)
331                    for c in kchunks:
332                        c.write(fp)
333                    fp.seek(0)
334                    self.runPatcher(fp, self.currentFile, False)
335                finally:
336                    wlock.release()
337            self.fileModified.emit()
338
339    def mergeChunks(self, wfile, chunks):
340        def isAorR(header):
341            for line in header:
342                if line.startswith(b'--- /dev/null'):
343                    return True
344                if line.startswith(b'+++ /dev/null'):
345                    return True
346            return False
347        repo = self.repo
348        ctx = self.ctx
349        if isinstance(ctx, patchctx):
350            if wfile in ctx._files:
351                patchchunks = ctx._files[wfile]
352                if isAorR(chunks[0].header) or isAorR(patchchunks[0].header):
353                    qtlib.InfoMsgBox(_('Unable to merge chunks'),
354                                    _('Add or remove patches must be merged '
355                                      'in the working directory'))
356                    return False
357                # merge new chunks into existing chunks, sorting on start line
358                newchunks = [chunks[0]]
359                pidx = nidx = 1
360                while pidx < len(patchchunks) or nidx < len(chunks):
361                    if pidx == len(patchchunks):
362                        newchunks.append(chunks[nidx])
363                        nidx += 1
364                    elif nidx == len(chunks):
365                        newchunks.append(patchchunks[pidx])
366                        pidx += 1
367                    elif chunks[nidx].fromline < patchchunks[pidx].fromline:
368                        newchunks.append(chunks[nidx])
369                        nidx += 1
370                    else:
371                        newchunks.append(patchchunks[pidx])
372                        pidx += 1
373                ctx._files[wfile] = newchunks
374            else:
375                # add file to patch
376                ctx._files[wfile] = chunks
377                ctx._fileorder.append(wfile)
378            repo.thgbackup(ctx._path)
379            fp = util.atomictempfile(ctx._path, b'wb')
380            try:
381                if ctx._ph.comments:
382                    fp.write(b'\n'.join(ctx._ph.comments))
383                    fp.write(b'\n\n')
384                for file in ctx._fileorder:
385                    for chunk in ctx._files[file]:
386                        chunk.write(fp)
387                fp.close()
388                ctx.invalidate()
389                self.fileModified.emit()
390                return True
391            finally:
392                del fp
393        else:
394            # Apply chunks to wfile
395            repo.thgbackup(repo.wjoin(wfile))
396            fp = pycompat.bytesio()
397            for c in chunks:
398                c.write(fp)
399            fp.seek(0)
400            wlock = repo.wlock()
401            try:
402                return self.runPatcher(fp, wfile, True)
403            finally:
404                wlock.release()
405
406    def getFileList(self):
407        return self.ctx.files()
408
409    def removeFile(self, wfile):
410        repo = self.repo
411        ctx = self.ctx
412        if isinstance(ctx, patchctx):
413            repo.thgbackup(ctx._path)
414            fp = util.atomictempfile(ctx._path, b'wb')
415            try:
416                if ctx._ph.comments:
417                    fp.write(b'\n'.join(ctx._ph.comments))
418                    fp.write(b'\n\n')
419                for file in ctx._fileorder:
420                    if file == wfile:
421                        continue
422                    for chunk in ctx._files[file]:
423                        chunk.write(fp)
424                fp.close()
425            finally:
426                del fp
427            ctx.invalidate()
428        else:
429            fullpath = repo.wjoin(wfile)
430            repo.thgbackup(fullpath)
431            wasadded = wfile in repo[None].added()
432            try:
433                commands.revert(repo.ui, repo, fullpath, rev=b'.',
434                                no_backup=True)
435                if wasadded and os.path.exists(fullpath):
436                    os.unlink(fullpath)
437            except EnvironmentError:
438                qtlib.InfoMsgBox(_("Unable to remove"),
439                                 _("Unable to remove file %s,\n"
440                                   "permission denied") %
441                                    hglib.tounicode(wfile))
442        self.fileModified.emit()
443
444    def getChunksForFile(self, wfile):
445        repo = self.repo
446        ctx = self.ctx
447        if isinstance(ctx, patchctx):
448            if wfile in ctx._files:
449                return ctx._files[wfile]
450            else:
451                return []
452        else:
453            buf = pycompat.bytesio()
454            diffopts = patch.diffopts(repo.ui, {'git':True})
455            m = scmutil.matchfiles(repo, [wfile])
456            for p in patch.diff(repo, ctx.p1().node(), None, match=m,
457                                opts=diffopts):
458                buf.write(p)
459            buf.seek(0)
460            chunks = patch.parsepatch(buf)
461            if chunks:
462                header = chunks[0]
463                return [header] + header.hunks
464            else:
465                return []
466
467    @pyqtSlot(str, str)
468    def displayFile(self, file, status):
469        if isinstance(file, pycompat.unicode):
470            file = hglib.fromunicode(file)
471        if not isinstance(file, pycompat.unicode):
472            status = hglib.tounicode(status)
473        if file:
474            self.currentFile = file
475            path = self.repo.wjoin(file)
476            if os.path.exists(path):
477                self.mtime = os.path.getmtime(path)
478            else:
479                self.mtime = None
480            self.diffbrowse.displayFile(file, status)
481            self.fileSelected.emit(True)
482        else:
483            self.currentFile = None
484            self.diffbrowse.clearDisplay()
485            self.diffbrowse.clearChunks()
486            self.fileSelected.emit(False)
487
488    def setContext(self, ctx):
489        self.diffbrowse.setContext(ctx)
490        model = self.filelist.model()
491        assert isinstance(model, manifestmodel.ManifestModel)
492        model.setRawContext(ctx)
493        empty = len(ctx.files()) == 0
494        self.fileModelEmpty.emit(empty)
495        self.fileSelected.emit(not empty)
496        if empty:
497            self.currentFile = None
498            self.diffbrowse.clearDisplay()
499            self.diffbrowse.clearChunks()
500        self.diffbrowse.updateSummary()
501        self.ctx = ctx
502        for act in ['diff', 'revert']:
503            self._actions[act].setEnabled(ctx.rev() is None)
504
505    def refresh(self):
506        ctx = self.ctx
507        if isinstance(ctx, patchctx):
508            # if patch mtime has not changed, it could return the same ctx
509            ctx = self.repo[ctx._path]
510        else:
511            self.repo.thginvalidate()
512            ctx = self.repo[ctx.node()]
513        self.setContext(ctx)
514
515    def loadSettings(self, qs, prefix):
516        self.diffbrowse.loadSettings(qs, prefix)
517
518    def saveSettings(self, qs, prefix):
519        self.diffbrowse.saveSettings(qs, prefix)
520
521
522# DO NOT USE.  Sadly, this does not work.
523class ElideLabel(QLabel):
524    def __init__(self, text='', parent=None):
525        QLabel.__init__(self, text, parent)
526
527    def sizeHint(self):
528        return super(ElideLabel, self).sizeHint()
529
530    def paintEvent(self, event):
531        p = QPainter()
532        fm = QFontMetrics(self.font())
533        if fm.width(self.text()): # > self.contentsRect().width():
534            elided = fm.elidedText(self.text(), Qt.ElideLeft,
535                                   self.rect().width(), 0)
536            p.drawText(self.rect(), Qt.AlignTop | Qt.AlignRight |
537                       Qt.TextSingleLine, elided)
538        else:
539            super(ElideLabel, self).paintEvent(event)
540
541class DiffBrowser(QFrame):
542    """diff browser"""
543
544    linkActivated = pyqtSignal(str)
545    showMessage = pyqtSignal(str)
546    chunksSelected = pyqtSignal(bool)
547
548    def __init__(self, parent):
549        QFrame.__init__(self, parent)
550
551        self.curchunks = []
552        self.countselected = 0
553        self._ctx = None
554        self._lastfile = None
555        self._status = None
556
557        vbox = QVBoxLayout()
558        vbox.setContentsMargins(0,0,0,0)
559        vbox.setSpacing(0)
560        self.setLayout(vbox)
561
562        self.labelhbox = hbox = QHBoxLayout()
563        hbox.setContentsMargins(0,0,0,0)
564        hbox.setSpacing(2)
565        self.layout().addLayout(hbox)
566        self.filenamelabel = w = QLabel()
567        self.filenamelabel.hide()
568        hbox.addWidget(w)
569        w.setWordWrap(True)
570        f = w.textInteractionFlags()
571        w.setTextInteractionFlags(f | Qt.TextSelectableByMouse)
572        w.linkActivated.connect(self.linkActivated)
573
574        self.searchbar = qscilib.SearchToolBar()
575        self.searchbar.hide()
576        self.searchbar.searchRequested.connect(self.find)
577        self.searchbar.conditionChanged.connect(self.highlightText)
578        self.addActions(self.searchbar.editorActions())
579
580        self.sumlabel = QLabel()
581        self.allbutton = QToolButton()
582        self.allbutton.setText(_('All', 'files'))
583        self.allbutton.setShortcut(QKeySequence.SelectAll)
584        self.allbutton.clicked.connect(self.selectAll)
585        self.nonebutton = QToolButton()
586        self.nonebutton.setText(_('None', 'files'))
587        self.nonebutton.setShortcut(QKeySequence.New)
588        self.nonebutton.clicked.connect(self.selectNone)
589        self.actionFind = self.searchbar.toggleViewAction()
590        self.actionFind.setIcon(qtlib.geticon('edit-find'))
591        self.actionFind.setToolTip(_('Toggle display of text search bar'))
592        qtlib.newshortcutsforstdkey(QKeySequence.Find, self, self.searchbar.show)
593        self.diffToolbar = QToolBar(_('Diff Toolbar'))
594        self.diffToolbar.setIconSize(qtlib.smallIconSize())
595        self.diffToolbar.setStyleSheet(qtlib.tbstylesheet)
596        self.diffToolbar.addAction(self.actionFind)
597        hbox.addWidget(self.diffToolbar)
598        hbox.addStretch(1)
599        hbox.addWidget(self.sumlabel)
600        hbox.addWidget(self.allbutton)
601        hbox.addWidget(self.nonebutton)
602
603        self.extralabel = w = QLabel()
604        w.setWordWrap(True)
605        w.linkActivated.connect(self.linkActivated)
606        self.layout().addWidget(w)
607        self.layout().addSpacing(2)
608        w.hide()
609
610        self._forceviewindicator = None
611        self.sci = qscilib.Scintilla(self)
612        self.sci.setReadOnly(True)
613        self.sci.setUtf8(True)
614        self.sci.installEventFilter(qscilib.KeyPressInterceptor(self))
615        self.sci.setCaretLineVisible(False)
616        self.sci.setFont(qtlib.getfont('fontdiff').font())
617
618        self.sci.setMarginType(1, qsci.SymbolMargin)
619        self.sci.setMarginLineNumbers(1, False)
620        self.sci.setMarginWidth(1, QFontMetrics(self.font()).width('XX'))
621        self.sci.setMarginSensitivity(1, True)
622        self.sci.marginClicked.connect(self.marginClicked)
623
624        self._checkedpix = qtlib.getcheckboxpixmap(QStyle.State_On,
625                                                   Qt.gray, self)
626        self.selected = self.sci.markerDefine(self._checkedpix, -1)
627
628        self._uncheckedpix = qtlib.getcheckboxpixmap(QStyle.State_Off,
629                                                     Qt.gray, self)
630        self.unselected = self.sci.markerDefine(self._uncheckedpix, -1)
631
632        self.vertical = self.sci.markerDefine(qsci.VerticalLine, -1)
633        self.divider = self.sci.markerDefine(qsci.Background, -1)
634        self.selcolor = self.sci.markerDefine(qsci.Background, -1)
635        self.sci.setMarkerBackgroundColor(QColor('#BBFFFF'), self.selcolor)
636        self.sci.setMarkerBackgroundColor(QColor('#AAAAAA'), self.divider)
637        mask = (1 << self.selected) | (1 << self.unselected) | \
638               (1 << self.vertical) | (1 << self.selcolor) | (1 << self.divider)
639        self.sci.setMarginMarkerMask(1, mask)
640
641        self.blksearch = blockmatcher.BlockList(self)
642        self.blksearch.linkScrollBar(self.sci.verticalScrollBar())
643        self.blksearch.setVisible(False)
644
645        hbox = QHBoxLayout()
646        hbox.addWidget(self.sci)
647        hbox.addWidget(self.blksearch)
648
649        lexer = lexers.difflexer(self)
650        self.sci.setLexer(lexer)
651
652        self.layout().addLayout(hbox)
653        self.layout().addWidget(self.searchbar)
654
655        self.clearDisplay()
656
657    def loadSettings(self, qs, prefix):
658        self.sci.loadSettings(qs, prefix)
659
660    def saveSettings(self, qs, prefix):
661        self.sci.saveSettings(qs, prefix)
662
663    def updateSummary(self):
664        self.sumlabel.setText(_('Chunks selected: %d / %d') % (
665            self.countselected, len(self.curchunks[1:])))
666        self.chunksSelected.emit(self.countselected > 0)
667
668    @pyqtSlot()
669    def selectAll(self):
670        for chunk in self.curchunks[1:]:
671            if not chunk.selected:
672                self.sci.markerDelete(chunk.mline, -1)
673                self.sci.markerAdd(chunk.mline, self.selected)
674                chunk.selected = True
675                self.countselected += 1
676                for i in pycompat.xrange(*chunk.lrange):
677                    self.sci.markerAdd(i, self.selcolor)
678        self.updateSummary()
679
680    @pyqtSlot()
681    def selectNone(self):
682        for chunk in self.curchunks[1:]:
683            if chunk.selected:
684                self.sci.markerDelete(chunk.mline, -1)
685                self.sci.markerAdd(chunk.mline, self.unselected)
686                chunk.selected = False
687                self.countselected -= 1
688                for i in pycompat.xrange(*chunk.lrange):
689                    self.sci.markerDelete(i, self.selcolor)
690        self.updateSummary()
691
692    @pyqtSlot(int, int, Qt.KeyboardModifiers)
693    def marginClicked(self, margin, line, modifiers):
694        for chunk in self.curchunks[1:]:
695            if line >= chunk.lrange[0] and line < chunk.lrange[1]:
696                self.toggleChunk(chunk)
697                self.updateSummary()
698                return
699
700    def toggleChunk(self, chunk):
701        self.sci.markerDelete(chunk.mline, -1)
702        if chunk.selected:
703            self.sci.markerAdd(chunk.mline, self.unselected)
704            chunk.selected = False
705            self.countselected -= 1
706            for i in pycompat.xrange(*chunk.lrange):
707                self.sci.markerDelete(i, self.selcolor)
708        else:
709            self.sci.markerAdd(chunk.mline, self.selected)
710            chunk.selected = True
711            self.countselected += 1
712            for i in pycompat.xrange(*chunk.lrange):
713                self.sci.markerAdd(i, self.selcolor)
714
715    def setContext(self, ctx):
716        self._ctx = ctx
717        self.sci.setTabWidth(ctx.repo().tabwidth)
718
719    def clearDisplay(self):
720        self.sci.clear()
721        self.filenamelabel.setText(' ')
722        self.extralabel.hide()
723        self.blksearch.clear()
724
725    def clearChunks(self):
726        self.curchunks = []
727        self.countselected = 0
728        self.updateSummary()
729
730    def _setupForceViewIndicator(self):
731        if not self._forceviewindicator:
732            self._forceviewindicator = self.sci.indicatorDefine(self.sci.PlainIndicator)
733            self.sci.setIndicatorDrawUnder(True, self._forceviewindicator)
734            self.sci.setIndicatorForegroundColor(
735                QColor('blue'), self._forceviewindicator)
736            # delay until next event-loop in order to complete mouse release
737            self.sci.SCN_INDICATORRELEASE.connect(self.forceDisplayFile,
738                                                  Qt.QueuedConnection)
739
740    def forceDisplayFile(self):
741        if self.curchunks:
742            return
743        self.sci.setText(_('Please wait while the file is opened ...'))
744        QTimer.singleShot(10,
745            lambda: self.displayFile(self._lastfile, self._status, force=True))
746
747    def displayFile(self, filename, status, force=False):
748        self._status = status
749        self.clearDisplay()
750        if filename == self._lastfile:
751            reenable = [(c.fromline, len(c.before)) for c in self.curchunks[1:]\
752                        if c.selected]
753        else:
754            reenable = []
755        self._lastfile = filename
756        self.clearChunks()
757
758        fd = filedata.createFileData(self._ctx, None, filename, status)
759        fd.load(force=force)
760        fd.detectTextEncoding()
761
762        if fd.elabel:
763            self.extralabel.setText(fd.elabel)
764            self.extralabel.show()
765        else:
766            self.extralabel.hide()
767        self.filenamelabel.setText(fd.flabel)
768
769        if not fd.isValid() or not fd.diff:
770            if fd.error is None:
771                self.sci.clear()
772                return
773            self.sci.setText(fd.error)
774            forcedisplaymsg = filedata.forcedisplaymsg
775            linkstart = fd.error.find(forcedisplaymsg)
776            if linkstart >= 0:
777                # add the link to force to view the data anyway
778                self._setupForceViewIndicator()
779                self.sci.fillIndicatorRange(
780                    0, linkstart, 0, linkstart+len(forcedisplaymsg),
781                    self._forceviewindicator)
782            return
783        elif isinstance(self._ctx.rev(), str):
784            chunks = self._ctx._files[filename]
785        else:
786            header = patch.parsepatch(pycompat.bytesio(fd.diff))[0]
787            chunks = [header] + header.hunks
788
789        utext = []
790        for chunk in chunks[1:]:
791            buf = pycompat.bytesio()
792            chunk.selected = False
793            chunk.write(buf)
794            chunk.lines = buf.getvalue().splitlines()
795            utext.append(buf.getvalue().decode(fd.textEncoding(), 'replace'))
796        self.sci.setText(u'\n'.join(utext))
797
798        start = 0
799        self.sci.markerDeleteAll(-1)
800        for chunk in chunks[1:]:
801            chunk.lrange = (start, start+len(chunk.lines))
802            chunk.mline = start
803            if start:
804                self.sci.markerAdd(start-1, self.divider)
805            for i in pycompat.xrange(0,len(chunk.lines)):
806                if start + i == chunk.mline:
807                    self.sci.markerAdd(chunk.mline, self.unselected)
808                else:
809                    self.sci.markerAdd(start+i, self.vertical)
810            start += len(chunk.lines) + 1
811        self.origcontents = fd.olddata
812        self.countselected = 0
813        self.curchunks = chunks
814        for c in chunks[1:]:
815            if (c.fromline, len(c.before)) in reenable:
816                self.toggleChunk(c)
817        self.updateSummary()
818
819    @pyqtSlot(str, bool, bool, bool)
820    def find(self, exp, icase=True, wrap=False, forward=True):
821        self.sci.find(exp, icase, wrap, forward)
822
823    @pyqtSlot(str, bool)
824    def highlightText(self, match, icase=False):
825        self._lastSearch = match, icase
826        self.sci.highlightText(match, icase)
827        blk = self.blksearch
828        blk.clear()
829        blk.setUpdatesEnabled(False)
830        blk.clear()
831        for l in self.sci.highlightLines:
832            blk.addBlock('s', l, l + 1)
833        blk.setVisible(bool(match))
834        blk.setUpdatesEnabled(True)
835