1# repotreeitem.py - treeitems for the reporegistry
2#
3# Copyright 2010 Adrian Buehlmann <adrian@cadifra.com>
4#
5# This software may be used and distributed according to the terms of the
6# GNU General Public License version 2 or any later version.
7
8from __future__ import absolute_import
9
10import os
11import re
12
13from .qtcore import (
14    Qt,
15)
16from .qtgui import (
17    QApplication,
18    QMessageBox,
19    QStyle,
20)
21
22from mercurial import (
23    error,
24    hg,
25    node,
26    pycompat,
27    util,
28)
29
30from ..util import (
31    hglib,
32    paths,
33)
34from ..util.i18n import _
35from . import (
36    hgrcutil,
37    qtlib,
38)
39
40def _dumpChild(xw, parent):
41    for c in parent.childs:
42        c.dumpObject(xw)
43
44def undumpObject(xr):
45    xmltagname = str(xr.name())
46    obj = _xmlUndumpMap[xmltagname](xr)
47    assert obj.xmltagname == xmltagname, (obj.xmltagname, xmltagname)
48    return obj
49
50def _undumpChild(xr, parent, undump=undumpObject):
51    while not xr.atEnd():
52        xr.readNext()
53        if xr.isStartElement():
54            try:
55                item = undump(xr)
56                parent.appendChild(item)
57            except KeyError:
58                pass # ignore unknown classes in xml
59        elif xr.isEndElement():
60            break
61
62def flatten(root, stopfunc=None):
63    """Iterate root and its child items recursively until stop condition"""
64    yield root
65    if stopfunc and stopfunc(root):
66        return
67    for c in root.childs:
68        for e in flatten(c, stopfunc):
69            yield e
70
71def find(root, targetfunc, stopfunc=None):
72    """Search recursively for item of which targetfunc evaluates to True"""
73    for e in flatten(root, stopfunc):
74        if targetfunc(e):
75            return e
76    raise ValueError('not found')
77
78# '/' for path separator, '#n' for index of duplicated names
79_quotenamere = re.compile(r'[%/#]')
80
81def _quotename(s):
82    r"""Replace special characters to %xx (minimal set of urllib.quote)
83
84    >>> _quotename('foo/bar%baz#qux')
85    'foo%2Fbar%25baz%23qux'
86    >>> _quotename(u'\xa1')
87    u'\xa1'
88    """
89    return _quotenamere.sub(lambda m: '%%%02X' % ord(m.group(0)), s)
90
91def _buildquotenamemap(items):
92    namemap = {}
93    for e in items:
94        q = _quotename(e.shortname())
95        if q not in namemap:
96            namemap[q] = [e]
97        else:
98            namemap[q].append(e)
99    return namemap
100
101def itempath(item):
102    """Virtual path to the given item"""
103    rnames = []
104    while item.parent():
105        namemap = _buildquotenamemap(item.parent().childs)
106        q = _quotename(item.shortname())
107        i = namemap[q].index(item)
108        if i == 0:
109            rnames.append(q)
110        else:
111            rnames.append('%s#%d' % (q, i))
112        item = item.parent()
113    return '/'.join(reversed(rnames))
114
115def findbyitempath(root, path):
116    """Return the item for the given virtual path
117
118    >>> root = RepoTreeItem()
119    >>> foo = RepoGroupItem('foo')
120    >>> root.appendChild(foo)
121    >>> bar = RepoGroupItem('bar')
122    >>> root.appendChild(bar)
123    >>> bar.appendChild(RepoItem('/tmp/baz', 'baz'))
124    >>> root.appendChild(RepoGroupItem('foo'))
125    >>> root.appendChild(RepoGroupItem('qux/quux'))
126
127    >>> def f(path):
128    ...     return itempath(findbyitempath(root, path))
129
130    >>> f('')
131    ''
132    >>> f('foo')
133    'foo'
134    >>> f('bar/baz')
135    'bar/baz'
136    >>> f('qux%2Fquux')
137    'qux%2Fquux'
138    >>> f('bar/baz/unknown')
139    Traceback (most recent call last):
140      ...
141    ValueError: not found
142
143    >>> f('foo#1')
144    'foo#1'
145    >>> f('foo#2')
146    Traceback (most recent call last):
147      ...
148    ValueError: not found
149    >>> f('foo#bar')
150    Traceback (most recent call last):
151      ...
152    ValueError: invalid path
153    """
154    if not path:
155        return root
156    item = root
157    for q in path.split('/'):
158        h = q.rfind('#')
159        if h >= 0:
160            try:
161                i = int(q[h + 1:])
162            except ValueError:
163                raise ValueError('invalid path')
164            q = q[:h]
165        else:
166            i = 0
167        namemap = _buildquotenamemap(item.childs)
168        try:
169            item = namemap[q][i]
170        except LookupError:
171            raise ValueError('not found')
172    return item
173
174
175class RepoTreeItem(object):
176    xmltagname = 'treeitem'
177
178    def __init__(self, parent=None):
179        self._parent = parent
180        self.childs = []
181        self._row = 0
182
183    def appendChild(self, child):
184        child._row = len(self.childs)
185        child._parent = self
186        self.childs.append(child)
187
188    def insertChild(self, row, child):
189        child._row = row
190        child._parent = self
191        self.childs.insert(row, child)
192
193    def child(self, row):
194        return self.childs[row]
195
196    def childCount(self):
197        return len(self.childs)
198
199    def columnCount(self):
200        return 2
201
202    def data(self, column, role):
203        return None
204
205    def setData(self, column, value):
206        return False
207
208    def row(self):
209        return self._row
210
211    def parent(self):
212        return self._parent
213
214    def menulist(self):
215        return []
216
217    def flags(self):
218        return Qt.NoItemFlags
219
220    def removeRows(self, row, count):
221        cs = self.childs
222        remove = cs[row : row + count]
223        keep = cs[:row] + cs[row + count:]
224        self.childs = keep
225        for c in remove:
226            c._row = 0
227            c._parent = None
228        for i, c in enumerate(keep):
229            c._row = i
230        return True
231
232    def dump(self, xw):
233        _dumpChild(xw, parent=self)
234
235    @classmethod
236    def undump(cls, xr):
237        obj = cls()
238        _undumpChild(xr, parent=obj)
239        return obj
240
241    def dumpObject(self, xw):
242        xw.writeStartElement(self.xmltagname)
243        self.dump(xw)
244        xw.writeEndElement()
245
246    def isRepo(self):
247        return False
248
249    def details(self):
250        return ''
251
252    def okToDelete(self):
253        return True
254
255    def getSupportedDragDropActions(self):
256        return Qt.MoveAction
257
258
259class RepoItem(RepoTreeItem):
260    xmltagname = 'repo'
261
262    def __init__(self, root, shortname=None, basenode=None, sharedpath=None,
263                 parent=None):
264        RepoTreeItem.__init__(self, parent)
265        self._root = root
266        self._shortname = shortname or u''
267        self._basenode = basenode or node.nullid
268        # expensive check is done at appendSubrepos()
269        self._sharedpath = sharedpath or ''
270        self._valid = True
271
272    def isRepo(self):
273        return True
274
275    def rootpath(self):
276        return self._root
277
278    def shortname(self):
279        if self._shortname:
280            return self._shortname
281        else:
282            return os.path.basename(self._root)
283
284    def repotype(self):
285        return 'hg'
286
287    def basenode(self):
288        """Return node id of revision 0"""
289        return self._basenode
290
291    def setBaseNode(self, basenode):
292        self._basenode = basenode
293
294    def setShortName(self, uname):
295        uname = pycompat.unicode(uname)
296        if uname != self._shortname:
297            self._shortname = uname
298
299    def data(self, column, role):
300        if role == Qt.DecorationRole and column == 0:
301            baseiconname = 'hg'
302            if paths.is_unc_path(self.rootpath()):
303                baseiconname = 'thg-remote-repo'
304            ico = qtlib.geticon(baseiconname)
305            if not self._valid:
306                ico = qtlib.getoverlaidicon(ico, qtlib.geticon('dialog-warning'))
307            elif self._sharedpath:
308                ico = qtlib.getoverlaidicon(ico, qtlib.geticon('hg-sharedrepo'))
309            return ico
310        elif role in (Qt.DisplayRole, Qt.EditRole):
311            return [self.shortname, self.shortpath][column]()
312
313    def getCommonPath(self):
314        return self.parent().getCommonPath()
315
316    def shortpath(self):
317        try:
318            cpath = self.getCommonPath()
319        except:
320            cpath = ''
321        spath2 = spath = os.path.normpath(self._root)
322
323        if os.name == 'nt':
324            spath2 = spath2.lower()
325
326        if cpath and spath2.startswith(cpath):
327            iShortPathStart = len(cpath)
328            spath = spath[iShortPathStart:]
329            if spath and spath[0] in '/\\':
330                # do not show a slash at the beginning of the short path
331                spath = spath[1:]
332
333        return spath
334
335    def menulist(self):
336        acts = ['open', 'clone', 'addsubrepo', None, 'explore',
337                'terminal', 'copypath', None, 'rename', 'remove']
338        if self.childCount() > 0:
339            acts.extend([None, (_('&Sort'), ['sortbyname', 'sortbyhgsub'])])
340        acts.extend([None, 'settings'])
341        return acts
342
343    def flags(self):
344        return (Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsDragEnabled
345            | Qt.ItemIsEditable)
346
347    def dump(self, xw):
348        xw.writeAttribute('root', self._root)
349        xw.writeAttribute('shortname', self.shortname())
350        xw.writeAttribute('basenode',
351                          pycompat.sysstr(node.hex(self.basenode())))
352        if self._sharedpath:
353            xw.writeAttribute('sharedpath', self._sharedpath)
354        _dumpChild(xw, parent=self)
355
356    @classmethod
357    def undump(cls, xr):
358        a = xr.attributes()
359        obj = cls(pycompat.unicode(a.value('', 'root')),
360                  pycompat.unicode(a.value('', 'shortname')),
361                  node.bin(str(a.value('', 'basenode'))),
362                  pycompat.unicode(a.value('', 'sharedpath')))
363        _undumpChild(xr, parent=obj, undump=_undumpSubrepoItem)
364        return obj
365
366    def details(self):
367        return _('Local Repository %s') % self._root
368
369    def appendSubrepos(self, repo=None):
370        self._sharedpath = ''
371        invalidRepoList = []
372        sri = None
373        abssubpath = None
374        try:
375            if repo is None:
376                if not os.path.exists(self._root):
377                    self._valid = False
378                    return [hglib.fromunicode(self._root)]
379                elif (not os.path.exists(os.path.join(self._root, '.hgsub'))
380                      and not os.path.exists(
381                          os.path.join(self._root, '.hg', 'sharedpath'))):
382                    return []  # skip repo creation, which is expensive
383                repo = hg.repository(hglib.loadui(),
384                                     hglib.fromunicode(self._root))
385            if repo.sharedpath != repo.path:
386                self._sharedpath = hglib.tounicode(repo.sharedpath)
387            wctx = repo[b'.']
388            sortkey = lambda x: os.path.basename(util.normpath(repo.wjoin(x)))
389            for subpath in sorted(wctx.substate, key=sortkey):
390                sri = None
391                abssubpath = repo.wjoin(subpath)
392                subtype = pycompat.sysstr(wctx.substate[subpath][2])
393                sriIsValid = os.path.isdir(abssubpath)
394                sri = _newSubrepoItem(hglib.tounicode(abssubpath),
395                                      repotype=subtype)
396                sri._valid = sriIsValid
397                self.appendChild(sri)
398
399                if not sriIsValid:
400                    self._valid = False
401                    sri._valid = False
402                    invalidRepoList.append(repo.wjoin(subpath))
403                    return invalidRepoList
404
405                if subtype == 'hg':
406                    # Only recurse into mercurial subrepos
407                    sctx = wctx.sub(subpath)
408                    invalidSubrepoList = sri.appendSubrepos(sctx._repo)
409                    if invalidSubrepoList:
410                        self._valid = False
411                        invalidRepoList += invalidSubrepoList
412
413        except (EnvironmentError, error.RepoError, error.Abort) as e:
414            # Add the repo to the list of repos/subrepos
415            # that could not be open
416            self._valid = False
417            if sri:
418                sri._valid = False
419                invalidRepoList.append(abssubpath)
420            invalidRepoList.append(hglib.fromunicode(self._root))
421        except Exception as e:
422            # If any other sort of exception happens, show the corresponding
423            # error message, but do not crash!
424            # Note that we _also_ will mark the offending repos as invalid
425            self._valid = False
426            if sri:
427                sri._valid = False
428                invalidRepoList.append(abssubpath)
429            invalidRepoList.append(hglib.fromunicode(self._root))
430
431            # Show a warning message indicating that there was an error
432            if repo:
433                rootpath = hglib.tounicode(repo.root)
434            else:
435                rootpath = self._root
436            warningMessage = (_('An exception happened while loading the '
437                'subrepos of:<br><br>"%s"<br><br>') +
438                _('The exception error message was:<br><br>%s<br><br>') +
439                _('Click OK to continue or Abort to exit.')) \
440                % (rootpath, hglib.tounicode(str(e)))
441            res = qtlib.WarningMsgBox(_('Error loading subrepos'),
442                                warningMessage,
443                                buttons = QMessageBox.Ok | QMessageBox.Abort)
444            # Let the user abort so that he gets the full exception info
445            if res == QMessageBox.Abort:
446                raise
447        return invalidRepoList
448
449    def setData(self, column, value):
450        if column == 0:
451            shortname = hglib.fromunicode(value)
452            abshgrcpath = os.path.join(hglib.fromunicode(self.rootpath()),
453                                       b'.hg', b'hgrc')
454            if not hgrcutil.setConfigValue(abshgrcpath, b'web.name', shortname):
455                qtlib.WarningMsgBox(_('Unable to update repository name'),
456                    _('An error occurred while updating the repository hgrc '
457                      'file (%s)') % hglib.tounicode(abshgrcpath))
458                return False
459            self.setShortName(value)
460            return True
461        return False
462
463
464_subrepoType2IcoMap = {
465    'hg': 'hg',
466    'git': 'thg-git-subrepo',
467    'svn': 'thg-svn-subrepo',
468    }
469
470def _newSubrepoIcon(repotype, valid=True):
471    subiconame = _subrepoType2IcoMap.get(repotype)
472    if subiconame is None:
473        ico = qtlib.geticon('thg-subrepo')
474    else:
475        ico = qtlib.geticon(subiconame)
476        ico = qtlib.getoverlaidicon(ico, qtlib.geticon('thg-subrepo'))
477    if not valid:
478        ico = qtlib.getoverlaidicon(ico, qtlib.geticon('dialog-warning'))
479    return ico
480
481class StandaloneSubrepoItem(RepoItem):
482    """Mercurial repository just decorated as subrepo"""
483    xmltagname = 'subrepo'
484
485    def data(self, column, role):
486        if role == Qt.DecorationRole and column == 0:
487            return _newSubrepoIcon('hg', valid=self._valid)
488        else:
489            return super(StandaloneSubrepoItem, self).data(column, role)
490
491class SubrepoItem(RepoItem):
492    """Actual Mercurial subrepo"""
493    xmltagname = 'subrepo'
494
495    def data(self, column, role):
496        if role == Qt.DecorationRole and column == 0:
497            return _newSubrepoIcon('hg', valid=self._valid)
498        else:
499            return super(SubrepoItem, self).data(column, role)
500
501    def menulist(self):
502        acts = ['open', 'clone', None, 'addsubrepo', 'removesubrepo',
503                None, 'explore', 'terminal', 'copypath']
504        if self.childCount() > 0:
505            acts.extend([None, (_('&Sort'), ['sortbyname', 'sortbyhgsub'])])
506        acts.extend([None, 'settings'])
507        return acts
508
509    def getSupportedDragDropActions(self):
510        return Qt.CopyAction
511
512    def flags(self):
513        return Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsDragEnabled
514
515# possibly this should not be a RepoItem because it lacks common functions
516class AlienSubrepoItem(RepoItem):
517    """Actual non-Mercurial subrepo"""
518    xmltagname = 'subrepo'
519
520    def __init__(self, root, repotype, parent=None):
521        RepoItem.__init__(self, root, parent=parent)
522        self._repotype = repotype
523
524    def data(self, column, role):
525        if role == Qt.DecorationRole and column == 0:
526            return _newSubrepoIcon(self._repotype)
527        else:
528            return super(AlienSubrepoItem, self).data(column, role)
529
530    def menulist(self):
531        return ['explore', 'terminal', 'copypath']
532
533    def flags(self):
534        return Qt.ItemIsEnabled | Qt.ItemIsSelectable
535
536    def repotype(self):
537        return self._repotype
538
539    def dump(self, xw):
540        xw.writeAttribute('root', self._root)
541        xw.writeAttribute('repotype', self._repotype)
542
543    @classmethod
544    def undump(cls, xr):
545        a = xr.attributes()
546        obj = cls(pycompat.unicode(a.value('', 'root')),
547                  str(a.value('', 'repotype')))
548        xr.skipCurrentElement()  # no child
549        return obj
550
551    def appendSubrepos(self, repo=None):
552        raise Exception('unsupported by non-hg subrepo')
553
554def _newSubrepoItem(root, repotype):
555    if repotype == 'hg':
556        return SubrepoItem(root)
557    else:
558        return AlienSubrepoItem(root, repotype=repotype)
559
560def _undumpSubrepoItem(xr):
561    a = xr.attributes()
562    repotype = str(a.value('', 'repotype')) or 'hg'
563    if repotype == 'hg':
564        return SubrepoItem.undump(xr)
565    else:
566        return AlienSubrepoItem.undump(xr)
567
568class RepoGroupItem(RepoTreeItem):
569    xmltagname = 'group'
570
571    def __init__(self, name, parent=None):
572        RepoTreeItem.__init__(self, parent)
573        self.name = name
574        self._commonpath = ''
575
576    def data(self, column, role):
577        if role == Qt.DecorationRole:
578            if column == 0:
579                s = QApplication.style()
580                ico = s.standardIcon(QStyle.SP_DirIcon)
581                return ico
582            return None
583        if column == 0:
584            return self.name
585        elif column == 1:
586            return self.getCommonPath()
587        return None
588
589    def setData(self, column, value):
590        if column == 0:
591            self.name = pycompat.unicode(value)
592            return True
593        return False
594
595    def rootpath(self):  # for sortbypath()
596        return ''  # may be okay to return _commonpath instead?
597
598    def shortname(self):  # for sortbyname()
599        return self.name
600
601    def menulist(self):
602        return ['openAll', 'add', None, 'newGroup', None, 'rename', 'remove',
603            None, (_('&Sort'), ['sortbyname', 'sortbypath']), None,
604            'reloadRegistry']
605
606    def flags(self):
607        return (Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsDropEnabled
608            | Qt.ItemIsDragEnabled | Qt.ItemIsEditable)
609
610    def childRoots(self):
611        return [c._root for c in self.childs if isinstance(c, RepoItem)]
612
613    def dump(self, xw):
614        xw.writeAttribute('name', self.name)
615        _dumpChild(xw, parent=self)
616
617    @classmethod
618    def undump(cls, xr):
619        a = xr.attributes()
620        obj = cls(pycompat.unicode(a.value('', 'name')))
621        _undumpChild(xr, parent=obj)
622        return obj
623
624    def okToDelete(self):
625        return False
626
627    def updateCommonPath(self, cpath=None):
628        """
629        Update or set the group 'common path'
630
631        When called with no arguments, the group common path is calculated by
632        looking for the common path of all the repos on a repo group
633
634        When called with an argument, the group common path is set to the input
635        argument. This is commonly used to set the group common path to an empty
636        string, thus disabling the "show short paths" functionality.
637        """
638        if cpath is not None:
639            self._commonpath = cpath
640        elif len(self.childs) == 0:
641            # If a group has no repo items, the common path is empty
642            self._commonpath = ''
643        else:
644            childs = [os.path.normcase(child.rootpath())
645                      for child in self.childs
646                      if not isinstance(child, RepoGroupItem)]
647            self._commonpath = os.path.dirname(os.path.commonprefix(childs))
648
649    def getCommonPath(self):
650        return self._commonpath
651
652class AllRepoGroupItem(RepoGroupItem):
653    xmltagname = 'allgroup'
654
655    def __init__(self, name=None, parent=None):
656        RepoGroupItem.__init__(self, name or _('default'), parent=parent)
657
658    def menulist(self):
659        return ['openAll', 'add', None, 'newGroup', None, 'rename',
660            None, (_('&Sort'), ['sortbyname', 'sortbypath']), None,
661            'reloadRegistry']
662
663_xmlUndumpMap = {
664    'allgroup': AllRepoGroupItem.undump,
665    'group': RepoGroupItem.undump,
666    'repo': RepoItem.undump,
667    'subrepo': StandaloneSubrepoItem.undump,
668    'treeitem': RepoTreeItem.undump,
669    }
670