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