widgets.remote: retain focus when showing remote messages
[git-cola.git] / cola / widgets / remote.py
blob24e090843eed8eedac717df5f46f0074a2c5f8c8
1 """Widgets for Fetch, Push, and Pull"""
2 import fnmatch
4 from qtpy import QtGui
5 from qtpy import QtWidgets
6 from qtpy.QtCore import Qt
8 from ..i18n import N_
9 from ..interaction import Interaction
10 from ..qtutils import connect_button
11 from ..qtutils import get
12 from .. import gitcmds
13 from .. import icons
14 from .. import qtutils
15 from .. import utils
16 from . import defs
17 from . import log
18 from . import standard
21 FETCH = 'FETCH'
22 PUSH = 'PUSH'
23 PULL = 'PULL'
26 def fetch(context):
27 """Fetch from remote repositories"""
28 return run(context, Fetch)
31 def push(context):
32 """Push to remote repositories"""
33 return run(context, Push)
36 def pull(context):
37 """Pull from remote repositories"""
38 return run(context, Pull)
41 def run(context, RemoteDialog):
42 """Launches fetch/push/pull dialogs."""
43 # Copy global stuff over to speedup startup
44 parent = qtutils.active_window()
45 view = RemoteDialog(context, parent=parent)
46 view.show()
47 return view
50 def combine(result, prev):
51 """Combine multiple (status, out, err) tuples into a combined tuple
53 The current state is passed in via `prev`.
54 The status code is a max() over all the subprocess status codes.
55 Individual (out, err) strings are sequentially concatenated together.
57 """
58 if isinstance(prev, (tuple, list)):
59 if len(prev) != 3:
60 raise AssertionError('combine() with length %d' % len(prev))
61 combined = (
62 max(prev[0], result[0]),
63 combine(prev[1], result[1]),
64 combine(prev[2], result[2]),
66 elif prev and result:
67 combined = prev + '\n\n' + result
68 elif prev:
69 combined = prev
70 else:
71 combined = result
73 return combined
76 def uncheck(value, *checkboxes):
77 """Uncheck the specified checkboxes if value is True"""
78 if value:
79 for checkbox in checkboxes:
80 checkbox.setChecked(False)
83 def strip_remotes(remote_branches):
84 """Strip the <remote>/ prefixes from branches
86 e.g. "origin/main" becomes "main".
88 """
89 branches = [utils.strip_one(branch) for branch in remote_branches]
90 return [branch for branch in branches if branch != 'HEAD']
93 def get_default_remote(context):
94 """Get the name of the default remote to use for pushing.
96 This will be the remote the branch is set to track, if it is set. If it
97 is not, remote.pushDefault will be used (or origin if not set)
99 """
100 upstream_remote = gitcmds.upstream_remote(context)
101 return upstream_remote or context.cfg.get('remote.pushDefault', default='origin')
104 class ActionTask(qtutils.Task):
105 """Run actions asynchronously"""
107 def __init__(self, model_action, remote, kwargs):
108 qtutils.Task.__init__(self)
109 self.model_action = model_action
110 self.remote = remote
111 self.kwargs = kwargs
113 def task(self):
114 """Runs the model action and captures the result"""
115 return self.model_action(self.remote, **self.kwargs)
118 class RemoteActionDialog(standard.Dialog):
119 """Interface for performing remote operations"""
121 def __init__(self, context, action, title, parent=None, icon=None):
122 """Customize the dialog based on the remote action"""
123 standard.Dialog.__init__(self, parent=parent)
124 self.setWindowTitle(title)
125 if parent is not None:
126 self.setWindowModality(Qt.WindowModal)
128 self.context = context
129 self.model = model = context.model
130 self.action = action
131 self.filtered_remote_branches = []
132 self.selected_remotes = []
134 self.runtask = qtutils.RunTask(parent=self)
135 self.local_label = QtWidgets.QLabel()
136 self.local_label.setText(N_('Local Branch'))
138 self.local_branch = QtWidgets.QLineEdit()
139 qtutils.add_completer(self.local_branch, model.local_branches)
141 self.local_branches = QtWidgets.QListWidget()
142 self.local_branches.addItems(model.local_branches)
144 self.remote_label = QtWidgets.QLabel()
145 self.remote_label.setText(N_('Remote'))
147 self.remote_name = QtWidgets.QLineEdit()
148 qtutils.add_completer(self.remote_name, model.remotes)
149 # pylint: disable=no-member
150 self.remote_name.editingFinished.connect(self.remote_name_edited)
151 self.remote_name.textEdited.connect(lambda x: self.remote_name_edited())
153 self.remotes = QtWidgets.QListWidget()
154 if action == PUSH:
155 mode = QtWidgets.QAbstractItemView.ExtendedSelection
156 self.remotes.setSelectionMode(mode)
157 self.remotes.addItems(model.remotes)
159 self.remote_branch_label = QtWidgets.QLabel()
160 self.remote_branch_label.setText(N_('Remote Branch'))
162 self.remote_branch = QtWidgets.QLineEdit()
163 remote_branches = strip_remotes(model.remote_branches)
164 qtutils.add_completer(self.remote_branch, remote_branches)
166 self.remote_branches = QtWidgets.QListWidget()
167 self.remote_branches.addItems(model.remote_branches)
169 text = N_('Prompt on creation')
170 tooltip = N_('Prompt when pushing creates new remote branches')
171 self.prompt_checkbox = qtutils.checkbox(
172 checked=True, text=text, tooltip=tooltip
175 text = N_('Show remote messages')
176 tooltip = N_('Display remote messages in a separate dialog')
177 self.remote_messages_checkbox = qtutils.checkbox(
178 checked=False, text=text, tooltip=tooltip
181 text = N_('Fast-forward only')
182 tooltip = N_(
183 'Refuse to merge unless the current HEAD is already up-'
184 'to-date or the merge can be resolved as a fast-forward'
186 self.ff_only_checkbox = qtutils.checkbox(
187 checked=True, text=text, tooltip=tooltip
190 text = N_('No fast-forward')
191 tooltip = N_(
192 'Create a merge commit even when the merge resolves as a fast-forward'
194 self.no_ff_checkbox = qtutils.checkbox(
195 checked=False, text=text, tooltip=tooltip
197 text = N_('Force')
198 tooltip = N_(
199 'Allow non-fast-forward updates. Using "force" can '
200 'cause the remote repository to lose commits; '
201 'use it with care'
203 self.force_checkbox = qtutils.checkbox(
204 checked=False, text=text, tooltip=tooltip
207 self.tags_checkbox = qtutils.checkbox(text=N_('Include tags '))
209 tooltip = N_(
210 'Remove remote-tracking branches that no longer exist on the remote'
212 self.prune_checkbox = qtutils.checkbox(text=N_('Prune '), tooltip=tooltip)
214 tooltip = N_('Rebase the current branch instead of merging')
215 self.rebase_checkbox = qtutils.checkbox(text=N_('Rebase'), tooltip=tooltip)
217 text = N_('Set upstream')
218 tooltip = N_('Configure the remote branch as the the new upstream')
219 self.upstream_checkbox = qtutils.checkbox(text=text, tooltip=tooltip)
221 self.action_button = qtutils.ok_button(title, icon=icon)
222 self.close_button = qtutils.close_button()
223 self.buttons_group = utils.Group(self.close_button, self.action_button)
224 self.inputs_group = utils.Group(
225 self.force_checkbox,
226 self.ff_only_checkbox,
227 self.local_branch,
228 self.local_branches,
229 self.tags_checkbox,
230 self.prune_checkbox,
231 self.rebase_checkbox,
232 self.remote_name,
233 self.remotes,
234 self.remote_branch,
235 self.remote_branches,
236 self.upstream_checkbox,
237 self.prompt_checkbox,
238 self.remote_messages_checkbox,
240 self.progress = standard.progress_bar(
241 self,
242 hide=(self.action_button,),
243 disable=(self.buttons_group, self.inputs_group),
246 self.local_branch_layout = qtutils.hbox(
247 defs.small_margin, defs.spacing, self.local_label, self.local_branch
250 self.remote_layout = qtutils.hbox(
251 defs.small_margin, defs.spacing, self.remote_label, self.remote_name
254 self.remote_branch_layout = qtutils.hbox(
255 defs.small_margin,
256 defs.spacing,
257 self.remote_branch_label,
258 self.remote_branch,
261 self.options_layout = qtutils.hbox(
262 defs.no_margin,
263 defs.button_spacing,
264 self.force_checkbox,
265 self.ff_only_checkbox,
266 self.no_ff_checkbox,
267 self.tags_checkbox,
268 self.prune_checkbox,
269 self.rebase_checkbox,
270 self.upstream_checkbox,
271 self.prompt_checkbox,
272 self.remote_messages_checkbox,
273 qtutils.STRETCH,
274 self.close_button,
275 self.action_button,
276 self.progress,
279 self.remote_input_layout = qtutils.vbox(
280 defs.no_margin, defs.spacing, self.remote_layout, self.remotes
283 self.local_branch_input_layout = qtutils.vbox(
284 defs.no_margin, defs.spacing, self.local_branch_layout, self.local_branches
287 self.remote_branch_input_layout = qtutils.vbox(
288 defs.no_margin,
289 defs.spacing,
290 self.remote_branch_layout,
291 self.remote_branches,
294 if action == PUSH:
295 widgets = (
296 self.remote_input_layout,
297 self.local_branch_input_layout,
298 self.remote_branch_input_layout,
300 else: # fetch and pull
301 widgets = (
302 self.remote_input_layout,
303 self.remote_branch_input_layout,
304 self.local_branch_input_layout,
306 self.top_layout = qtutils.hbox(defs.no_margin, defs.spacing, *widgets)
308 self.main_layout = qtutils.vbox(
309 defs.margin, defs.spacing, self.top_layout, self.options_layout
311 self.setLayout(self.main_layout)
313 default_remote = get_default_remote(context)
315 remotes = model.remotes
316 if default_remote in remotes:
317 idx = remotes.index(default_remote)
318 if self.select_remote(idx):
319 self.set_remote_name(default_remote)
320 else:
321 if self.select_first_remote():
322 self.set_remote_name(remotes[0])
324 # Trim the remote list to just the default remote
325 self.update_remotes()
326 self.set_field_defaults()
328 # Setup signals and slots
329 # pylint: disable=no-member
330 self.remotes.itemSelectionChanged.connect(self.update_remotes)
332 local = self.local_branches
333 local.itemSelectionChanged.connect(self.update_local_branches)
335 remote = self.remote_branches
336 remote.itemSelectionChanged.connect(self.update_remote_branches)
338 self.no_ff_checkbox.toggled.connect(
339 lambda x: uncheck(x, self.ff_only_checkbox, self.rebase_checkbox)
342 self.ff_only_checkbox.toggled.connect(
343 lambda x: uncheck(x, self.no_ff_checkbox, self.rebase_checkbox)
346 self.rebase_checkbox.toggled.connect(
347 lambda x: uncheck(x, self.no_ff_checkbox, self.ff_only_checkbox)
350 connect_button(self.action_button, self.action_callback)
351 connect_button(self.close_button, self.close)
353 qtutils.add_action(
354 self, N_('Close'), self.close, QtGui.QKeySequence.Close, 'Esc'
356 if action != FETCH:
357 self.prune_checkbox.hide()
359 if action != PUSH:
360 # Push-only options
361 self.upstream_checkbox.hide()
362 self.prompt_checkbox.hide()
364 if action == PULL:
365 # Fetch and Push-only options
366 self.force_checkbox.hide()
367 self.tags_checkbox.hide()
368 self.local_label.hide()
369 self.local_branch.hide()
370 self.local_branches.hide()
371 else:
372 # Pull-only options
373 self.rebase_checkbox.hide()
374 self.no_ff_checkbox.hide()
375 self.ff_only_checkbox.hide()
377 self.init_size(parent=parent)
379 def set_rebase(self, value):
380 """Check the rebase checkbox"""
381 self.rebase_checkbox.setChecked(value)
383 def set_field_defaults(self):
384 """Set sensible initial defaults"""
385 # Default to "git fetch origin main"
386 action = self.action
387 if action in (FETCH, PULL):
388 self.local_branch.setText('')
389 self.remote_branch.setText('')
390 return
392 # Select the current branch by default for push
393 if action == PUSH:
394 branch = self.model.currentbranch
395 try:
396 idx = self.model.local_branches.index(branch)
397 except ValueError:
398 return
399 if self.select_local_branch(idx):
400 self.set_local_branch(branch)
401 self.set_remote_branch('')
403 def set_remote_name(self, remote_name):
404 """Set the remote name"""
405 self.remote_name.setText(remote_name)
407 def set_local_branch(self, branch):
408 """Set the local branch name"""
409 self.local_branch.setText(branch)
410 if branch:
411 self.local_branch.selectAll()
413 def set_remote_branch(self, branch):
414 """Set the remote branch name"""
415 self.remote_branch.setText(branch)
416 if branch:
417 self.remote_branch.selectAll()
419 def set_remote_branches(self, branches):
420 """Set the list of remote branches"""
421 self.remote_branches.clear()
422 self.remote_branches.addItems(branches)
423 self.filtered_remote_branches = branches
424 qtutils.add_completer(self.remote_branch, strip_remotes(branches))
426 def select_first_remote(self):
427 """Select the first remote in the list view"""
428 return self.select_remote(0)
430 def select_remote(self, idx):
431 """Select a remote by index"""
432 item = self.remotes.item(idx)
433 if item:
434 item.setSelected(True)
435 self.remotes.setCurrentItem(item)
436 self.set_remote_name(item.text())
437 result = True
438 else:
439 result = False
440 return result
442 def select_remote_by_name(self, remote):
443 """Select a remote by name"""
444 remotes = self.model.remotes
445 if remote in remotes:
446 idx = remotes.index(remote)
447 result = self.select_remote(idx)
448 else:
449 result = False
450 return result
452 def set_selected_remotes(self, remotes):
453 """Set the list of selected remotes
455 Return True if all remotes were found and selected.
458 # Invalid remote names are ignored.
459 # This handles a remote going away between sessions.
460 # The selection is unchanged when none of the specified remotes exist.
461 found = False
462 for remote in remotes:
463 if remote in self.model.remotes:
464 found = True
465 break
466 if found:
467 # Only clear the selection if the specified remotes exist
468 self.remotes.clearSelection()
469 found = all(self.select_remote_by_name(x) for x in remotes)
470 return found
472 def select_local_branch(self, idx):
473 """Selects a local branch by index in the list view"""
474 item = self.local_branches.item(idx)
475 if item:
476 item.setSelected(True)
477 self.local_branches.setCurrentItem(item)
478 self.local_branch.setText(item.text())
479 result = True
480 else:
481 result = False
482 return result
484 def display_remotes(self, widget):
485 """Display the available remotes in a listwidget"""
486 displayed = []
487 for remote_name in self.model.remotes:
488 url = self.model.remote_url(remote_name, self.action)
489 display = '{}\t({})'.format(remote_name, N_('URL: %s') % url)
490 displayed.append(display)
491 qtutils.set_items(widget, displayed)
493 def update_remotes(self):
494 """Update the remote name when a remote from the list is selected"""
495 widget = self.remotes
496 remotes = self.model.remotes
497 selection = qtutils.selected_item(widget, remotes)
498 if not selection:
499 self.selected_remotes = []
500 return
501 self.set_remote_name(selection)
502 self.selected_remotes = qtutils.selected_items(self.remotes, self.model.remotes)
503 self.set_remote_to(selection, self.selected_remotes)
505 def set_remote_to(self, _remote, selected_remotes):
506 context = self.context
507 all_branches = gitcmds.branch_list(context, remote=True)
508 branches = []
509 patterns = []
510 for remote_name in selected_remotes:
511 patterns.append(remote_name + '/*')
513 for branch in all_branches:
514 for pat in patterns:
515 if fnmatch.fnmatch(branch, pat):
516 branches.append(branch)
517 break
518 if branches:
519 self.set_remote_branches(branches)
520 else:
521 self.set_remote_branches(all_branches)
522 self.set_remote_branch('')
524 def remote_name_edited(self):
525 """Update the current remote when the remote name is typed manually"""
526 remote = self.remote_name.text()
527 self.set_remote_to(remote, [remote])
529 def update_local_branches(self):
530 """Update the local/remote branch names when a branch is selected"""
531 branches = self.model.local_branches
532 widget = self.local_branches
533 selection = qtutils.selected_item(widget, branches)
534 if not selection:
535 return
536 self.set_local_branch(selection)
537 self.set_remote_branch(selection)
539 def update_remote_branches(self):
540 """Update the remote branch name when a branch is selected"""
541 widget = self.remote_branches
542 branches = self.filtered_remote_branches
543 selection = qtutils.selected_item(widget, branches)
544 if not selection:
545 return
546 branch = utils.strip_one(selection)
547 if branch == 'HEAD':
548 return
549 self.set_remote_branch(branch)
551 def common_args(self):
552 """Returns git arguments common to fetch/push/pull"""
553 remote_name = self.remote_name.text()
554 local_branch = self.local_branch.text()
555 remote_branch = self.remote_branch.text()
557 ff_only = get(self.ff_only_checkbox)
558 force = get(self.force_checkbox)
559 no_ff = get(self.no_ff_checkbox)
560 rebase = get(self.rebase_checkbox)
561 set_upstream = get(self.upstream_checkbox)
562 tags = get(self.tags_checkbox)
563 prune = get(self.prune_checkbox)
565 return (
566 remote_name,
568 'ff_only': ff_only,
569 'force': force,
570 'local_branch': local_branch,
571 'no_ff': no_ff,
572 'rebase': rebase,
573 'remote_branch': remote_branch,
574 'set_upstream': set_upstream,
575 'tags': tags,
576 'prune': prune,
580 # Actions
582 def push_to_all(self, _remote, *args, **kwargs):
583 """Push to all selected remotes"""
584 selected_remotes = self.selected_remotes
585 all_results = None
586 for remote in selected_remotes:
587 result = self.model.push(remote, *args, **kwargs)
588 all_results = combine(result, all_results)
589 return all_results
591 def action_callback(self):
592 """Perform the actual fetch/push/pull operation"""
593 action = self.action
594 remote_messages = get(self.remote_messages_checkbox)
595 if action == FETCH:
596 model_action = self.model.fetch
597 elif action == PUSH:
598 model_action = self.push_to_all
599 else: # if action == PULL:
600 model_action = self.model.pull
602 remote_name = self.remote_name.text()
603 if not remote_name:
604 errmsg = N_('No repository selected.')
605 Interaction.log(errmsg)
606 return
607 remote, kwargs = self.common_args()
608 self.selected_remotes = qtutils.selected_items(self.remotes, self.model.remotes)
610 # Check if we're about to create a new branch and warn.
611 remote_branch = self.remote_branch.text()
612 local_branch = self.local_branch.text()
614 if action == PUSH and not remote_branch:
615 branch = local_branch
616 candidate = f'{remote}/{branch}'
617 prompt = get(self.prompt_checkbox)
619 if prompt and candidate not in self.model.remote_branches:
620 title = N_('Push')
621 args = {
622 'branch': branch,
623 'remote': remote,
625 msg = (
627 'Branch "%(branch)s" does not exist in "%(remote)s".\n'
628 'A new remote branch will be published.'
630 % args
632 info_txt = N_('Create a new remote branch?')
633 ok_text = N_('Create Remote Branch')
634 if not Interaction.confirm(
635 title, msg, info_txt, ok_text, icon=icons.cola()
637 return
639 if get(self.force_checkbox):
640 if action == FETCH:
641 title = N_('Force Fetch?')
642 msg = N_('Non-fast-forward fetch overwrites local history!')
643 info_txt = N_('Force fetching from %s?') % remote
644 ok_text = N_('Force Fetch')
645 elif action == PUSH:
646 title = N_('Force Push?')
647 msg = N_(
648 'Non-fast-forward push overwrites published '
649 'history!\n(Did you pull first?)'
651 info_txt = N_('Force push to %s?') % remote
652 ok_text = N_('Force Push')
653 else: # pull: shouldn't happen since the controls are hidden
654 return
655 if not Interaction.confirm(
656 title, msg, info_txt, ok_text, default=False, icon=icons.discard()
658 return
660 self.progress.setMaximumWidth(self.action_button.width())
661 self.progress.setMinimumHeight(self.action_button.height() - 2)
663 # Use a thread to update in the background
664 task = ActionTask(model_action, remote, kwargs)
665 if remote_messages:
666 result = log.show_remote_messages(self, self.context)
667 else:
668 result = None
669 self.runtask.start(
670 task,
671 progress=self.progress,
672 finish=self.action_completed,
673 result=result,
676 def action_completed(self, task):
677 """Grab the results of the action and finish up"""
678 if not task.result or not isinstance(task.result, (list, tuple)):
679 return
681 status, out, err = task.result
682 command = 'git %s' % self.action.lower()
683 message = Interaction.format_command_status(command, status)
684 details = Interaction.format_out_err(out, err)
686 log_message = message
687 if details:
688 log_message += '\n\n' + details
689 Interaction.log(log_message)
691 if status == 0:
692 self.accept()
693 return
695 if self.action == PUSH:
696 message += '\n\n'
697 message += N_('Have you rebased/pulled lately?')
699 Interaction.critical(self.windowTitle(), message=message, details=details)
701 def export_state(self):
702 """Export persistent settings"""
703 state = standard.Dialog.export_state(self)
704 state['remote_messages'] = get(self.remote_messages_checkbox)
705 return state
707 def apply_state(self, state):
708 """Apply persistent settings"""
709 result = standard.Dialog.apply_state(self, state)
710 # Restore the "show remote messages" checkbox
711 remote_messages = bool(state.get('remote_messages', False))
712 self.remote_messages_checkbox.setChecked(remote_messages)
713 return result
716 # Use distinct classes so that each saves its own set of preferences
717 class Fetch(RemoteActionDialog):
718 """Fetch from remote repositories"""
720 def __init__(self, context, parent=None):
721 super().__init__(context, FETCH, N_('Fetch'), parent=parent, icon=icons.repo())
723 def export_state(self):
724 """Export persistent settings"""
725 state = RemoteActionDialog.export_state(self)
726 state['tags'] = get(self.tags_checkbox)
727 state['prune'] = get(self.prune_checkbox)
728 return state
730 def apply_state(self, state):
731 """Apply persistent settings"""
732 result = RemoteActionDialog.apply_state(self, state)
733 tags = bool(state.get('tags', False))
734 self.tags_checkbox.setChecked(tags)
735 prune = bool(state.get('prune', False))
736 self.prune_checkbox.setChecked(prune)
737 return result
740 class Push(RemoteActionDialog):
741 """Push to remote repositories"""
743 def __init__(self, context, parent=None):
744 super().__init__(context, PUSH, N_('Push'), parent=parent, icon=icons.push())
746 def export_state(self):
747 """Export persistent settings"""
748 state = RemoteActionDialog.export_state(self)
749 state['prompt'] = get(self.prompt_checkbox)
750 state['tags'] = get(self.tags_checkbox)
751 return state
753 def apply_state(self, state):
754 """Apply persistent settings"""
755 result = RemoteActionDialog.apply_state(self, state)
756 # Restore the "prompt on creation" checkbox
757 prompt = bool(state.get('prompt', True))
758 self.prompt_checkbox.setChecked(prompt)
759 # Restore the "tags" checkbox
760 tags = bool(state.get('tags', False))
761 self.tags_checkbox.setChecked(tags)
762 return result
765 class Pull(RemoteActionDialog):
766 """Pull from remote repositories"""
768 def __init__(self, context, parent=None):
769 super().__init__(context, PULL, N_('Pull'), parent=parent, icon=icons.pull())
771 def apply_state(self, state):
772 """Apply persistent settings"""
773 result = RemoteActionDialog.apply_state(self, state)
774 # Rebase has the highest priority
775 rebase = bool(state.get('rebase', False))
776 self.rebase_checkbox.setChecked(rebase)
778 ff_only = not rebase and bool(state.get('ff_only', False))
779 no_ff = not rebase and not ff_only and bool(state.get('no_ff', False))
780 self.no_ff_checkbox.setChecked(no_ff)
781 # Allow users coming from older versions that have rebase=False to
782 # pickup the new ff_only=True default by only setting ff_only False
783 # when it either exists in the config or when rebase=True.
784 if 'ff_only' in state or rebase:
785 self.ff_only_checkbox.setChecked(ff_only)
786 return result
788 def export_state(self):
789 """Export persistent settings"""
790 state = RemoteActionDialog.export_state(self)
791 state['ff_only'] = get(self.ff_only_checkbox)
792 state['no_ff'] = get(self.no_ff_checkbox)
793 state['rebase'] = get(self.rebase_checkbox)
794 return state