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
12 from ..interaction
import Interaction
13 from ..widgets
import defs
14 from ..widgets
import standard
15 from ..qtutils
import get
17 from .. import gitcmds
18 from .. import hotkeys
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
:
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
)
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
49 self
.update_refs
= update_refs
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
)
68 icons
.reverse_chronological(),
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
)
105 self
.filter_widget
.setFocus()
109 def order_icon(self
, idx
):
110 return self
.order_icons
[idx
% len(self
.order_icons
)]
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
):
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
)
152 """Refresh the UI widgets to match the current state"""
153 self
._needs
_refresh
= True
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
:
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
)
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
:
194 if self
._needs
_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()
210 # Only allow actions on leaf nodes that have a valid refname.
211 if not selected
.refname
:
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
:
221 qtutils
.add_action(menu
, N_('Checkout'), self
.checkout_action
)
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'):
239 if root
.name
== N_('Local'):
241 remote
= gitcmds
.tracked_branch(context
, full_name
)
242 if remote
is not None:
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())
263 menu
.addAction(rename_menu_action
)
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())
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()
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
:
306 prefix
= remote
+ '/'
307 for branch
in model
.remote_branches
:
308 if branch
.startswith(prefix
):
309 branches
.append(branch
)
311 other_branches
.append(branch
)
313 # This can be a pretty big list, let's try to split it apart
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
)
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(
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.
344 sub_menu
= menu
.addMenu(N_('Other branches'))
345 for branch
in other_branches
:
346 current_remote
= add_branch_to_menu(
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"""
365 for item
in self
.items():
366 states
.update(self
.tree_helper
.save_state(item
))
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
)
389 expand_item_parents(item
)
390 item
.setIcon(0, icons
.star())
392 branch_details_task
= BranchDetailsTask(
393 context
, current_branch
, self
.git_helper
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:
407 status_str
+= '%s%s' % (uchr(0x2191), ahead
)
410 status_str
+= ' %s%s' % (uchr(0x2193), behind
)
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):
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
)
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
)
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()
454 remote_branch
= gitcmds
.tracked_branch(context
, 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
:
466 root
= get_toplevel_item(self
.selected_item())
469 if root
.name
== N_('Remote'):
473 remote
, branch
= gitcmds
.parse_remote_branch(branch
)
474 if remote
and branch
:
475 cmds
.do(cmds
.DeleteRemoteBranch
, self
.context
, remote
, branch
)
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
511 """Query git for branch details"""
512 tracked_branch
= gitcmds
.tracked_branch(self
.context
, self
.current_branch
)
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')
523 origin
= self
.current_branch
+ '..' + tracked_branch
524 their_commits
= self
.git_helper
.log(origin
)[STDOUT
]
525 behind
= their_commits
.count('\n')
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
)
536 self
.refname
= refname
537 self
.setText(0, name
)
538 self
.setToolTip(0, name
)
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
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::
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
595 root
= TreeEntry(None, None, [])
597 cur_names
= tree_names
598 cur_entries
= entries
600 children
= root
.children
601 for part
in item
.split('/'):
605 tree
, _
= cur_entries
[part
]
608 tree
= TreeEntry(part
, None, [])
609 cur_entries
[part
] = (tree
, {})
610 # Append onto the parent children list only once
611 children
.append(tree
)
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
]
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': {}}}
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
, {})
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
)
645 item
.addChildren(children
)
649 def create_tree_items(entries
, icon
=None, ellipsis
=None):
650 """Create children items for a tree item"""
653 item
= BranchTreeWidgetItem(tree
.basename
, refname
=tree
.refname
, icon
=icon
)
654 children
= create_tree_items(tree
.children
, icon
=icon
, ellipsis
=ellipsis
)
656 item
.addChildren(children
)
657 if ellipsis
is not None:
658 item
.setIcon(0, ellipsis
)
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"""
677 for i
in range(item
.childCount()):
678 child
= item
.child(i
)
679 if child
.refname
and child
.refname
== refname
:
682 result
= find_by_refname(child
, refname
)
683 if result
is not None:
689 def get_toplevel_item(item
):
690 """Returns top-most item found by traversing up the specified item"""
692 parent
= item
.parent()
694 while parent
is not None:
695 parents
.append(parent
)
696 parent
= parent
.parent()
701 class BranchesTreeHelper(object):
702 """Save and restore the tree state"""
704 def __init__(self
, 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"""
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
:
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()
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
))
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
)
773 def show_result(command
, status
, out
, err
):
774 Interaction
.log_status(status
, out
, err
)
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
)
784 hint
= N_('Filter branches...')
785 self
.text
= LineEdit(parent
=self
, clear_button
=True)
786 self
.text
.setToolTip(hint
)
787 self
.setFocusProxy(self
.text
)
790 self
.main_layout
= qtutils
.hbox(defs
.no_margin
, defs
.spacing
, self
.text
)
791 self
.setLayout(self
.main_layout
)
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
:
802 self
._apply
_bold
(self
._filter
, False)
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:
815 child
.setFont(0, font
)