extras: pylint fixes
[git-cola.git] / cola / widgets / branch.py
blob7a828f7d092fd245e4e12753ea2a5fc8e757db25
1 """Provides widgets related to branches"""
2 from __future__ import absolute_import, division, print_function, unicode_literals
3 from functools import partial
5 from qtpy import QtWidgets
6 from qtpy.QtCore import Qt
7 from qtpy.QtCore import Signal
9 from ..compat import uchr
10 from ..i18n import N_
11 from ..interaction import Interaction
12 from ..widgets import defs
13 from ..widgets import standard
14 from ..qtutils import get
15 from .. import cmds
16 from .. import gitcmds
17 from .. import hotkeys
18 from .. import icons
19 from .. import qtutils
20 from .text import LineEdit
23 def defer_fn(parent, title, fn, *args, **kwargs):
24 return qtutils.add_action(parent, title, partial(fn, *args, **kwargs))
27 def add_branch_to_menu(menu, branch, remote_branch, remote, upstream, fn):
28 """Add a remote branch to the context menu"""
29 branch_remote, _ = gitcmds.parse_remote_branch(remote_branch)
30 if branch_remote != remote:
31 menu.addSeparator()
32 action = defer_fn(menu, remote_branch, fn, branch, remote_branch)
33 if remote_branch == upstream:
34 action.setIcon(icons.star())
35 menu.addAction(action)
36 return branch_remote
39 class AsyncGitActionTask(qtutils.Task):
40 """Run git action asynchronously"""
42 def __init__(self, git_helper, action, args, kwarg):
43 qtutils.Task.__init__(self)
44 self.git_helper = git_helper
45 self.action = action
46 self.args = args
47 self.kwarg = kwarg
49 def task(self):
50 """Runs action and captures the result"""
51 git_action = getattr(self.git_helper, self.action)
52 return git_action(*self.args, **self.kwarg)
55 class BranchesWidget(QtWidgets.QFrame):
56 def __init__(self, context, parent):
57 QtWidgets.QFrame.__init__(self, parent)
58 self.model = model = context.model
60 tooltip = N_('Toggle the branches filter')
61 icon = icons.ellipsis()
62 self.filter_button = qtutils.create_action_button(tooltip=tooltip, icon=icon)
64 self.order_icons = (
65 icons.alphabetical(),
66 icons.reverse_chronological(),
68 tooltip_order = N_(
69 'Set the sort order for branches and tags.\n'
70 'Toggle between date-based and version-name-based sorting.'
72 icon = self.order_icon(model.ref_sort)
73 self.sort_order_button = qtutils.create_action_button(
74 tooltip=tooltip_order, icon=icon
77 self.tree = BranchesTreeWidget(context, parent=self)
78 self.filter_widget = BranchesFilterWidget(self.tree)
79 self.filter_widget.hide()
81 self.setFocusProxy(self.tree)
82 self.setToolTip(N_('Branches'))
84 self.main_layout = qtutils.vbox(
85 defs.no_margin, defs.spacing, self.filter_widget, self.tree
87 self.setLayout(self.main_layout)
89 self.toggle_action = qtutils.add_action(
90 self, tooltip, self.toggle_filter, hotkeys.FILTER
92 qtutils.connect_button(self.filter_button, self.toggle_filter)
93 qtutils.connect_button(
94 self.sort_order_button, cmds.run(cmds.CycleReferenceSort, context)
97 model.refs_updated.connect(self.refresh, Qt.QueuedConnection)
99 def toggle_filter(self):
100 shown = not self.filter_widget.isVisible()
101 self.filter_widget.setVisible(shown)
102 if shown:
103 self.filter_widget.setFocus()
104 else:
105 self.tree.setFocus()
107 def order_icon(self, idx):
108 return self.order_icons[idx % len(self.order_icons)]
110 def refresh(self):
111 icon = self.order_icon(self.model.ref_sort)
112 self.sort_order_button.setIcon(icon)
113 self.tree.refresh()
116 # pylint: disable=too-many-ancestors
117 class BranchesTreeWidget(standard.TreeWidget):
118 updated = Signal()
120 def __init__(self, context, parent=None):
121 standard.TreeWidget.__init__(self, parent)
123 self.context = context
125 self.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection)
126 self.setHeaderHidden(True)
127 self.setAlternatingRowColors(False)
128 self.setColumnCount(1)
129 self.setExpandsOnDoubleClick(False)
131 self.tree_helper = BranchesTreeHelper()
132 self.git_helper = GitHelper(context)
133 self.current_branch = None
135 self.runtask = qtutils.RunTask(parent=self)
136 self._active = False
138 self.updated.connect(self.refresh, type=Qt.QueuedConnection)
139 context.model.updated.connect(self.updated)
141 # Expand items when they are clicked
142 # pylint: disable=no-member
143 self.clicked.connect(self._toggle_expanded)
145 # Checkout branch when double clicked
146 self.doubleClicked.connect(self.checkout_action)
148 def refresh(self):
149 if not self._active:
150 return
151 model = self.context.model
152 self.current_branch = model.currentbranch
154 states = self.save_tree_state()
155 ellipsis = icons.ellipsis()
157 local_tree = create_tree_entries(model.local_branches)
158 local_tree.basename = N_('Local')
159 local = create_toplevel_item(local_tree, icon=icons.branch(), ellipsis=ellipsis)
161 remote_tree = create_tree_entries(model.remote_branches)
162 remote_tree.basename = N_('Remote')
163 remote = create_toplevel_item(
164 remote_tree, icon=icons.branch(), ellipsis=ellipsis
167 tags_tree = create_tree_entries(model.tags)
168 tags_tree.basename = N_('Tags')
169 tags = create_toplevel_item(tags_tree, icon=icons.tag(), ellipsis=ellipsis)
171 self.clear()
172 self.addTopLevelItems([local, remote, tags])
173 self.update_select_branch()
174 self.load_tree_state(states)
176 def showEvent(self, event):
177 """Defer updating widgets until the widget is visible"""
178 if not self._active:
179 self._active = True
180 self.refresh()
181 return super(BranchesTreeWidget, self).showEvent(event)
183 def _toggle_expanded(self, index):
184 """Toggle expanded/collapsed state when items are clicked"""
185 self.setExpanded(index, not self.isExpanded(index))
187 def contextMenuEvent(self, event):
188 """Build and execute the context menu"""
189 context = self.context
190 selected = self.selected_item()
191 if not selected:
192 return
193 # Only allow actions on leaf nodes that have a valid refname.
194 if not selected.refname:
195 return
197 root = get_toplevel_item(selected)
198 full_name = selected.refname
199 menu = qtutils.create_menu(N_('Actions'), self)
201 # all branches except current the current branch
202 if full_name != self.current_branch:
203 menu.addAction(
204 qtutils.add_action(menu, N_('Checkout'), self.checkout_action)
206 # remote branch
207 if root.name == N_('Remote'):
208 label = N_('Checkout as new branch')
209 action = self.checkout_new_branch_action
210 menu.addAction(qtutils.add_action(menu, label, action))
212 merge_menu_action = qtutils.add_action(
213 menu, N_('Merge into current branch'), self.merge_action
215 merge_menu_action.setIcon(icons.merge())
217 menu.addAction(merge_menu_action)
219 # local and remote branch
220 if root.name != N_('Tags'):
221 # local branch
222 if root.name == N_('Local'):
224 remote = gitcmds.tracked_branch(context, full_name)
225 if remote is not None:
226 menu.addSeparator()
228 pull_menu_action = qtutils.add_action(
229 menu, N_('Pull'), self.pull_action
231 pull_menu_action.setIcon(icons.pull())
232 menu.addAction(pull_menu_action)
234 push_menu_action = qtutils.add_action(
235 menu, N_('Push'), self.push_action
237 push_menu_action.setIcon(icons.push())
238 menu.addAction(push_menu_action)
240 rename_menu_action = qtutils.add_action(
241 menu, N_('Rename Branch'), self.rename_action
243 rename_menu_action.setIcon(icons.edit())
245 menu.addSeparator()
246 menu.addAction(rename_menu_action)
248 # not current branch
249 if full_name != self.current_branch:
250 delete_label = N_('Delete Branch')
251 if root.name == N_('Remote'):
252 delete_label = N_('Delete Remote Branch')
254 delete_menu_action = qtutils.add_action(
255 menu, delete_label, self.delete_action
257 delete_menu_action.setIcon(icons.discard())
259 menu.addSeparator()
260 menu.addAction(delete_menu_action)
262 # manage upstreams for local branches
263 if root.name == N_('Local'):
264 upstream_menu = menu.addMenu(N_('Set Upstream Branch'))
265 upstream_menu.setIcon(icons.branch())
266 self.build_upstream_menu(upstream_menu)
268 menu.exec_(self.mapToGlobal(event.pos()))
270 def build_upstream_menu(self, menu):
271 """Build the "Set Upstream Branch" sub-menu"""
272 context = self.context
273 model = context.model
274 selected_branch = self.selected_refname()
275 remote = None
276 upstream = None
278 branches = []
279 other_branches = []
281 if selected_branch:
282 remote = gitcmds.upstream_remote(context, selected_branch)
283 upstream = gitcmds.tracked_branch(context, branch=selected_branch)
285 if not remote and 'origin' in model.remotes:
286 remote = 'origin'
288 if remote:
289 prefix = remote + '/'
290 for branch in model.remote_branches:
291 if branch.startswith(prefix):
292 branches.append(branch)
293 else:
294 other_branches.append(branch)
295 else:
296 # This can be a pretty big list, let's try to split it apart
297 branch_remote = ''
298 target = branches
299 for branch in model.remote_branches:
300 new_branch_remote, _ = gitcmds.parse_remote_branch(branch)
301 if branch_remote and branch_remote != new_branch_remote:
302 target = other_branches
303 branch_remote = new_branch_remote
304 target.append(branch)
306 limit = 16
307 if not other_branches and len(branches) > limit:
308 branches, other_branches = (branches[:limit], branches[limit:])
310 # Add an action for each remote branch
311 current_remote = remote
313 for branch in branches:
314 current_remote = add_branch_to_menu(
315 menu,
316 selected_branch,
317 branch,
318 current_remote,
319 upstream,
320 self.set_upstream,
323 # This list could be longer so we tuck it away in a sub-menu.
324 # Selecting a branch from the non-default remote is less common.
325 if other_branches:
326 menu.addSeparator()
327 sub_menu = menu.addMenu(N_('Other branches'))
328 for branch in other_branches:
329 current_remote = add_branch_to_menu(
330 sub_menu,
331 selected_branch,
332 branch,
333 current_remote,
334 upstream,
335 self.set_upstream,
338 def set_upstream(self, branch, remote_branch):
339 """Configure the upstream for a branch"""
340 context = self.context
341 remote, r_branch = gitcmds.parse_remote_branch(remote_branch)
342 if remote and r_branch:
343 cmds.do(cmds.SetUpstreamBranch, context, branch, remote, r_branch)
345 def save_tree_state(self):
346 states = {}
347 for item in self.items():
348 states.update(self.tree_helper.save_state(item))
350 return states
352 def load_tree_state(self, states):
353 for item in self.items():
354 if item.name in states:
355 self.tree_helper.load_state(item, states[item.name])
357 def update_select_branch(self):
358 context = self.context
359 current_branch = self.current_branch
360 top_item = self.topLevelItem(0)
361 item = find_by_refname(top_item, current_branch)
363 if item is not None:
364 expand_item_parents(item)
365 item.setIcon(0, icons.star())
367 tracked_branch = gitcmds.tracked_branch(context, current_branch)
368 if current_branch and tracked_branch:
369 status = {'ahead': 0, 'behind': 0}
370 status_str = ''
372 origin = tracked_branch + '..' + self.current_branch
373 log = self.git_helper.log(origin)
374 status['ahead'] = len(log[1].splitlines())
376 origin = self.current_branch + '..' + tracked_branch
377 log = self.git_helper.log(origin)
378 status['behind'] = len(log[1].splitlines())
380 if status['ahead'] > 0:
381 status_str += '%s%s' % (uchr(0x2191), status['ahead'])
383 if status['behind'] > 0:
384 status_str += ' %s%s' % (uchr(0x2193), status['behind'])
386 if status_str:
387 item.setText(0, '%s\t%s' % (item.text(0), status_str))
389 def git_action_async(self, action, args, kwarg=None):
390 if kwarg is None:
391 kwarg = {}
392 task = AsyncGitActionTask(self, action, args, kwarg)
393 progress = standard.progress(
394 N_('Executing action %s') % action, N_('Updating'), self
396 self.runtask.start(task, progress=progress, finish=self.git_action_completed)
398 def git_action_completed(self, task):
399 status, out, err = task.result
400 self.git_helper.show_result(task.action, status, out, err)
401 self.context.model.update_refs()
403 def push_action(self):
404 context = self.context
405 branch = self.selected_refname()
406 remote_branch = gitcmds.tracked_branch(context, branch)
407 if remote_branch:
408 remote, branch_name = gitcmds.parse_remote_branch(remote_branch)
409 if remote and branch_name:
410 # we assume that user wants to "Push" the selected local
411 # branch to a remote with same name
412 self.git_action_async('push', [remote, branch_name])
414 def rename_action(self):
415 branch = self.selected_refname()
416 new_branch, ok = qtutils.prompt(
417 N_('Enter New Branch Name'), title=N_('Rename branch'), text=branch
419 if ok and new_branch:
420 self.git_action_async('rename', [branch, new_branch])
422 def pull_action(self):
423 context = self.context
424 branch = self.selected_refname()
425 if not branch:
426 return
427 remote_branch = gitcmds.tracked_branch(context, branch)
428 if remote_branch:
429 remote, branch_name = gitcmds.parse_remote_branch(remote_branch)
430 if remote and branch_name:
431 self.git_action_async('pull', [remote, branch_name])
433 def delete_action(self):
434 branch = self.selected_refname()
435 if not branch or branch == self.current_branch:
436 return
438 remote = False
439 root = get_toplevel_item(self.selected_item())
440 if root.name == N_('Remote'):
441 remote = True
443 if remote:
444 remote, branch = gitcmds.parse_remote_branch(branch)
445 if remote and branch:
446 cmds.do(cmds.DeleteRemoteBranch, self.context, remote, branch)
447 else:
448 cmds.do(cmds.DeleteBranch, self.context, branch)
450 def merge_action(self):
451 branch = self.selected_refname()
452 if branch and branch != self.current_branch:
453 self.git_action_async('merge', [branch])
455 def checkout_action(self):
456 branch = self.selected_refname()
457 if branch and branch != self.current_branch:
458 self.git_action_async('checkout', [branch])
460 def checkout_new_branch_action(self):
461 branch = self.selected_refname()
462 if branch and branch != self.current_branch:
463 _, new_branch = gitcmds.parse_remote_branch(branch)
464 self.git_action_async('checkout', ['-b', new_branch, branch])
466 def selected_refname(self):
467 return getattr(self.selected_item(), 'refname', None)
470 class BranchTreeWidgetItem(QtWidgets.QTreeWidgetItem):
471 def __init__(self, name, refname=None, icon=None):
472 QtWidgets.QTreeWidgetItem.__init__(self)
473 self.name = name
474 self.refname = refname
475 self.setText(0, name)
476 self.setToolTip(0, name)
477 if icon is not None:
478 self.setIcon(0, icon)
479 self.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
481 # TODO: review standard.py 317.
482 # original function returns 'QTreeWidgetItem' object which has no
483 # attribute 'rowCount'. This workaround fix error throw when
484 # navigating with keyboard and press left key
485 @staticmethod
486 def rowCount():
487 return 1
490 class TreeEntry(object):
491 """Tree representation for the branches widget
493 The branch widget UI displays the basename. For intermediate names, e.g.
494 "xxx" in the "xxx/abc" and "xxx/def" branches, the 'refname' will be None.
495 'children' contains a list of TreeEntry, and is empty when refname is
496 defined.
500 def __init__(self, basename, refname, children):
501 self.basename = basename
502 self.refname = refname
503 self.children = children
506 def create_tree_entries(names):
507 """Create a nested tree structure with a single root TreeEntry.
509 When names == ['xxx/abc', 'xxx/def'] the result will be::
511 TreeEntry(
512 basename=None,
513 refname=None,
514 children=[
515 TreeEntry(
516 basename='xxx',
517 refname=None,
518 children=[
519 TreeEntry(
520 basename='abc',
521 refname='xxx/abc',
522 children=[]
524 TreeEntry(
525 basename='def',
526 refname='xxx/def',
527 children=[]
535 # Phase 1: build a nested dictionary representing the intermediate
536 # names in the branches. e.g. {'xxx': {'abc': {}, 'def': {}}}
537 tree_names = create_name_dict(names)
539 # Loop over the names again, this time we'll create tree entries
540 entries = {}
541 root = TreeEntry(None, None, [])
542 for item in names:
543 cur_names = tree_names
544 cur_entries = entries
545 tree = root
546 children = root.children
547 for part in item.split('/'):
548 if cur_names[part]:
549 # This has children
550 try:
551 tree, _ = cur_entries[part]
552 except KeyError:
553 # New entry
554 tree = TreeEntry(part, None, [])
555 cur_entries[part] = (tree, {})
556 # Append onto the parent children list only once
557 children.append(tree)
558 else:
559 # This is the actual branch
560 tree = TreeEntry(part, item, [])
561 children.append(tree)
562 cur_entries[part] = (tree, {})
564 # Advance into the nested child list
565 children = tree.children
566 # Advance into the inner dict
567 cur_names = cur_names[part]
568 _, cur_entries = cur_entries[part]
570 return root
573 def create_name_dict(names):
574 # Phase 1: build a nested dictionary representing the intermediate
575 # names in the branches. e.g. {'xxx': {'abc': {}, 'def': {}}}
576 tree_names = {}
577 for item in names:
578 part_names = tree_names
579 for part in item.split('/'):
580 # Descend into the inner names dict.
581 part_names = part_names.setdefault(part, {})
582 return tree_names
585 def create_toplevel_item(tree, icon=None, ellipsis=None):
586 """Create a top-level BranchTreeWidgetItem and its children"""
588 item = BranchTreeWidgetItem(tree.basename, icon=ellipsis)
589 children = create_tree_items(tree.children, icon=icon, ellipsis=ellipsis)
590 if children:
591 item.addChildren(children)
592 return item
595 def create_tree_items(entries, icon=None, ellipsis=None):
596 """Create children items for a tree item"""
597 result = []
598 for tree in entries:
599 item = BranchTreeWidgetItem(tree.basename, refname=tree.refname, icon=icon)
600 children = create_tree_items(tree.children, icon=icon, ellipsis=ellipsis)
601 if children:
602 item.addChildren(children)
603 if ellipsis is not None:
604 item.setIcon(0, ellipsis)
605 result.append(item)
607 return result
610 def expand_item_parents(item):
611 """Expand tree parents from item"""
612 parent = item.parent()
613 while parent is not None:
614 parent.setExpanded(True)
615 parent = parent.parent()
618 def find_by_refname(item, refname):
619 """Find child by full name recursive"""
620 result = None
622 for i in range(item.childCount()):
623 child = item.child(i)
624 if child.refname and child.refname == refname:
625 return child
627 result = find_by_refname(child, refname)
628 if result is not None:
629 return result
631 return result
634 def get_toplevel_item(item):
635 """Returns top-most item found by traversing up the specified item"""
636 parents = [item]
637 parent = item.parent()
639 while parent is not None:
640 parents.append(parent)
641 parent = parent.parent()
643 return parents[-1]
646 class BranchesTreeHelper(object):
647 def load_state(self, item, state):
648 """Load expanded items from a dict"""
649 if state.keys():
650 item.setExpanded(True)
652 for i in range(item.childCount()):
653 child = item.child(i)
654 if child.name in state:
655 self.load_state(child, state[child.name])
657 def save_state(self, item):
658 """Save expanded items in a dict"""
659 result = {item.name: {}}
661 if item.isExpanded():
662 for i in range(item.childCount()):
663 child = item.child(i)
664 result[item.name].update(self.save_state(child))
666 return result
669 class GitHelper(object):
670 def __init__(self, context):
671 self.context = context
672 self.git = context.git
674 def log(self, origin):
675 return self.git.log(origin, oneline=True)
677 def push(self, remote, branch):
678 return self.git.push(remote, branch, verbose=True)
680 def pull(self, remote, branch):
681 return self.git.pull(remote, branch, no_ff=True, verbose=True)
683 def merge(self, branch):
684 return self.git.merge(branch, no_commit=True)
686 def rename(self, branch, new_branch):
687 return self.git.branch(branch, new_branch, m=True)
689 def checkout(self, *args, **options):
690 return self.git.checkout(*args, **options)
692 @staticmethod
693 def show_result(command, status, out, err):
694 Interaction.log_status(status, out, err)
695 if status != 0:
696 Interaction.command_error(N_('Error'), command, status, out, err)
699 class BranchesFilterWidget(QtWidgets.QWidget):
700 def __init__(self, tree, parent=None):
701 QtWidgets.QWidget.__init__(self, parent)
702 self.tree = tree
704 hint = N_('Filter branches...')
705 self.text = LineEdit(parent=self, clear_button=True)
706 self.text.setToolTip(hint)
707 self.setFocusProxy(self.text)
708 self._filter = None
710 self.main_layout = qtutils.hbox(defs.no_margin, defs.spacing, self.text)
711 self.setLayout(self.main_layout)
713 text = self.text
714 # pylint: disable=no-member
715 text.textChanged.connect(self.apply_filter)
716 self.tree.updated.connect(self.apply_filter, type=Qt.QueuedConnection)
718 def apply_filter(self):
719 text = get(self.text)
720 if text == self._filter:
721 return
722 self._apply_bold(self._filter, False)
723 self._filter = text
724 if text:
725 self._apply_bold(text, True)
727 def _apply_bold(self, text, value):
728 match = Qt.MatchContains | Qt.MatchRecursive
729 children = self.tree.findItems(text, match)
731 for child in children:
732 if child.childCount() == 0:
733 font = child.font(0)
734 font.setBold(value)
735 child.setFont(0, font)