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
11 from ..interaction
import Interaction
12 from ..widgets
import defs
13 from ..widgets
import standard
14 from ..qtutils
import get
16 from .. import gitcmds
17 from .. import hotkeys
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
:
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
)
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
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
)
66 icons
.reverse_chronological(),
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
)
103 self
.filter_widget
.setFocus()
107 def order_icon(self
, idx
):
108 return self
.order_icons
[idx
% len(self
.order_icons
)]
111 icon
= self
.order_icon(self
.model
.ref_sort
)
112 self
.sort_order_button
.setIcon(icon
)
116 # pylint: disable=too-many-ancestors
117 class BranchesTreeWidget(standard
.TreeWidget
):
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
)
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
)
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
)
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"""
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()
193 # Only allow actions on leaf nodes that have a valid refname.
194 if not selected
.refname
:
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
:
204 qtutils
.add_action(menu
, N_('Checkout'), self
.checkout_action
)
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'):
222 if root
.name
== N_('Local'):
224 remote
= gitcmds
.tracked_branch(context
, full_name
)
225 if remote
is not None:
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())
246 menu
.addAction(rename_menu_action
)
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())
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()
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
:
289 prefix
= remote
+ '/'
290 for branch
in model
.remote_branches
:
291 if branch
.startswith(prefix
):
292 branches
.append(branch
)
294 other_branches
.append(branch
)
296 # This can be a pretty big list, let's try to split it apart
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
)
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(
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.
327 sub_menu
= menu
.addMenu(N_('Other branches'))
328 for branch
in other_branches
:
329 current_remote
= add_branch_to_menu(
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
):
347 for item
in self
.items():
348 states
.update(self
.tree_helper
.save_state(item
))
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
)
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}
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'])
387 item
.setText(0, '%s\t%s' % (item
.text(0), status_str
))
389 def git_action_async(self
, action
, args
, kwarg
=None):
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
)
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()
427 remote_branch
= gitcmds
.tracked_branch(context
, 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
:
439 root
= get_toplevel_item(self
.selected_item())
440 if root
.name
== N_('Remote'):
444 remote
, branch
= gitcmds
.parse_remote_branch(branch
)
445 if remote
and branch
:
446 cmds
.do(cmds
.DeleteRemoteBranch
, self
.context
, remote
, branch
)
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
)
474 self
.refname
= refname
475 self
.setText(0, name
)
476 self
.setToolTip(0, name
)
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
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
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::
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
541 root
= TreeEntry(None, None, [])
543 cur_names
= tree_names
544 cur_entries
= entries
546 children
= root
.children
547 for part
in item
.split('/'):
551 tree
, _
= cur_entries
[part
]
554 tree
= TreeEntry(part
, None, [])
555 cur_entries
[part
] = (tree
, {})
556 # Append onto the parent children list only once
557 children
.append(tree
)
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
]
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': {}}}
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
, {})
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
)
591 item
.addChildren(children
)
595 def create_tree_items(entries
, icon
=None, ellipsis
=None):
596 """Create children items for a tree item"""
599 item
= BranchTreeWidgetItem(tree
.basename
, refname
=tree
.refname
, icon
=icon
)
600 children
= create_tree_items(tree
.children
, icon
=icon
, ellipsis
=ellipsis
)
602 item
.addChildren(children
)
603 if ellipsis
is not None:
604 item
.setIcon(0, ellipsis
)
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"""
622 for i
in range(item
.childCount()):
623 child
= item
.child(i
)
624 if child
.refname
and child
.refname
== refname
:
627 result
= find_by_refname(child
, refname
)
628 if result
is not None:
634 def get_toplevel_item(item
):
635 """Returns top-most item found by traversing up the specified item"""
637 parent
= item
.parent()
639 while parent
is not None:
640 parents
.append(parent
)
641 parent
= parent
.parent()
646 class BranchesTreeHelper(object):
647 def load_state(self
, item
, state
):
648 """Load expanded items from a dict"""
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
))
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
)
693 def show_result(command
, status
, out
, err
):
694 Interaction
.log_status(status
, out
, err
)
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
)
704 hint
= N_('Filter branches...')
705 self
.text
= LineEdit(parent
=self
, clear_button
=True)
706 self
.text
.setToolTip(hint
)
707 self
.setFocusProxy(self
.text
)
710 self
.main_layout
= qtutils
.hbox(defs
.no_margin
, defs
.spacing
, self
.text
)
711 self
.setLayout(self
.main_layout
)
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
:
722 self
._apply
_bold
(self
._filter
, False)
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:
735 child
.setFont(0, font
)