visdiff.py 25.8 KB
Newer Older
1
2
3
4
5
6
7
# visdiff.py - launch external visual diff tools
#
# Copyright 2009 Steve Borho <steve@borho.org>
#
# This software may be used and distributed according to the terms of the
# GNU General Public License version 2, incorporated herein by reference.

8
9
from __future__ import absolute_import

10
import os
11
import re
12
import stat
13
import subprocess
14
15
import threading

16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
from .qtcore import (
    QTimer,
    pyqtSlot,
)
from .qtgui import (
    QComboBox,
    QDialog,
    QDialogButtonBox,
    QHBoxLayout,
    QKeySequence,
    QLabel,
    QListWidget,
    QMessageBox,
    QShortcut,
    QVBoxLayout,
)
32

33
34
35
36
from mercurial import (
    copies,
    error,
    match,
37
    pycompat,
38
39
40
    scmutil,
    util,
)
41
42
from mercurial.utils import (
    procutil,
43
    stringutil,
44
)
45

46
47
48
from ..util import hglib
from ..util.i18n import _
from . import qtlib
49

50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
if hglib.TYPE_CHECKING:
    from typing import (
        Any,
        Dict,
        Iterable,
        List,
        Optional,
        Sequence,
        Set,
        Text,
        Tuple,
        Union,
    )
    from mercurial import (
        localrepo,
        ui as uimod,
    )
    from .qtgui import (
        QListWidgetItem,
    )
    from ..util.typelib import (
        DiffTools,
        HgContext,
    )

    # Destination name, source name, dest modification time
    FnsAndMtime = Tuple[bytes, bytes, float]


79
# Match parent2 first, so 'parent1?' will match both parent1 and parent
kiilerix's avatar
kiilerix committed
80
_regex = b'\$(parent2|parent1?|child|plabel1|plabel2|clabel|repo|phash1|phash2|chash)'
81
82
83

_nonexistant = _('[non-existant]')

84
85
86
87
# This global counter is incremented for each visual diff done in a session
# It ensures that the names for snapshots created do not collide.
_diffCount = 0

88
def snapshotset(repo, ctxs, sa, sb, copies, copyworkingdir = False):
89
    # type: (localrepo.localrepository, Sequence[HgContext], List[Set[bytes]], List[Set[bytes]], Dict[bytes, bytes], bool) -> Tuple[List[Optional[bytes]], List[bytes], List[List[FnsAndMtime]]]
90
91
92
93
    '''snapshot files from parent-child set of revisions'''
    ctx1a, ctx1b, ctx2 = ctxs
    mod_a, add_a, rem_a = sa
    mod_b, add_b, rem_b = sb
94
95
96
97

    global _diffCount
    _diffCount += 1

98
99
100
101
102
103
104
105
    if copies:
        sources = set(copies.values())
    else:
        sources = set()

    # Always make a copy of ctx1a
    files1a = sources | mod_a | rem_a | ((mod_b | add_b) - add_a)
    dir1a, fns_mtime1a = snapshot(repo, files1a, ctx1a)
106
    label1a = b'@%d:%s' % (ctx1a.rev(), ctx1a)
107
108
109
110
111

    # Make a copy of ctx1b if relevant
    if ctx1b:
        files1b = sources | mod_b | rem_b | ((mod_a | add_a) - add_b)
        dir1b, fns_mtime1b = snapshot(repo, files1b, ctx1b)
112
        label1b = b'@%d:%s' % (ctx1b.rev(), ctx1b)
113
114
    else:
        dir1b = None
115
        fns_mtime1b = []  # type: List[FnsAndMtime]
116
        label1b = b''
117
118
119
120
121
122
123
124

    # Either make a copy of ctx2, or use working dir directly if relevant.
    files2 = mod_a | add_a | mod_b | add_b
    if ctx2.rev() is None:
        if copyworkingdir:
            dir2, fns_mtime2 = snapshot(repo, files2, ctx2)
        else:
            dir2 = repo.root
125
            fns_mtime2 = []  # type: List[FnsAndMtime]
126
        # If ctx2 is working copy, use empty label.
127
        label2 = b''
128
129
    else:
        dir2, fns_mtime2 = snapshot(repo, files2, ctx2)
130
        label2 = b'@%d:%s' % (ctx2.rev(), ctx2)
131
132
133
134
135
136

    dirs = [dir1a, dir1b, dir2]
    labels = [label1a, label1b, label2]
    fns_and_mtimes = [fns_mtime1a, fns_mtime1b, fns_mtime2]
    return dirs, labels, fns_and_mtimes

Steve Borho's avatar
Steve Borho committed
137
def snapshot(repo, files, ctx):
138
    # type: (localrepo.localrepository, Iterable[bytes], HgContext) -> Tuple[bytes, List[FnsAndMtime]]
kiilerix's avatar
kiilerix committed
139
140
141
    '''snapshot repo files as of some revision, returning a tuple with the
    created temporary snapshot dir and tuples of file info if using working
    copy.'''
142
143
    dirname = os.path.basename(repo.root) or b'root'
    dirname += b'.%d' % _diffCount
144
    if ctx.rev() is not None:
145
        dirname += b'.%d' % ctx.rev()
Steve Borho's avatar
Steve Borho committed
146
    base = os.path.join(qtlib.gettempdir(), dirname)
147
    fns_and_mtime = []
148
    if not os.path.exists(base):
149
        os.makedirs(base)
150
    for fn in files:
kiilerix's avatar
kiilerix committed
151
        assert isinstance(fn, bytes), repr(fn)
152
        wfn = util.pconvert(fn)
153
        if wfn not in ctx:
154
155
156
            # File doesn't exist; could be a bogus modify
            continue
        dest = os.path.join(base, wfn)
157
158
159
        if os.path.exists(dest):
            # File has already been snapshot
            continue
160
        destdir = os.path.dirname(dest)
161
162
163
        try:
            if not os.path.isdir(destdir):
                os.makedirs(destdir)
164
165
            fctx = ctx[wfn]
            data = repo.wwritedata(wfn, fctx.data())
166
167
            with open(dest, 'wb') as f:
                f.write(data)
kiilerix's avatar
kiilerix committed
168
            if b'x' in fctx.flags():
169
                util.setflags(dest, False, True)
170
            if ctx.rev() is None:
171
                fns_and_mtime.append((dest, repo.wjoin(fn),
172
173
174
175
176
177
                                    os.lstat(dest).st_mtime))
            else:
                # Make file read/only, to indicate it's static (archival) nature
                os.chmod(dest, stat.S_IREAD)
        except EnvironmentError:
            pass
178
179
180
    return base, fns_and_mtime

def launchtool(cmd, opts, replace, block):
181
182
    # type: (bytes, Sequence[bytes], Dict[Text, Union[bytes, Text]], bool) -> None
    # TODO: fix up the bytes vs str in the replacement mapping
183
    def quote(match):
kiilerix's avatar
kiilerix committed
184
        key = pycompat.sysstr(match.group()[1:])
185
        return procutil.shellquote(replace[key])
186
187

    args = b' '.join(opts)
188
    args = re.sub(_regex, quote, args)
kiilerix's avatar
kiilerix committed
189
    cmdline = procutil.shellquote(cmd) + b' ' + args
190
    try:
191
        proc = subprocess.Popen(procutil.tonativestr(cmdline), shell=True,
192
                                creationflags=qtlib.openflags,
193
194
195
196
197
                                stderr=subprocess.PIPE,
                                stdout=subprocess.PIPE,
                                stdin=subprocess.PIPE)
        if block:
            proc.communicate()
198
    except (OSError, EnvironmentError) as e:
199
        QMessageBox.warning(None,
200
                _('Tool launch failure'),
201
                _('%s : %s') % (hglib.tounicode(cmd), hglib.tounicode(str(e))))
202
203

def filemerge(ui, fname, patchedfname):
204
    # type: (uimod.ui, Text, Text) -> None
205
206
207
    'Launch the preferred visual diff tool for two text files'
    detectedtools = hglib.difftools(ui)
    if not detectedtools:
208
        QMessageBox.warning(None,
209
210
211
212
213
214
215
216
217
218
219
220
                _('No diff tool found'),
                _('No visual diff tools were detected'))
        return None
    preferred = besttool(ui, detectedtools)
    diffcmd, diffopts, mergeopts = detectedtools[preferred]
    replace = dict(parent=fname, parent1=fname,
                   plabel1=fname + _('[working copy]'),
                   repo='', phash1='', phash2='', chash='',
                   child=patchedfname, clabel=_('[original]'))
    launchtool(diffcmd, diffopts, replace, True)


221
def besttool(ui, tools, force=None):
222
    # type: (uimod.ui, DiffTools, Optional[bytes]) -> bytes
223
    'Select preferred or highest priority tool from dictionary'
224
    preferred = force or ui.config(b'tortoisehg', b'vdiff') or \
225
                         ui.config(b'ui', b'merge')
226
227
228
229
    if preferred and preferred in tools:
        return preferred
    pris = []
    for t in tools.keys():
230
        try:
231
            p = ui.configint(b'merge-tools', t + b'.priority')
232
        except error.ConfigError as inst:
233
            ui.warn(b'visdiff: %s\n' % stringutil.forcebytestr(inst))
234
            p = 0
235
        assert p is not None  # help pytype: default *.priority is 0
236
        pris.append((-p, t))
237
238

    return sorted(pris)[0][1]
239
240
241


def visualdiff(ui, repo, pats, opts):
242
    # type: (uimod.ui, localrepo.localrepository, Sequence[bytes], Dict[Text, Any]) -> Optional[FileSelectionDialog]
243
    revs = opts.get('rev', [])
244
245
246
247
248
    change = opts.get('change')

    try:
        ctx1b = None
        if change:
249
            # TODO: figure out what's the expect type
250
251
252
            if isinstance(change, pycompat.unicode):
                change = hglib.fromunicode(change)
            if isinstance(change, bytes):
253
                ctx2 = hglib.revsymbol(repo, change)
254
255
            else:
                ctx2 = repo[change]
256
257
258
259
260
261
            p = ctx2.parents()
            if len(p) > 1:
                ctx1a, ctx1b = p
            else:
                ctx1a = p[0]
        else:
262
263
            n1, n2 = scmutil.revpair(repo, [hglib.fromunicode(rev)
                                            for rev in revs])
264
265
266
267
268
            ctx1a, ctx2 = repo[n1], repo[n2]
            p = ctx2.parents()
            if not revs and len(p) > 1:
                ctx1b = p[1]
    except (error.LookupError, error.RepoError):
269
        QMessageBox.warning(None,
270
271
272
273
                       _('Unable to find changeset'),
                       _('You likely need to refresh this application'))
        return None

274
275
276
277
278
    return visual_diff(ui, repo, pats, ctx1a, ctx1b, ctx2, opts.get('tool'),
                       opts.get('mainapp'), revs)

def visual_diff(ui, repo, pats, ctx1a, ctx1b, ctx2, tool, mainapp=False,
                revs=None):
279
    # type: (uimod.ui, localrepo.localrepository, Sequence[bytes], HgContext, Optional[HgContext], HgContext, bytes, bool, Optional[Sequence[int]]) -> Optional[FileSelectionDialog]
280
281
282
283
284
285
286
287
288
289
290
    """Opens the visual diff tool on the given file patterns in the given
    contexts.  If a ``tool`` is provided, it is used, otherwise the diff tool
    launched is determined by the configuration.  For a 2-way diff, ``ctx1a`` is
    the context for the first revision, ``ctxb1`` is None, and ``ctx2`` is the
    context for the second revision.  For a 3-way diff, ``ctx2`` is the wdir
    context and ``ctx1a`` and ``ctx1b`` are the "local" and "other" contexts
    respectively.
    """
    # TODO: Figure out how to get rid of the `revs` argument
    if revs is None:
        revs = []
291
    pats = scmutil.expandpats(pats)
292
    m = match.match(repo.root, b'', pats, None, None, b'relpath')
293
    n2 = ctx2.node()
294
295

    def _status(ctx):
296
        # type: (HgContext) -> Tuple[List[bytes], List[bytes], List[bytes]]
297
298
299
300
        status = repo.status(ctx.node(), n2, m)
        return status.modified, status.added, status.removed

    mod_a, add_a, rem_a = pycompat.maplist(set, _status(ctx1a))
301
    if ctx1b:
302
        mod_b, add_b, rem_b = pycompat.maplist(set, _status(ctx1b))
303
        cpy = copies.mergecopies(repo, ctx1a, ctx1b, ctx1a.ancestor(ctx1b))[0].copy
304
    else:
305
        cpy = copies.pathcopies(ctx1a, ctx2)
306
307
308
309
        mod_b, add_b, rem_b = set(), set(), set()
    MA = mod_a | add_a | mod_b | add_b
    MAR = MA | rem_a | rem_b
    if not MAR:
310
        QMessageBox.information(None,
311
312
313
314
315
316
                _('No file changes'),
                _('There are no file changes to view'))
        return None

    detectedtools = hglib.difftools(repo.ui)
    if not detectedtools:
317
        QMessageBox.warning(None,
318
319
320
321
                _('No diff tool found'),
                _('No visual diff tools were detected'))
        return None

322
    preferred = besttool(repo.ui, detectedtools, tool)
323
324
325

    # Build tool list based on diff-patterns matches
    toollist = set()
326
    patterns = repo.ui.configitems(b'diff-patterns')
327
328
329
    patterns = [(p, t) for p,t in patterns if t in detectedtools]
    for path in MAR:
        for pat, tool in patterns:
330
            mf = match.match(repo.root, b'', [pat])
331
332
333
334
335
336
            if mf(path):
                toollist.add(tool)
                break
        else:
            toollist.add(preferred)

337
    cto = list(cpy.keys())
338
    for path in MAR:
339
        if path in cto:
340
341
342
343
            hascopies = True
            break
    else:
        hascopies = False
344
    force = repo.ui.configbool(b'tortoisehg', b'forcevdiffwin')
345
    if len(toollist) > 1 or (hascopies and len(MAR) > 1) or force:
346
347
348
        usewin = True
    else:
        preferred = toollist.pop()
349
350
351
        dirdiff = repo.ui.configbool(b'merge-tools', preferred + b'.dirdiff')
        dir3diff = repo.ui.configbool(b'merge-tools', preferred + b'.dir3diff')
        usewin = repo.ui.configbool(b'merge-tools', preferred + b'.usewin')
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
        if not usewin and len(MAR) > 1:
            if ctx1b is not None:
                usewin = not dir3diff
            else:
                usewin = not dirdiff
    if usewin:
        # Multiple required tools, or tool does not support directory diffs
        sa = [mod_a, add_a, rem_a]
        sb = [mod_b, add_b, rem_b]
        dlg = FileSelectionDialog(repo, pats, ctx1a, sa, ctx1b, sb, ctx2, cpy)
        return dlg

    # We can directly use the selected tool, without a visual diff window
    diffcmd, diffopts, mergeopts = detectedtools[preferred]

    # Disable 3-way merge if there is only one parent or no tool support
    do3way = False
    if ctx1b:
        if mergeopts:
            do3way = True
            args = mergeopts
        else:
            args = diffopts
            if str(ctx1b.rev()) in revs:
                ctx1a = ctx1b
    else:
        args = diffopts

Steve Borho's avatar
Steve Borho committed
380
    def dodiff():
381
382
        assert not (hascopies and len(MAR) > 1), \
                'dodiff cannot handle copies when diffing dirs'
383

384
385
386
387
388
389
        sa = [mod_a, add_a, rem_a]
        sb = [mod_b, add_b, rem_b]
        ctxs = [ctx1a, ctx1b, ctx2]

        # If more than one file, diff on working dir copy.
        copyworkingdir = len(MAR) > 1
390
        dirs, labels, fns_and_mtimes = snapshotset(repo, ctxs, sa, sb, cpy,
391
392
393
394
395
                                                   copyworkingdir)
        dir1a, dir1b, dir2 = dirs
        label1a, label1b, label2 = labels
        fns_and_mtime = fns_and_mtimes[2]

396
        if len(MAR) > 1 and label2 == b'':
397
            label2 = b'working files'
398
399

        def getfile(fname, dir, label):
400
            # type: (bytes, bytes, bytes) -> Tuple[bytes, bytes]
Steve Borho's avatar
Steve Borho committed
401
            file = os.path.join(qtlib.gettempdir(), dir, fname)
402
403
            if os.path.isfile(file):
                return fname+label, file
404
            nullfile = os.path.join(qtlib.gettempdir(), b'empty')
kiilerix's avatar
kiilerix committed
405
            fp = open(nullfile, 'wb')
406
            fp.close()
407
408
            return (hglib.fromunicode(_nonexistant, 'replace') + label,
                    nullfile)
409
410
411
412

        # If only one change, diff the files instead of the directories
        # Handle bogus modifies correctly by checking if the files exist
        if len(MAR) == 1:
413
414
            file2 = MAR.pop()
            file2local = util.localpath(file2)
415
416
417
418
419
            if file2 in cto:
                file1 = util.localpath(cpy[file2])
            else:
                file1 = file2
            label1a, dir1a = getfile(file1, dir1a, label1a)
420
            if do3way:
421
                label1b, dir1b = getfile(file1, dir1b, label1b)
422
            label2, dir2 = getfile(file2local, dir2, label2)
423
        if do3way:
424
425
426
            label1a += b'[local]'
            label1b += b'[other]'
            label2 += b'[merged]'
427

428
        repoagent = repo._pyqtobj  # TODO
429
430

        # TODO: sort out bytes vs str
431
432
433
        replace = dict(parent=dir1a, parent1=dir1a, parent2=dir1b,
                       plabel1=label1a, plabel2=label1b,
                       phash1=str(ctx1a), phash2=str(ctx1b),
434
                       repo=hglib.fromunicode(repoagent.displayName()),
435
                       clabel=label2, child=dir2, chash=str(ctx2))
436
        launchtool(diffcmd, args, replace, True)  # type: Dict[Text, Union[bytes, Text]]
437
438
439

        # detect if changes were made to mirrored working files
        for copy_fn, working_fn, mtime in fns_and_mtime:
440
            try:
441
                if os.lstat(copy_fn).st_mtime != mtime:
442
443
444
                    ui.debug(b'file changed while diffing. '
                             b'Overwriting: %s (src: %s)\n'
                             % (working_fn, copy_fn))
445
446
447
                    util.copyfile(copy_fn, working_fn)
            except EnvironmentError:
                pass # Ignore I/O errors or missing files
448
449
450

    def dodiffwrapper():
        try:
Steve Borho's avatar
Steve Borho committed
451
            dodiff()
452
        finally:
Steve Borho's avatar
Steve Borho committed
453
            # cleanup happens atexit
454
            ui.note(b'cleaning up temp directory\n')
455

456
    if mainapp:
457
458
459
460
461
462
463
464
        dodiffwrapper()
    else:
        # We are not the main application, so this must be done in a
        # background thread
        thread = threading.Thread(target=dodiffwrapper, name='visualdiff')
        thread.setDaemon(True)
        thread.start()

465
class FileSelectionDialog(QDialog):
466
467
    'Dialog for selecting visual diff candidates'
    def __init__(self, repo, pats, ctx1a, sa, ctx1b, sb, ctx2, cpy):
468
        # type: (localrepo.localrepository, Sequence[bytes], HgContext, List[Set[bytes]], Optional[HgContext], List[Set[bytes]], HgContext, Dict[bytes, bytes]) -> None
469
        'Initialize the Dialog'
470
        QDialog.__init__(self)
471

472
        self.setWindowIcon(qtlib.geticon('visualdiff'))
473
474
475
476

        if ctx2.rev() is None:
            title = _('working changes')
        elif ctx1a == ctx2.parents()[0]:
477
            title = _('changeset %d:%s') % (ctx2.rev(), ctx2)
478
        else:
479
480
            title = _('revisions %d:%s to %d:%s') \
                % (ctx1a.rev(), ctx1a, ctx2.rev(), ctx2)
481
482
483
        title = _('Visual Diffs - ') + title
        if pats:
            title += _(' filtered')
484
        self.setWindowTitle(title)
485

486
        self.resize(650, 250)
487
488
        repoagent = repo._pyqtobj  # TODO
        self.reponame = hglib.fromunicode(repoagent.displayName())
489
490

        self.ctxs = (ctx1a, ctx1b, ctx2)
491
        self.filesets = (sa, sb)
492
        self.copies = cpy
493
        self.repo = repo
494
        self.curFile = None  # type: Optional[bytes]
495

496
        layout = QVBoxLayout()
497
498
        self.setLayout(layout)

499
500
        lbl = QLabel(_('Temporary files are removed when this dialog '
                       'is closed'))
501
502
        layout.addWidget(lbl)

503
        list = QListWidget()
504
505
        layout.addWidget(list)
        self.list = list
506
        list.itemActivated.connect(self.itemActivated)
507
508
509
510

        tools = hglib.difftools(repo.ui)
        preferred = besttool(repo.ui, tools)
        self.diffpath, self.diffopts, self.mergeopts = tools[preferred]
511
        self.tools = tools
512
        self.preferred = preferred
513
514

        if len(tools) > 1:
515
            hbox = QHBoxLayout()
516
517
            combo = QComboBox()
            lbl = QLabel(_('Select Tool:'))
518
519
520
521
            lbl.setBuddy(combo)
            hbox.addWidget(lbl)
            hbox.addWidget(combo, 1)
            layout.addLayout(hbox)
522
            for i, name in enumerate(tools.keys()):
523
                combo.addItem(hglib.tounicode(name))
524
525
526
527
                if name == preferred:
                    defrow = i
            combo.setCurrentIndex(defrow)

528
            list.currentRowChanged.connect(self.updateToolSelection)
529
            combo.currentIndexChanged[str].connect(self.onToolSelected)
530
            self.toolCombo = combo
531

532
        BB = QDialogButtonBox
533
534
        bb = BB()
        layout.addWidget(bb)
535
536
537
538
539
540

        if ctx2.rev() is None:
            pass
            # Do not offer directory diffs when the working directory
            # is being referenced directly
        elif ctx1b:
541
542
543
544
545
546
            self.p1button = bb.addButton(_('Dir diff to p1'), BB.ActionRole)
            self.p1button.pressed.connect(self.p1dirdiff)
            self.p2button = bb.addButton(_('Dir diff to p2'), BB.ActionRole)
            self.p2button.pressed.connect(self.p2dirdiff)
            self.p3button = bb.addButton(_('3-way dir diff'), BB.ActionRole)
            self.p3button.pressed.connect(self.threewaydirdiff)
547
        else:
548
549
            self.dbutton = bb.addButton(_('Directory diff'), BB.ActionRole)
            self.dbutton.pressed.connect(self.p1dirdiff)
550

551
        self.updateDiffButtons(preferred)
552

553
        QShortcut(QKeySequence('CTRL+D'), self.list, self.activateCurrent)
554
        QTimer.singleShot(0, self.fillmodel)
555

556
557
    @pyqtSlot()
    def fillmodel(self):
558
        # type: () -> None
559
560
        repo = self.repo
        sa, sb = self.filesets
561
        self.dirs, self.revs = snapshotset(repo, self.ctxs, sa, sb, self.copies)[:2]
562
563

        def get_status(file, mod, add, rem):
564
            # type: (bytes, Set[bytes], Set[bytes], Set[bytes]) -> Text
565
566
567
568
569
570
571
572
            if file in mod:
                return 'M'
            if file in add:
                return 'A'
            if file in rem:
                return 'R'
            return ' '

573
        mod_a, add_a, rem_a = sa
574
        for f in sorted(mod_a | add_a | rem_a):
575
            status = get_status(f, mod_a, add_a, rem_a)
576
            row = '%s %s' % (status, hglib.tounicode(f))
577
            self.list.addItem(row)
578

579
    @pyqtSlot(str)
580
    def onToolSelected(self, tool):
581
        # type: (Text) -> None
582
        'user selected a tool from the tool combo'
583
        tool = hglib.fromunicode(tool)  # pytype: disable=annotation-type-mismatch
584
        assert tool in self.tools, tool
585
586
        self.diffpath, self.diffopts, self.mergeopts = self.tools[tool]
        self.updateDiffButtons(tool)
587

588
589
    @pyqtSlot(int)
    def updateToolSelection(self, row):
590
        # type: (int) -> None
591
        'user selected a file, pick an appropriate tool from combo'
592
593
594
595
        if row == -1:
            return

        repo = self.repo
596
        patterns = repo.ui.configitems(b'diff-patterns')
597
        patterns = [(p, t) for p,t in patterns if t in self.tools]
598

599
600
601
602
603
        fname = self.list.item(row).text()[2:]
        fname = hglib.fromunicode(fname)
        if self.curFile == fname:
            return
        self.curFile = fname
604
        for pat, tool in patterns:
605
            mf = match.match(repo.root, b'', [pat])
606
607
608
609
            if mf(fname):
                selected = tool
                break
        else:
610
            selected = self.preferred
611
        for i, name in enumerate(self.tools.keys()):
612
            if name == selected:
613
                self.toolCombo.setCurrentIndex(i)
614

615
    def activateCurrent(self):
616
        # type: () -> None
617
618
619
620
621
        'CTRL+D has been pressed'
        row = self.list.currentRow()
        if row >= 0:
            self.launch(self.list.item(row).text()[2:])

622
    def itemActivated(self, item):
623
        # type: (QListWidgetItem) -> None
624
625
        'A QListWidgetItem has been activated'
        self.launch(item.text()[2:])
626

627
    def updateDiffButtons(self, tool):
628
        # type: (bytes) -> None
629
        # hg>=4.4: configbool() may return None as the default is set to None
630
        if hasattr(self, 'p1button'):
631
632
            d2 = self.repo.ui.configbool(b'merge-tools', tool + b'.dirdiff')
            d3 = self.repo.ui.configbool(b'merge-tools', tool + b'.dir3diff')
633
634
635
            self.p1button.setEnabled(bool(d2))
            self.p2button.setEnabled(bool(d2))
            self.p3button.setEnabled(bool(d3))
636
        elif hasattr(self, 'dbutton'):
637
            d2 = self.repo.ui.configbool(b'merge-tools', tool + b'.dirdiff')
638
            self.dbutton.setEnabled(bool(d2))
639

640
    def launch(self, fname):
641
642
        # type: (Text) -> None
        fname = hglib.fromunicode(fname)  # pytype: disable=annotation-type-mismatch
643
644
645
646
647
        source = self.copies.get(fname, None)
        dir1a, dir1b, dir2 = self.dirs
        rev1a, rev1b, rev2 = self.revs
        ctx1a, ctx1b, ctx2 = self.ctxs

648
        # pytype: disable=redundant-function-type-comment
649
        def getfile(ctx, dir, fname, source):
650
            # type: (HgContext, bytes, bytes, Optional[bytes]) -> Tuple[bytes, bytes]
651
652
653
654
655
656
657
658
            m = ctx.manifest()
            if fname in m:
                path = os.path.join(dir, util.localpath(fname))
                return fname, path
            elif source and source in m:
                path = os.path.join(dir, util.localpath(source))
                return source, path
            else:
659
                nullfile = os.path.join(qtlib.gettempdir(), b'empty')
660
661
                fp = open(nullfile, 'w')
                fp.close()
662
                return hglib.fromunicode(_nonexistant, 'replace'), nullfile
663
        # pytype: enable=redundant-function-type-comment
664
665
666
667
668

        local, file1a = getfile(ctx1a, dir1a, fname, source)
        if ctx1b:
            other, file1b = getfile(ctx1b, dir1b, fname, source)
        else:
Steve Borho's avatar
Steve Borho committed
669
            other, file1b = fname, None
670
        fname, file2 = getfile(ctx2, dir2, fname, None)  # pytype: disable=annotation-type-mismatch
671
672
673
674
675

        label1a = local+rev1a
        label1b = other+rev1b
        label2 = fname+rev2
        if ctx1b:
676
677
678
            label1a += b'[local]'
            label1b += b'[other]'
            label2 += b'[merged]'
679
680
681
682
683
684

        # Function to quote file/dir names in the argument string
        replace = dict(parent=file1a, parent1=file1a, plabel1=label1a,
                       parent2=file1b, plabel2=label1b,
                       repo=self.reponame,
                       phash1=str(ctx1a), phash2=str(ctx1b), chash=str(ctx2),
685
                       clabel=label2, child=file2)  # type: Dict[Text, Union[bytes, Text]]
686
687
688
        args = ctx1b and self.mergeopts or self.diffopts
        launchtool(self.diffpath, args, replace, False)

689
    def p1dirdiff(self):
690
        # type: () -> None
691
692
693
694
695
696
697
        dir1a, dir1b, dir2 = self.dirs
        rev1a, rev1b, rev2 = self.revs
        ctx1a, ctx1b, ctx2 = self.ctxs

        replace = dict(parent=dir1a, parent1=dir1a, plabel1=rev1a,
                       repo=self.reponame,
                       phash1=str(ctx1a), phash2=str(ctx1b), chash=str(ctx2),
698
                       parent2='', plabel2='', clabel=rev2, child=dir2)  # type: Dict[Text, Union[bytes, Text]]
699
700
        launchtool(self.diffpath, self.diffopts, replace, False)

701
    def p2dirdiff(self):
702
        # type: () -> None
703
704
705
706
707
708
709
        dir1a, dir1b, dir2 = self.dirs
        rev1a, rev1b, rev2 = self.revs
        ctx1a, ctx1b, ctx2 = self.ctxs

        replace = dict(parent=dir1b, parent1=dir1b, plabel1=rev1b,
                       repo=self.reponame,
                       phash1=str(ctx1a), phash2=str(ctx1b), chash=str(ctx2),
710
                       parent2='', plabel2='', clabel=rev2, child=dir2)  # type: Dict[Text, Union[bytes, Text]]
711
712
        launchtool(self.diffpath, self.diffopts, replace, False)

713
    def threewaydirdiff(self):
714
        # type: () -> None
715
716
717
718
719
720
721
        dir1a, dir1b, dir2 = self.dirs
        rev1a, rev1b, rev2 = self.revs
        ctx1a, ctx1b, ctx2 = self.ctxs

        replace = dict(parent=dir1a, parent1=dir1a, plabel1=rev1a,
                       repo=self.reponame,
                       phash1=str(ctx1a), phash2=str(ctx1b), chash=str(ctx2),
722
                       parent2=dir1b, plabel2=rev1b, clabel=dir2, child=rev2)  # type: Dict[Text, Union[bytes, Text]]
723
        launchtool(self.diffpath, self.mergeopts, replace, False)