1 """Provides widgets related to branches"""
2 from __future__
import division
, absolute_import
, 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 odict
10 from ..compat
import uchr
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
25 NAME_LOCAL_BRANCH
= N_('Local')
26 NAME_REMOTE_BRANCH
= N_('Remote')
27 NAME_TAGS_BRANCH
= N_('Tags')
30 def defer_fn(parent
, title
, fn
, *args
, **kwargs
):
31 return qtutils
.add_action(parent
, title
, partial(fn
, *args
, **kwargs
))
34 def add_branch_to_menu(menu
, branch
, remote_branch
, remote
, upstream
, fn
):
35 """Add a remote branch to the context menu"""
36 branch_remote
, _
= gitcmds
.parse_remote_branch(remote_branch
)
37 if branch_remote
!= remote
:
39 action
= defer_fn(menu
, remote_branch
, fn
, branch
, remote_branch
)
40 if remote_branch
== upstream
:
41 action
.setIcon(icons
.star())
42 menu
.addAction(action
)
46 class AsyncGitActionTask(qtutils
.Task
):
47 """Run git action asynchronously"""
49 def __init__(self
, parent
, git_helper
, action
, args
, kwarg
,
50 refresh_tree
, update_remotes
):
51 qtutils
.Task
.__init
__(self
, parent
)
52 self
.git_helper
= git_helper
56 self
.refresh_tree
= refresh_tree
57 self
.update_remotes
= update_remotes
60 """Runs action and captures the result"""
61 git_action
= getattr(self
.git_helper
, self
.action
)
62 return git_action(*self
.args
, **self
.kwarg
)
65 class BranchesWidget(QtWidgets
.QWidget
):
66 def __init__(self
, context
, parent
):
67 QtWidgets
.QWidget
.__init
__(self
, parent
)
69 tooltip
= N_('Toggle the branches filter')
70 icon
= icons
.ellipsis()
71 self
.filter_button
= qtutils
.create_action_button(tooltip
=tooltip
,
74 self
.tree
= BranchesTreeWidget(context
, parent
=self
)
75 self
.filter_widget
= BranchesFilterWidget(self
.tree
)
76 self
.filter_widget
.hide()
78 self
.setFocusProxy(self
.tree
)
79 self
.setToolTip(N_('Branches'))
81 self
.main_layout
= qtutils
.vbox(defs
.no_margin
, defs
.spacing
,
82 self
.filter_widget
, self
.tree
)
83 self
.setLayout(self
.main_layout
)
85 self
.toggle_action
= qtutils
.add_action(self
, tooltip
,
88 qtutils
.connect_button(self
.filter_button
, self
.toggle_filter
)
90 def toggle_filter(self
):
91 shown
= not self
.filter_widget
.isVisible()
92 self
.filter_widget
.setVisible(shown
)
94 self
.filter_widget
.setFocus(True)
96 self
.tree
.setFocus(True)
99 class BranchesTreeWidget(standard
.TreeWidget
):
102 def __init__(self
, context
, parent
=None):
103 standard
.TreeWidget
.__init
__(self
, parent
)
105 self
.context
= context
106 self
.main_model
= model
= context
.model
108 self
.setSelectionMode(QtWidgets
.QAbstractItemView
.SingleSelection
)
109 self
.setHeaderHidden(True)
110 self
.setAlternatingRowColors(False)
111 self
.setColumnCount(1)
112 self
.setExpandsOnDoubleClick(False)
114 self
.tree_helper
= BranchesTreeHelper()
115 self
.git_helper
= GitHelper(model
.git
)
116 self
.current_branch
= None
118 self
.runtask
= qtutils
.RunTask(parent
=self
)
121 self
.updated
.connect(self
.refresh
, type=Qt
.QueuedConnection
)
122 model
.add_observer(model
.message_updated
, self
.updated
.emit
)
124 # Expand items when they are clicked
125 self
.clicked
.connect(self
._toggle
_expanded
)
130 model
= self
.main_model
131 self
.current_branch
= model
.currentbranch
133 states
= self
.save_tree_state()
135 local_dict
= self
.tree_helper
.group_branches(
136 model
.local_branches
, SLASH
)
138 remote_dict
= self
.tree_helper
.group_branches(
139 model
.remote_branches
, SLASH
)
141 tags_dict
= self
.tree_helper
.group_branches(model
.tags
, SLASH
)
143 ellipsis
= icons
.ellipsis()
144 local
= self
.tree_helper
.create_top_level_item(
145 NAME_LOCAL_BRANCH
, local_dict
,
146 icon
=icons
.branch(), ellipsis
=ellipsis
)
148 remote
= self
.tree_helper
.create_top_level_item(
149 NAME_REMOTE_BRANCH
, remote_dict
,
150 icon
=icons
.branch(), ellipsis
=ellipsis
)
152 tags
= self
.tree_helper
.create_top_level_item(
153 NAME_TAGS_BRANCH
, tags_dict
,
154 icon
=icons
.tag(), ellipsis
=ellipsis
)
157 self
.addTopLevelItems([local
, remote
, tags
])
158 self
.update_select_branch()
159 self
.load_tree_state(states
)
161 def showEvent(self
, event
):
162 """Defer updating widgets until the widget is visible"""
166 return super(BranchesTreeWidget
, self
).showEvent(event
)
168 def _toggle_expanded(self
, index
):
169 """Toggle expanded/collapsed state when items are clicked"""
170 self
.setExpanded(index
, not self
.isExpanded(index
))
172 def contextMenuEvent(self
, event
):
173 """Build and execute the context menu"""
174 context
= self
.context
175 selected
= self
.selected_item()
176 root
= self
.tree_helper
.get_root(selected
)
178 if selected
.childCount() > 0 or not root
:
181 full_name
= self
.tree_helper
.get_full_name(selected
, SLASH
)
182 menu
= qtutils
.create_menu(N_('Actions'), self
)
184 # all branches except current the current branch
185 if full_name
!= self
.current_branch
:
186 menu
.addAction(qtutils
.add_action(
187 menu
, N_('Checkout'), self
.checkout_action
))
189 if NAME_REMOTE_BRANCH
== root
.name
:
190 label
= N_('Checkout as new branch')
191 action
= self
.checkout_new_branch_action
192 menu
.addAction(qtutils
.add_action(menu
, label
, action
))
194 merge_menu_action
= qtutils
.add_action(
195 menu
, N_('Merge into current branch'), self
.merge_action
)
196 merge_menu_action
.setIcon(icons
.merge())
198 menu
.addAction(merge_menu_action
)
200 # local and remote branch
201 if NAME_TAGS_BRANCH
!= root
.name
:
203 if NAME_LOCAL_BRANCH
== root
.name
:
205 remote
= gitcmds
.tracked_branch(context
, full_name
)
206 if remote
is not None:
209 pull_menu_action
= qtutils
.add_action(
210 menu
, N_('Pull'), self
.pull_action
)
211 pull_menu_action
.setIcon(icons
.pull())
212 menu
.addAction(pull_menu_action
)
214 push_menu_action
= qtutils
.add_action(
215 menu
, N_('Push'), self
.push_action
)
216 push_menu_action
.setIcon(icons
.push())
217 menu
.addAction(push_menu_action
)
219 rename_menu_action
= qtutils
.add_action(
220 menu
, N_('Rename Branch'), self
.rename_action
)
221 rename_menu_action
.setIcon(icons
.edit())
224 menu
.addAction(rename_menu_action
)
227 if full_name
!= self
.current_branch
:
228 delete_label
= N_('Delete Branch')
229 if NAME_REMOTE_BRANCH
== root
.name
:
230 delete_label
= N_('Delete Remote Branch')
232 delete_menu_action
= qtutils
.add_action(
233 menu
, delete_label
, self
.delete_action
)
234 delete_menu_action
.setIcon(icons
.discard())
237 menu
.addAction(delete_menu_action
)
239 # manage upstreams for local branches
240 if root
.name
== NAME_LOCAL_BRANCH
:
241 upstream_menu
= menu
.addMenu(N_('Set Upstream Branch'))
242 upstream_menu
.setIcon(icons
.branch())
243 self
.build_upstream_menu(upstream_menu
)
245 menu
.exec_(self
.mapToGlobal(event
.pos()))
247 def build_upstream_menu(self
, menu
):
248 """Build the "Set Upstream Branch" sub-menu"""
249 context
= self
.context
250 model
= self
.main_model
251 selected_item
= self
.selected_item()
252 selected_branch
= self
.tree_helper
.get_full_name(selected_item
, SLASH
)
260 remote
= gitcmds
.upstream_remote(context
, selected_branch
)
261 upstream
= gitcmds
.tracked_branch(context
, branch
=selected_branch
)
263 if not remote
and 'origin' in model
.remotes
:
267 prefix
= remote
+ '/'
268 for branch
in model
.remote_branches
:
269 if branch
.startswith(prefix
):
270 branches
.append(branch
)
272 other_branches
.append(branch
)
274 # This can be a pretty big list, let's try to split it apart
277 for branch
in model
.remote_branches
:
278 new_branch_remote
, _
= gitcmds
.parse_remote_branch(branch
)
279 if branch_remote
and branch_remote
!= new_branch_remote
:
280 target
= other_branches
281 branch_remote
= new_branch_remote
282 target
.append(branch
)
285 if not other_branches
and len(branches
) > limit
:
286 branches
, other_branches
= (branches
[:limit
], branches
[limit
:])
288 # Add an action for each remote branch
289 current_remote
= remote
291 for branch
in branches
:
292 current_remote
= add_branch_to_menu(
293 menu
, selected_branch
, branch
, current_remote
,
294 upstream
, self
.set_upstream
)
296 # This list could be longer so we tuck it away in a sub-menu.
297 # Selecting a branch from the non-default remote is less common.
300 sub_menu
= menu
.addMenu(N_('Other branches'))
301 for branch
in other_branches
:
302 current_remote
= add_branch_to_menu(
303 sub_menu
, selected_branch
, branch
,
304 current_remote
, upstream
, self
.set_upstream
)
306 def set_upstream(self
, branch
, remote_branch
):
307 """Configure the upstream for a branch"""
308 context
= self
.context
309 remote
, r_branch
= gitcmds
.parse_remote_branch(remote_branch
)
310 if remote
and r_branch
:
311 cmds
.do(cmds
.SetUpstreamBranch
, context
, branch
, remote
, r_branch
)
313 def save_tree_state(self
):
315 for item
in self
.items():
316 states
.update(self
.tree_helper
.save_state(item
))
320 def load_tree_state(self
, states
):
321 for item
in self
.items():
322 if item
.name
in states
:
323 self
.tree_helper
.load_state(item
, states
[item
.name
])
325 def update_select_branch(self
):
326 context
= self
.context
327 current_branch
= self
.current_branch
328 top_item
= self
.topLevelItem(0)
329 item
= self
.tree_helper
.find_child(top_item
, current_branch
)
332 self
.tree_helper
.expand_from_item(item
)
333 item
.setIcon(0, icons
.star())
335 tracked_branch
= gitcmds
.tracked_branch(context
, current_branch
)
336 if current_branch
and tracked_branch
:
337 status
= {'ahead': 0, 'behind': 0}
340 origin
= tracked_branch
+ '..' + self
.current_branch
341 log
= self
.git_helper
.log(origin
)
342 status
['ahead'] = len(log
[1].splitlines())
344 origin
= self
.current_branch
+ '..' + tracked_branch
345 log
= self
.git_helper
.log(origin
)
346 status
['behind'] = len(log
[1].splitlines())
348 if status
['ahead'] > 0:
349 status_str
+= '%s%s' % (uchr(0x2191), status
['ahead'])
351 if status
['behind'] > 0:
352 status_str
+= ' %s%s' % (uchr(0x2193), status
['behind'])
355 item
.setText(0, '%s\t%s' % (item
.text(0), status_str
))
357 def git_action_async(self
, action
, args
, kwarg
=None, refresh_tree
=False,
358 update_remotes
=False):
361 task
= AsyncGitActionTask(self
, self
.git_helper
, action
, args
, kwarg
,
362 refresh_tree
, update_remotes
)
363 progress
= standard
.ProgressDialog(
364 N_('Executing action %s') % action
, N_('Updating'), self
)
365 self
.runtask
.start(task
, progress
=progress
,
366 finish
=self
.git_action_completed
)
368 def git_action_completed(self
, task
):
369 status
, out
, err
= task
.result
370 self
.git_helper
.show_result(task
.action
, status
, out
, err
)
371 if task
.refresh_tree
:
373 if task
.update_remotes
:
374 model
= self
.main_model
375 model
.update_remotes()
377 def push_action(self
):
378 context
= self
.context
379 branch
= self
.tree_helper
.get_full_name(self
.selected_item(), SLASH
)
380 remote_branch
= gitcmds
.tracked_branch(context
, branch
)
382 remote
, branch_name
= gitcmds
.parse_remote_branch(remote_branch
)
383 if remote
and branch_name
:
384 # we assume that user wants to "Push" the selected local
385 # branch to a remote with same name
386 self
.git_action_async('push', [remote
, branch_name
])
388 def rename_action(self
):
389 branch
= self
.tree_helper
.get_full_name(self
.selected_item(), SLASH
)
390 new_branch
= qtutils
.prompt(
391 N_('Enter New Branch Name'),
392 title
=N_('Rename branch'), text
=branch
)
393 if new_branch
[1] is True and new_branch
[0]:
394 self
.git_action_async('rename', [branch
, new_branch
[0]],
397 def pull_action(self
):
398 context
= self
.context
399 branch
= self
.tree_helper
.get_full_name(self
.selected_item(), SLASH
)
400 remote_branch
= gitcmds
.tracked_branch(context
, branch
)
402 remote
, branch_name
= gitcmds
.parse_remote_branch(remote_branch
)
403 if remote
and branch_name
:
404 self
.git_action_async(
405 'pull', [remote
, branch_name
], refresh_tree
=True)
407 def delete_action(self
):
408 title
= N_('Delete Branch')
409 question
= N_('Delete selected branch?')
410 info
= N_('The branch will be no longer available.')
411 ok_btn
= N_('Delete Branch')
413 branch
= self
.tree_helper
.get_full_name(self
.selected_item(), SLASH
)
415 if (branch
!= self
.current_branch
and
416 Interaction
.confirm(title
, question
, info
, ok_btn
)):
418 root
= self
.tree_helper
.get_root(self
.selected_item())
419 if NAME_REMOTE_BRANCH
== root
.name
:
423 remote
, branch_name
= gitcmds
.parse_remote_branch(branch
)
424 if remote
and branch_name
:
425 self
.git_action_async(
426 'delete_remote', [remote
, branch_name
],
429 self
.git_action_async('delete_local', [branch
])
431 def merge_action(self
):
432 branch
= self
.tree_helper
.get_full_name(self
.selected_item(), SLASH
)
434 if branch
!= self
.current_branch
:
435 self
.git_action_async('merge', [branch
], refresh_tree
=True)
437 def checkout_action(self
):
438 branch
= self
.tree_helper
.get_full_name(self
.selected_item(), SLASH
)
439 if branch
!= self
.current_branch
:
440 self
.git_action_async('checkout', [branch
])
442 def checkout_new_branch_action(self
):
443 branch
= self
.tree_helper
.get_full_name(self
.selected_item(), SLASH
)
444 if branch
!= self
.current_branch
:
445 _
, new_branch
= gitcmds
.parse_remote_branch(branch
)
446 self
.git_action_async('checkout', ['-b', new_branch
, branch
])
449 class BranchTreeWidgetItem(QtWidgets
.QTreeWidgetItem
):
451 def __init__(self
, name
, icon
=None):
452 QtWidgets
.QTreeWidgetItem
.__init
__(self
)
454 self
.setText(0, name
)
455 self
.setToolTip(0, name
)
457 self
.setIcon(0, icon
)
458 self
.setFlags(Qt
.ItemIsEnabled | Qt
.ItemIsSelectable
)
460 # TODO: review standard.py 317.
461 # original function returns 'QTreeWidgetItem' object which has no
462 # attribute 'rowCount'. This workaround fix error throw when
463 # navigating with keyboard and press left key
469 class BranchesTreeHelper(object):
471 def group_branches(list_branches
, separator_char
):
472 """Convert a list of delimited strings to a nested tree dict"""
474 for item
in list_branches
:
476 for part
in item
.split(separator_char
):
477 tree
= tree
.setdefault(part
, odict())
482 def create_top_level_item(name
, dict_children
,
483 icon
=None, ellipsis
=None):
484 """Create a top level tree item and its children """
486 def create_children(grouped_branches
):
487 """Create children items for a tree item"""
489 for k
, v
in grouped_branches
.items():
490 item
= BranchTreeWidgetItem(k
, icon
=icon
)
491 item
.addChildren(create_children(v
))
493 if item
.childCount() > 0 and ellipsis
is not None:
494 item
.setIcon(0, ellipsis
)
500 branch
= BranchTreeWidgetItem(name
, icon
=ellipsis
)
501 branch
.addChildren(create_children(dict_children
))
507 """Returns top level item from an item"""
509 parent
= item
.parent()
511 while parent
is not None:
512 parents
.append(parent
)
513 parent
= parent
.parent()
515 return parents
[len(parents
) - 1]
518 def get_full_name(item
, join_char
):
519 """Returns item full name generated by iterating over
520 parents and joining their names with 'join_char'"""
521 parents
= [item
.name
]
522 parent
= item
.parent()
524 while parent
is not None:
525 parents
.append(parent
.name
)
526 parent
= parent
.parent()
528 result
= join_char
.join(reversed(parents
))
530 return result
[result
.find(join_char
) + 1:]
533 def expand_from_item(item
):
534 """Expand tree parents from item"""
535 parent
= item
.parent()
537 while parent
is not None:
538 parent
.setExpanded(True)
539 parent
= parent
.parent()
541 def find_child(self
, top_level_item
, name
):
542 """Find child by full name recursive"""
545 for i
in range(top_level_item
.childCount()):
546 child
= top_level_item
.child(i
)
547 full_name
= self
.get_full_name(child
, SLASH
)
549 if full_name
== name
:
553 result
= self
.find_child(child
, name
)
554 if result
is not None:
559 def load_state(self
, item
, state
):
560 """Load expanded items from a dict"""
562 item
.setExpanded(True)
564 for i
in range(item
.childCount()):
565 child
= item
.child(i
)
566 if child
.name
in state
:
567 self
.load_state(child
, state
[child
.name
])
569 def save_state(self
, item
):
570 """Save expanded items in a dict"""
571 result
= {item
.name
: {}}
573 if item
.isExpanded():
574 for i
in range(item
.childCount()):
575 child
= item
.child(i
)
576 result
[item
.name
].update(self
.save_state(child
))
581 class GitHelper(object):
583 def __init__(self
, git
):
586 def log(self
, origin
):
587 return self
.git
.log(origin
, oneline
=True)
589 def push(self
, remote
, branch
):
590 return self
.git
.push(remote
, branch
, verbose
=True)
592 def pull(self
, remote
, branch
):
593 return self
.git
.pull(remote
, branch
, no_ff
=True, verbose
=True)
595 def delete_remote(self
, remote
, branch
):
596 return self
.git
.push(remote
, branch
, delete
=True)
598 def delete_local(self
, branch
):
599 return self
.git
.branch(branch
, D
=True)
601 def merge(self
, branch
):
602 return self
.git
.merge(branch
, no_commit
=True)
604 def rename(self
, branch
, new_branch
):
605 return self
.git
.branch(branch
, new_branch
, m
=True)
607 def checkout(self
, *args
, **options
):
608 return self
.git
.checkout(*args
, **options
)
611 def show_result(command
, status
, out
, err
):
612 Interaction
.log_status(status
, out
, err
)
614 Interaction
.command_error(N_('Error'), command
, status
, out
, err
)
617 class BranchesFilterWidget(QtWidgets
.QWidget
):
618 def __init__(self
, tree
, parent
=None):
619 QtWidgets
.QWidget
.__init
__(self
, parent
)
622 hint
= N_('Filter branches...')
623 self
.text
= LineEdit(parent
=self
, clear_button
=True)
624 self
.text
.setToolTip(hint
)
625 self
.setFocusProxy(self
.text
)
628 self
.main_layout
= qtutils
.hbox(defs
.no_margin
, defs
.spacing
, self
.text
)
629 self
.setLayout(self
.main_layout
)
632 text
.textChanged
.connect(self
.apply_filter
)
633 self
.tree
.updated
.connect(self
.apply_filter
, type=Qt
.QueuedConnection
)
635 def apply_filter(self
):
636 text
= get(self
.text
)
637 if text
== self
._filter
:
639 self
._apply
_bold
(self
._filter
, False)
642 self
._apply
_bold
(text
, True)
644 def _apply_bold(self
, text
, value
):
645 match
= Qt
.MatchContains | Qt
.MatchRecursive
646 children
= self
.tree
.findItems(text
, match
)
648 for child
in children
:
649 if child
.childCount() == 0:
652 child
.setFont(0, font
)