branch: address the TODO to eliminate the stub rowCount() method
[git-cola.git] / cola / widgets / branch.py
blobf56960b6dd312cf400c458546b39b004f844c841
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 ..git import STDOUT
11 from ..i18n import N_
12 from ..interaction import Interaction
13 from ..widgets import defs
14 from ..widgets import standard
15 from ..qtutils import get
16 from .. import cmds
17 from .. import gitcmds
18 from .. import hotkeys
19 from .. import icons
20 from .. import qtutils
21 from .text import LineEdit
24 def defer_fn(parent, title, fn, *args, **kwargs):
25 return qtutils.add_action(parent, title, partial(fn, *args, **kwargs))
28 def add_branch_to_menu(menu, branch, remote_branch, remote, upstream, fn):
29 """Add a remote branch to the context menu"""
30 branch_remote, _ = gitcmds.parse_remote_branch(remote_branch)
31 if branch_remote != remote:
32 menu.addSeparator()
33 action = defer_fn(menu, remote_branch, fn, branch, remote_branch)
34 if remote_branch == upstream:
35 action.setIcon(icons.star())
36 menu.addAction(action)
37 return branch_remote
40 class AsyncGitActionTask(qtutils.Task):
41 """Run git action asynchronously"""
43 def __init__(self, git_helper, action, args, kwarg, update_refs):
44 qtutils.Task.__init__(self)
45 self.git_helper = git_helper
46 self.action = action
47 self.args = args
48 self.kwarg = kwarg
49 self.update_refs = update_refs
51 def task(self):
52 """Runs action and captures the result"""
53 git_action = getattr(self.git_helper, self.action)
54 return git_action(*self.args, **self.kwarg)
57 class BranchesWidget(QtWidgets.QFrame):
58 def __init__(self, context, parent):
59 QtWidgets.QFrame.__init__(self, parent)
60 self.model = model = context.model
62 tooltip = N_('Toggle the branches filter')
63 icon = icons.ellipsis()
64 self.filter_button = qtutils.create_action_button(tooltip=tooltip, icon=icon)
66 self.order_icons = (
67 icons.alphabetical(),
68 icons.reverse_chronological(),
70 tooltip_order = N_(
71 'Set the sort order for branches and tags.\n'
72 'Toggle between date-based and version-name-based sorting.'
74 icon = self.order_icon(model.ref_sort)
75 self.sort_order_button = qtutils.create_action_button(
76 tooltip=tooltip_order, icon=icon
79 self.tree = BranchesTreeWidget(context, parent=self)
80 self.filter_widget = BranchesFilterWidget(self.tree)
81 self.filter_widget.hide()
83 self.setFocusProxy(self.tree)
84 self.setToolTip(N_('Branches'))
86 self.main_layout = qtutils.vbox(
87 defs.no_margin, defs.spacing, self.filter_widget, self.tree
89 self.setLayout(self.main_layout)
91 self.toggle_action = qtutils.add_action(
92 self, tooltip, self.toggle_filter, hotkeys.FILTER
94 qtutils.connect_button(self.filter_button, self.toggle_filter)
95 qtutils.connect_button(
96 self.sort_order_button, cmds.run(cmds.CycleReferenceSort, context)
99 model.refs_updated.connect(self.refresh, Qt.QueuedConnection)
101 def toggle_filter(self):
102 shown = not self.filter_widget.isVisible()
103 self.filter_widget.setVisible(shown)
104 if shown:
105 self.filter_widget.setFocus()
106 else:
107 self.tree.setFocus()
109 def order_icon(self, idx):
110 return self.order_icons[idx % len(self.order_icons)]
112 def refresh(self):
113 icon = self.order_icon(self.model.ref_sort)
114 self.sort_order_button.setIcon(icon)
117 # pylint: disable=too-many-ancestors
118 class BranchesTreeWidget(standard.TreeWidget):
119 updated = Signal()
121 def __init__(self, context, parent=None):
122 standard.TreeWidget.__init__(self, parent)
124 self.context = context
126 self.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection)
127 self.setHeaderHidden(True)
128 self.setAlternatingRowColors(False)
129 self.setColumnCount(1)
130 self.setExpandsOnDoubleClick(False)
132 self.current_branch = None
133 self.tree_helper = BranchesTreeHelper(self)
134 self.git_helper = GitHelper(context)
135 self.runtask = qtutils.RunTask(parent=self)
137 self._visible = False
138 self._needs_refresh = False
139 self._tree_states = None
141 self.updated.connect(self.refresh, type=Qt.QueuedConnection)
142 context.model.updated.connect(self.updated)
144 # Expand items when they are clicked
145 # pylint: disable=no-member
146 self.clicked.connect(self._toggle_expanded)
148 # Checkout branch when double clicked
149 self.doubleClicked.connect(self.checkout_action)
151 def refresh(self):
152 """Refresh the UI widgets to match the current state"""
153 self._needs_refresh = True
154 self._refresh()
156 def _refresh(self):
157 """Refresh the UI to match the updated state"""
158 # There is no need to refresh the UI when this widget is inactive.
159 if not self._visible:
160 return
161 model = self.context.model
162 self.current_branch = model.currentbranch
164 self._tree_states = self._save_tree_state()
165 ellipsis = icons.ellipsis()
167 local_tree = create_tree_entries(model.local_branches)
168 local_tree.basename = N_('Local')
169 local = create_toplevel_item(local_tree, icon=icons.branch(), ellipsis=ellipsis)
171 remote_tree = create_tree_entries(model.remote_branches)
172 remote_tree.basename = N_('Remote')
173 remote = create_toplevel_item(
174 remote_tree, icon=icons.branch(), ellipsis=ellipsis
177 tags_tree = create_tree_entries(model.tags)
178 tags_tree.basename = N_('Tags')
179 tags = create_toplevel_item(tags_tree, icon=icons.tag(), ellipsis=ellipsis)
181 self.clear()
182 self.addTopLevelItems([local, remote, tags])
184 if self._tree_states:
185 self._load_tree_state(self._tree_states)
186 self._tree_states = None
188 self._update_branches()
190 def showEvent(self, event):
191 """Defer updating widgets until the widget is visible"""
192 if not self._visible:
193 self._visible = True
194 if self._needs_refresh:
195 self.refresh()
196 return super(BranchesTreeWidget, self).showEvent(event)
198 def _toggle_expanded(self, index):
199 """Toggle expanded/collapsed state when items are clicked"""
200 item = self.itemFromIndex(index)
201 if item and item.childCount():
202 self.setExpanded(index, not self.isExpanded(index))
204 def contextMenuEvent(self, event):
205 """Build and execute the context menu"""
206 context = self.context
207 selected = self.selected_item()
208 if not selected:
209 return
210 # Only allow actions on leaf nodes that have a valid refname.
211 if not selected.refname:
212 return
214 root = get_toplevel_item(selected)
215 full_name = selected.refname
216 menu = qtutils.create_menu(N_('Actions'), self)
218 # all branches except current the current branch
219 if full_name != self.current_branch:
220 menu.addAction(
221 qtutils.add_action(menu, N_('Checkout'), self.checkout_action)
223 # remote branch
224 if root.name == N_('Remote'):
225 label = N_('Checkout as new branch')
226 action = self.checkout_new_branch_action
227 menu.addAction(qtutils.add_action(menu, label, action))
229 merge_menu_action = qtutils.add_action(
230 menu, N_('Merge into current branch'), self.merge_action
232 merge_menu_action.setIcon(icons.merge())
234 menu.addAction(merge_menu_action)
236 # local and remote branch
237 if root.name != N_('Tags'):
238 # local branch
239 if root.name == N_('Local'):
241 remote = gitcmds.tracked_branch(context, full_name)
242 if remote is not None:
243 menu.addSeparator()
245 pull_menu_action = qtutils.add_action(
246 menu, N_('Pull'), self.pull_action
248 pull_menu_action.setIcon(icons.pull())
249 menu.addAction(pull_menu_action)
251 push_menu_action = qtutils.add_action(
252 menu, N_('Push'), self.push_action
254 push_menu_action.setIcon(icons.push())
255 menu.addAction(push_menu_action)
257 rename_menu_action = qtutils.add_action(
258 menu, N_('Rename Branch'), self.rename_action
260 rename_menu_action.setIcon(icons.edit())
262 menu.addSeparator()
263 menu.addAction(rename_menu_action)
265 # not current branch
266 if full_name != self.current_branch:
267 delete_label = N_('Delete Branch')
268 if root.name == N_('Remote'):
269 delete_label = N_('Delete Remote Branch')
271 delete_menu_action = qtutils.add_action(
272 menu, delete_label, self.delete_action
274 delete_menu_action.setIcon(icons.discard())
276 menu.addSeparator()
277 menu.addAction(delete_menu_action)
279 # manage upstreams for local branches
280 if root.name == N_('Local'):
281 upstream_menu = menu.addMenu(N_('Set Upstream Branch'))
282 upstream_menu.setIcon(icons.branch())
283 self.build_upstream_menu(upstream_menu)
285 menu.exec_(self.mapToGlobal(event.pos()))
287 def build_upstream_menu(self, menu):
288 """Build the "Set Upstream Branch" sub-menu"""
289 context = self.context
290 model = context.model
291 selected_branch = self.selected_refname()
292 remote = None
293 upstream = None
295 branches = []
296 other_branches = []
298 if selected_branch:
299 remote = gitcmds.upstream_remote(context, selected_branch)
300 upstream = gitcmds.tracked_branch(context, branch=selected_branch)
302 if not remote and 'origin' in model.remotes:
303 remote = 'origin'
305 if remote:
306 prefix = remote + '/'
307 for branch in model.remote_branches:
308 if branch.startswith(prefix):
309 branches.append(branch)
310 else:
311 other_branches.append(branch)
312 else:
313 # This can be a pretty big list, let's try to split it apart
314 branch_remote = ''
315 target = branches
316 for branch in model.remote_branches:
317 new_branch_remote, _ = gitcmds.parse_remote_branch(branch)
318 if branch_remote and branch_remote != new_branch_remote:
319 target = other_branches
320 branch_remote = new_branch_remote
321 target.append(branch)
323 limit = 16
324 if not other_branches and len(branches) > limit:
325 branches, other_branches = (branches[:limit], branches[limit:])
327 # Add an action for each remote branch
328 current_remote = remote
330 for branch in branches:
331 current_remote = add_branch_to_menu(
332 menu,
333 selected_branch,
334 branch,
335 current_remote,
336 upstream,
337 self.set_upstream,
340 # This list could be longer so we tuck it away in a sub-menu.
341 # Selecting a branch from the non-default remote is less common.
342 if other_branches:
343 menu.addSeparator()
344 sub_menu = menu.addMenu(N_('Other branches'))
345 for branch in other_branches:
346 current_remote = add_branch_to_menu(
347 sub_menu,
348 selected_branch,
349 branch,
350 current_remote,
351 upstream,
352 self.set_upstream,
355 def set_upstream(self, branch, remote_branch):
356 """Configure the upstream for a branch"""
357 context = self.context
358 remote, r_branch = gitcmds.parse_remote_branch(remote_branch)
359 if remote and r_branch:
360 cmds.do(cmds.SetUpstreamBranch, context, branch, remote, r_branch)
362 def _save_tree_state(self):
363 """Save the tree state into a dictionary"""
364 states = {}
365 for item in self.items():
366 states.update(self.tree_helper.save_state(item))
367 return states
369 def _load_tree_state(self, states):
370 """Restore expanded items after rebuilding UI widgets"""
371 # Disable animations to eliminate redraw flicker.
372 animated = self.isAnimated()
373 self.setAnimated(False)
375 for item in self.items():
376 self.tree_helper.load_state(item, states.get(item.name, {}))
377 self.tree_helper.set_current_item()
379 self.setAnimated(animated)
381 def _update_branches(self):
382 """Query branch details using a background task"""
383 context = self.context
384 current_branch = self.current_branch
385 top_item = self.topLevelItem(0)
386 item = find_by_refname(top_item, current_branch)
388 if item is not None:
389 expand_item_parents(item)
390 item.setIcon(0, icons.star())
392 branch_details_task = BranchDetailsTask(
393 context, current_branch, self.git_helper
395 self.runtask.start(
396 branch_details_task, finish=self._update_branches_finished
399 def _update_branches_finished(self, task):
400 """Update the UI with the branch details once the background task completes"""
401 current_branch, tracked_branch, ahead, behind = task.result
402 top_item = self.topLevelItem(0)
403 item = find_by_refname(top_item, current_branch)
404 if current_branch and tracked_branch and item is not None:
405 status_str = ''
406 if ahead > 0:
407 status_str += '%s%s' % (uchr(0x2191), ahead)
409 if behind > 0:
410 status_str += ' %s%s' % (uchr(0x2193), behind)
412 if status_str:
413 item.setText(0, '%s\t%s' % (item.text(0), status_str))
415 def git_action_async(self, action, args, kwarg=None, update_refs=False):
416 if kwarg is None:
417 kwarg = {}
418 task = AsyncGitActionTask(self.git_helper, action, args, kwarg, update_refs)
419 progress = standard.progress(
420 N_('Executing action %s') % action, N_('Updating'), self
422 self.runtask.start(task, progress=progress, finish=self.git_action_completed)
424 def git_action_completed(self, task):
425 status, out, err = task.result
426 self.git_helper.show_result(task.action, status, out, err)
427 if task.update_refs:
428 self.context.model.update_refs()
430 def push_action(self):
431 context = self.context
432 branch = self.selected_refname()
433 remote_branch = gitcmds.tracked_branch(context, branch)
434 if remote_branch:
435 remote, branch_name = gitcmds.parse_remote_branch(remote_branch)
436 if remote and branch_name:
437 # we assume that user wants to "Push" the selected local
438 # branch to a remote with same name
439 self.git_action_async('push', [remote, branch_name], update_refs=True)
441 def rename_action(self):
442 branch = self.selected_refname()
443 new_branch, ok = qtutils.prompt(
444 N_('Enter New Branch Name'), title=N_('Rename branch'), text=branch
446 if ok and new_branch:
447 self.git_action_async('rename', [branch, new_branch], update_refs=True)
449 def pull_action(self):
450 context = self.context
451 branch = self.selected_refname()
452 if not branch:
453 return
454 remote_branch = gitcmds.tracked_branch(context, branch)
455 if remote_branch:
456 remote, branch_name = gitcmds.parse_remote_branch(remote_branch)
457 if remote and branch_name:
458 self.git_action_async('pull', [remote, branch_name], update_refs=True)
460 def delete_action(self):
461 branch = self.selected_refname()
462 if not branch or branch == self.current_branch:
463 return
465 remote = False
466 root = get_toplevel_item(self.selected_item())
467 if not root:
468 return
469 if root.name == N_('Remote'):
470 remote = True
472 if remote:
473 remote, branch = gitcmds.parse_remote_branch(branch)
474 if remote and branch:
475 cmds.do(cmds.DeleteRemoteBranch, self.context, remote, branch)
476 else:
477 cmds.do(cmds.DeleteBranch, self.context, branch)
479 def merge_action(self):
480 branch = self.selected_refname()
481 if branch and branch != self.current_branch:
482 self.git_action_async('merge', [branch])
484 def checkout_action(self):
485 branch = self.selected_refname()
486 if branch and branch != self.current_branch:
487 self.git_action_async('checkout', [branch], update_refs=True)
489 def checkout_new_branch_action(self):
490 branch = self.selected_refname()
491 if branch and branch != self.current_branch:
492 _, new_branch = gitcmds.parse_remote_branch(branch)
493 self.git_action_async(
494 'checkout', ['-b', new_branch, branch], update_refs=True
497 def selected_refname(self):
498 return getattr(self.selected_item(), 'refname', None)
501 class BranchDetailsTask(qtutils.Task):
502 """Lookup branch details in a background task"""
504 def __init__(self, context, current_branch, git_helper):
505 super(BranchDetailsTask, self).__init__()
506 self.context = context
507 self.current_branch = current_branch
508 self.git_helper = git_helper
510 def task(self):
511 """Query git for branch details"""
512 tracked_branch = gitcmds.tracked_branch(self.context, self.current_branch)
513 ahead = 0
514 behind = 0
516 if self.current_branch and tracked_branch:
517 origin = tracked_branch + '..' + self.current_branch
518 our_commits = self.git_helper.log(origin)[STDOUT]
519 ahead = our_commits.count('\n')
520 if our_commits:
521 ahead += 1
523 origin = self.current_branch + '..' + tracked_branch
524 their_commits = self.git_helper.log(origin)[STDOUT]
525 behind = their_commits.count('\n')
526 if their_commits:
527 behind += 1
529 return self.current_branch, tracked_branch, ahead, behind
532 class BranchTreeWidgetItem(QtWidgets.QTreeWidgetItem):
533 def __init__(self, name, refname=None, icon=None):
534 QtWidgets.QTreeWidgetItem.__init__(self)
535 self.name = name
536 self.refname = refname
537 self.setText(0, name)
538 self.setToolTip(0, name)
539 if icon is not None:
540 self.setIcon(0, icon)
541 self.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
544 class TreeEntry(object):
545 """Tree representation for the branches widget
547 The branch widget UI displays the basename. For intermediate names, e.g.
548 "xxx" in the "xxx/abc" and "xxx/def" branches, the 'refname' will be None.
549 'children' contains a list of TreeEntry, and is empty when refname is
550 defined.
554 def __init__(self, basename, refname, children):
555 self.basename = basename
556 self.refname = refname
557 self.children = children
560 def create_tree_entries(names):
561 """Create a nested tree structure with a single root TreeEntry.
563 When names == ['xxx/abc', 'xxx/def'] the result will be::
565 TreeEntry(
566 basename=None,
567 refname=None,
568 children=[
569 TreeEntry(
570 basename='xxx',
571 refname=None,
572 children=[
573 TreeEntry(
574 basename='abc',
575 refname='xxx/abc',
576 children=[]
578 TreeEntry(
579 basename='def',
580 refname='xxx/def',
581 children=[]
589 # Phase 1: build a nested dictionary representing the intermediate
590 # names in the branches. e.g. {'xxx': {'abc': {}, 'def': {}}}
591 tree_names = create_name_dict(names)
593 # Loop over the names again, this time we'll create tree entries
594 entries = {}
595 root = TreeEntry(None, None, [])
596 for item in names:
597 cur_names = tree_names
598 cur_entries = entries
599 tree = root
600 children = root.children
601 for part in item.split('/'):
602 if cur_names[part]:
603 # This has children
604 try:
605 tree, _ = cur_entries[part]
606 except KeyError:
607 # New entry
608 tree = TreeEntry(part, None, [])
609 cur_entries[part] = (tree, {})
610 # Append onto the parent children list only once
611 children.append(tree)
612 else:
613 # This is the actual branch
614 tree = TreeEntry(part, item, [])
615 children.append(tree)
616 cur_entries[part] = (tree, {})
618 # Advance into the nested child list
619 children = tree.children
620 # Advance into the inner dict
621 cur_names = cur_names[part]
622 _, cur_entries = cur_entries[part]
624 return root
627 def create_name_dict(names):
628 # Phase 1: build a nested dictionary representing the intermediate
629 # names in the branches. e.g. {'xxx': {'abc': {}, 'def': {}}}
630 tree_names = {}
631 for item in names:
632 part_names = tree_names
633 for part in item.split('/'):
634 # Descend into the inner names dict.
635 part_names = part_names.setdefault(part, {})
636 return tree_names
639 def create_toplevel_item(tree, icon=None, ellipsis=None):
640 """Create a top-level BranchTreeWidgetItem and its children"""
642 item = BranchTreeWidgetItem(tree.basename, icon=ellipsis)
643 children = create_tree_items(tree.children, icon=icon, ellipsis=ellipsis)
644 if children:
645 item.addChildren(children)
646 return item
649 def create_tree_items(entries, icon=None, ellipsis=None):
650 """Create children items for a tree item"""
651 result = []
652 for tree in entries:
653 item = BranchTreeWidgetItem(tree.basename, refname=tree.refname, icon=icon)
654 children = create_tree_items(tree.children, icon=icon, ellipsis=ellipsis)
655 if children:
656 item.addChildren(children)
657 if ellipsis is not None:
658 item.setIcon(0, ellipsis)
659 result.append(item)
661 return result
664 def expand_item_parents(item):
665 """Expand tree parents from item"""
666 parent = item.parent()
667 while parent is not None:
668 if not parent.isExpanded():
669 parent.setExpanded(True)
670 parent = parent.parent()
673 def find_by_refname(item, refname):
674 """Find child by full name recursive"""
675 result = None
677 for i in range(item.childCount()):
678 child = item.child(i)
679 if child.refname and child.refname == refname:
680 return child
682 result = find_by_refname(child, refname)
683 if result is not None:
684 return result
686 return result
689 def get_toplevel_item(item):
690 """Returns top-most item found by traversing up the specified item"""
691 parents = [item]
692 parent = item.parent()
694 while parent is not None:
695 parents.append(parent)
696 parent = parent.parent()
698 return parents[-1]
701 class BranchesTreeHelper(object):
702 """Save and restore the tree state"""
704 def __init__(self, tree):
705 self.tree = tree
706 self.current_item = None
708 def set_current_item(self):
709 """Reset the current item"""
710 if self.current_item is not None:
711 self.tree.setCurrentItem(self.current_item)
712 self.current_item = None
714 def load_state(self, item, state):
715 """Load expanded items from a dict"""
716 if not state:
717 return
718 if state.get('expanded', False) and not item.isExpanded():
719 item.setExpanded(True)
720 if state.get('selected', False) and not item.isSelected():
721 item.setSelected(True)
722 self.current_item = item
724 children_state = state.get('children', {})
725 if not children_state:
726 return
727 for i in range(item.childCount()):
728 child = item.child(i)
729 self.load_state(child, children_state.get(child.name, {}))
731 def save_state(self, item):
732 """Save the selected and expanded item state into a dict"""
733 expanded = item.isExpanded()
734 selected = item.isSelected()
735 children = {}
736 entry = {
737 'children': children,
738 'expanded': expanded,
739 'selected': selected,
741 result = {item.name: entry}
742 for i in range(item.childCount()):
743 child = item.child(i)
744 children.update(self.save_state(child))
746 return result
749 class GitHelper(object):
750 def __init__(self, context):
751 self.context = context
752 self.git = context.git
754 def log(self, origin):
755 return self.git.log(origin, abbrev=7, pretty='format:%h', _readonly=True)
757 def push(self, remote, branch):
758 return self.git.push(remote, branch, verbose=True)
760 def pull(self, remote, branch):
761 return self.git.pull(remote, branch, no_ff=True, verbose=True)
763 def merge(self, branch):
764 return self.git.merge(branch, no_commit=True)
766 def rename(self, branch, new_branch):
767 return self.git.branch(branch, new_branch, m=True)
769 def checkout(self, *args, **options):
770 return self.git.checkout(*args, **options)
772 @staticmethod
773 def show_result(command, status, out, err):
774 Interaction.log_status(status, out, err)
775 if status != 0:
776 Interaction.command_error(N_('Error'), command, status, out, err)
779 class BranchesFilterWidget(QtWidgets.QWidget):
780 def __init__(self, tree, parent=None):
781 QtWidgets.QWidget.__init__(self, parent)
782 self.tree = tree
784 hint = N_('Filter branches...')
785 self.text = LineEdit(parent=self, clear_button=True)
786 self.text.setToolTip(hint)
787 self.setFocusProxy(self.text)
788 self._filter = None
790 self.main_layout = qtutils.hbox(defs.no_margin, defs.spacing, self.text)
791 self.setLayout(self.main_layout)
793 text = self.text
794 # pylint: disable=no-member
795 text.textChanged.connect(self.apply_filter)
796 self.tree.updated.connect(self.apply_filter, type=Qt.QueuedConnection)
798 def apply_filter(self):
799 text = get(self.text)
800 if text == self._filter:
801 return
802 self._apply_bold(self._filter, False)
803 self._filter = text
804 if text:
805 self._apply_bold(text, True)
807 def _apply_bold(self, text, value):
808 match = Qt.MatchContains | Qt.MatchRecursive
809 children = self.tree.findItems(text, match)
811 for child in children:
812 if child.childCount() == 0:
813 font = child.font(0)
814 font.setBold(value)
815 child.setFont(0, font)