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