doc: add Thomas to the credits
[git-cola.git] / cola / widgets / branch.py
blob3944bc1429fc61f6fc9b0bded74632de3f25e4aa
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
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 SLASH = '/'
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:
38 menu.addSeparator()
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)
43 return branch_remote
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
53 self.action = action
54 self.args = args
55 self.kwarg = kwarg
56 self.refresh_tree = refresh_tree
57 self.update_remotes = update_remotes
59 def task(self):
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,
72 icon=icon)
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,
86 self.toggle_filter,
87 hotkeys.FILTER)
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)
93 if shown:
94 self.filter_widget.setFocus(True)
95 else:
96 self.tree.setFocus(True)
99 class BranchesTreeWidget(standard.TreeWidget):
100 updated = Signal()
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)
119 self._active = False
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)
127 def refresh(self):
128 if not self._active:
129 return
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)
156 self.clear()
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"""
163 if not self._active:
164 self._active = True
165 self.refresh()
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:
179 return
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))
188 # remote branch
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:
202 # local branch
203 if NAME_LOCAL_BRANCH == root.name:
205 remote = gitcmds.tracked_branch(context, full_name)
206 if remote is not None:
207 menu.addSeparator()
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())
223 menu.addSeparator()
224 menu.addAction(rename_menu_action)
226 # not current branch
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())
236 menu.addSeparator()
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)
253 remote = None
254 upstream = None
256 branches = []
257 other_branches = []
259 if selected_branch:
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:
264 remote = 'origin'
266 if remote:
267 prefix = remote + '/'
268 for branch in model.remote_branches:
269 if branch.startswith(prefix):
270 branches.append(branch)
271 else:
272 other_branches.append(branch)
273 else:
274 # This can be a pretty big list, let's try to split it apart
275 branch_remote = ''
276 target = branches
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)
284 limit = 16
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.
298 if other_branches:
299 menu.addSeparator()
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):
314 states = {}
315 for item in self.items():
316 states.update(self.tree_helper.save_state(item))
318 return states
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)
331 if item is not None:
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}
338 status_str = ''
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'])
354 if status_str:
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):
359 if kwarg is None:
360 kwarg = {}
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:
372 self.refresh()
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)
381 if remote_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]],
395 refresh_tree=True)
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)
401 if remote_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)):
417 remote = False
418 root = self.tree_helper.get_root(self.selected_item())
419 if NAME_REMOTE_BRANCH == root.name:
420 remote = True
422 if remote:
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],
427 update_remotes=True)
428 else:
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)
453 self.name = name
454 self.setText(0, name)
455 self.setToolTip(0, name)
456 if icon is not None:
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
464 @staticmethod
465 def rowCount():
466 return 1
469 class BranchesTreeHelper(object):
470 @staticmethod
471 def group_branches(list_branches, separator_char):
472 """Convert a list of delimited strings to a nested tree dict"""
473 result = odict()
474 for item in list_branches:
475 tree = result
476 for part in item.split(separator_char):
477 tree = tree.setdefault(part, odict())
479 return result
481 @staticmethod
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"""
488 result = []
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)
496 result.append(item)
498 return result
500 branch = BranchTreeWidgetItem(name, icon=ellipsis)
501 branch.addChildren(create_children(dict_children))
503 return branch
505 @staticmethod
506 def get_root(item):
507 """Returns top level item from an item"""
508 parents = [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]
517 @staticmethod
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:]
532 @staticmethod
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"""
543 result = None
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:
550 result = child
551 return result
552 else:
553 result = self.find_child(child, name)
554 if result is not None:
555 return result
557 return result
559 def load_state(self, item, state):
560 """Load expanded items from a dict"""
561 if state.keys():
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))
578 return result
581 class GitHelper(object):
583 def __init__(self, git):
584 self.git = 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)
610 @staticmethod
611 def show_result(command, status, out, err):
612 Interaction.log_status(status, out, err)
613 if status != 0:
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)
620 self.tree = tree
622 hint = N_('Filter branches...')
623 self.text = LineEdit(parent=self, clear_button=True)
624 self.text.setToolTip(hint)
625 self.setFocusProxy(self.text)
626 self._filter = None
628 self.main_layout = qtutils.hbox(defs.no_margin, defs.spacing, self.text)
629 self.setLayout(self.main_layout)
631 text = self.text
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:
638 return
639 self._apply_bold(self._filter, False)
640 self._filter = text
641 if text:
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:
650 font = child.font(0)
651 font.setBold(value)
652 child.setFont(0, font)