widgets: use qtutils.BlockSignals to manage blockSignals(bool)
[git-cola.git] / cola / widgets / status.py
blobee2fbef9721fecf020b997745ce305107fa3ed0c
1 from __future__ import division, absolute_import, unicode_literals
2 import itertools
3 import os
4 from functools import partial
6 from qtpy.QtCore import Qt
7 from qtpy.QtCore import Signal
8 from qtpy import QtCore
9 from qtpy import QtWidgets
11 from ..i18n import N_
12 from ..models import prefs
13 from ..models import selection
14 from ..widgets import gitignore
15 from ..widgets import standard
16 from ..qtutils import get
17 from ..settings import Settings
18 from .. import actions
19 from .. import cmds
20 from .. import core
21 from .. import hotkeys
22 from .. import icons
23 from .. import qtutils
24 from .. import utils
25 from . import common
26 from . import completion
27 from . import defs
28 from . import text
31 class StatusWidget(QtWidgets.QFrame):
32 """
33 Provides a git-status-like repository widget.
35 This widget observes the main model and broadcasts
36 Qt signals.
38 """
40 def __init__(self, context, titlebar, parent):
41 QtWidgets.QFrame.__init__(self, parent)
42 self.context = context
44 tooltip = N_('Toggle the paths filter')
45 icon = icons.ellipsis()
46 self.filter_button = qtutils.create_action_button(tooltip=tooltip, icon=icon)
47 self.filter_widget = StatusFilterWidget(context)
48 self.filter_widget.hide()
49 self.tree = StatusTreeWidget(context, parent=self)
50 self.setFocusProxy(self.tree)
52 self.main_layout = qtutils.vbox(
53 defs.no_margin, defs.no_spacing, self.filter_widget, self.tree
55 self.setLayout(self.main_layout)
57 self.toggle_action = qtutils.add_action(
58 self, tooltip, self.toggle_filter, hotkeys.FILTER
61 titlebar.add_corner_widget(self.filter_button)
62 qtutils.connect_button(self.filter_button, self.toggle_filter)
64 def toggle_filter(self):
65 shown = not self.filter_widget.isVisible()
66 self.filter_widget.setVisible(shown)
67 if shown:
68 self.filter_widget.setFocus()
69 else:
70 self.tree.setFocus()
72 def set_initial_size(self):
73 self.setMaximumWidth(222)
74 QtCore.QTimer.singleShot(1, lambda: self.setMaximumWidth(2 ** 13))
76 def refresh(self):
77 self.tree.show_selection()
79 def set_filter(self, txt):
80 self.filter_widget.setVisible(True)
81 self.filter_widget.text.set_value(txt)
82 self.filter_widget.apply_filter()
84 def move_up(self):
85 self.tree.move_up()
87 def move_down(self):
88 self.tree.move_down()
90 def select_header(self):
91 self.tree.select_header()
94 # pylint: disable=too-many-ancestors
95 class StatusTreeWidget(QtWidgets.QTreeWidget):
96 # Signals
97 about_to_update = Signal()
98 updated = Signal()
99 diff_text_changed = Signal()
101 # Item categories
102 idx_header = -1
103 idx_staged = 0
104 idx_unmerged = 1
105 idx_modified = 2
106 idx_untracked = 3
107 idx_end = 4
109 # Read-only access to the mode state
110 mode = property(lambda self: self.m.mode)
112 def __init__(self, context, parent=None):
113 QtWidgets.QTreeWidget.__init__(self, parent)
114 self.context = context
115 self.selection_model = context.selection
117 self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
118 self.headerItem().setHidden(True)
119 self.setAllColumnsShowFocus(True)
120 self.setSortingEnabled(False)
121 self.setUniformRowHeights(True)
122 self.setAnimated(True)
123 self.setRootIsDecorated(False)
124 self.setDragEnabled(True)
125 self.setAutoScroll(False)
127 if not prefs.status_indent(context):
128 self.setIndentation(0)
130 ok = icons.ok()
131 compare = icons.compare()
132 question = icons.question()
133 self._add_toplevel_item(N_('Staged'), ok, hide=True)
134 self._add_toplevel_item(N_('Unmerged'), compare, hide=True)
135 self._add_toplevel_item(N_('Modified'), compare, hide=True)
136 self._add_toplevel_item(N_('Untracked'), question, hide=True)
138 # Used to restore the selection
139 self.old_vscroll = None
140 self.old_hscroll = None
141 self.old_selection = None
142 self.old_contents = None
143 self.old_current_item = None
144 self.was_visible = True
145 self.expanded_items = set()
147 self.image_formats = qtutils.ImageFormats()
149 self.process_selection_action = qtutils.add_action(
150 self,
151 cmds.StageOrUnstage.name(),
152 self._stage_selection,
153 hotkeys.STAGE_SELECTION,
155 self.process_selection_action.setIcon(icons.add())
157 self.stage_or_unstage_all_action = qtutils.add_action(
158 self,
159 cmds.StageOrUnstageAll.name(),
160 cmds.run(cmds.StageOrUnstageAll, self.context),
161 hotkeys.STAGE_ALL,
163 self.stage_or_unstage_all_action.setIcon(icons.add())
165 self.revert_unstaged_edits_action = qtutils.add_action(
166 self,
167 cmds.RevertUnstagedEdits.name(),
168 cmds.run(cmds.RevertUnstagedEdits, context),
169 hotkeys.REVERT,
171 self.revert_unstaged_edits_action.setIcon(icons.undo())
173 self.launch_difftool_action = qtutils.add_action(
174 self,
175 cmds.LaunchDifftool.name(),
176 cmds.run(cmds.LaunchDifftool, context),
177 hotkeys.DIFF,
179 self.launch_difftool_action.setIcon(icons.diff())
181 self.launch_editor_action = actions.launch_editor_at_line(
182 context, self, *hotkeys.ACCEPT
185 if not utils.is_win32():
186 self.default_app_action = common.default_app_action(
187 context, self, self.selected_group
190 self.parent_dir_action = common.parent_dir_action(
191 context, self, self.selected_group
194 self.terminal_action = common.terminal_action(
195 context, self, self.selected_group
198 self.up_action = qtutils.add_action(
199 self,
200 N_('Move Up'),
201 self.move_up,
202 hotkeys.MOVE_UP,
203 hotkeys.MOVE_UP_SECONDARY,
206 self.down_action = qtutils.add_action(
207 self,
208 N_('Move Down'),
209 self.move_down,
210 hotkeys.MOVE_DOWN,
211 hotkeys.MOVE_DOWN_SECONDARY,
214 self.copy_path_action = qtutils.add_action(
215 self,
216 N_('Copy Path to Clipboard'),
217 partial(copy_path, context),
218 hotkeys.COPY,
220 self.copy_path_action.setIcon(icons.copy())
222 self.copy_relpath_action = qtutils.add_action(
223 self,
224 N_('Copy Relative Path to Clipboard'),
225 partial(copy_relpath, context),
226 hotkeys.CUT,
228 self.copy_relpath_action.setIcon(icons.copy())
230 self.copy_leading_path_action = qtutils.add_action(
231 self,
232 N_('Copy Leading Path to Clipboard'),
233 partial(copy_leading_path, context),
235 self.copy_leading_path_action.setIcon(icons.copy())
237 self.copy_basename_action = qtutils.add_action(
238 self, N_('Copy Basename to Clipboard'), partial(copy_basename, context)
240 self.copy_basename_action.setIcon(icons.copy())
242 self.copy_customize_action = qtutils.add_action(
243 self, N_('Customize...'), partial(customize_copy_actions, context, self)
245 self.copy_customize_action.setIcon(icons.configure())
247 self.view_history_action = qtutils.add_action(
248 self, N_('View History...'), partial(view_history, context), hotkeys.HISTORY
251 self.view_blame_action = qtutils.add_action(
252 self, N_('Blame...'), partial(view_blame, context), hotkeys.BLAME
255 self.annex_add_action = qtutils.add_action(
256 self, N_('Add to Git Annex'), cmds.run(cmds.AnnexAdd, context)
259 self.lfs_track_action = qtutils.add_action(
260 self, N_('Add to Git LFS'), cmds.run(cmds.LFSTrack, context)
263 # MoveToTrash and Delete use the same shortcut.
264 # We will only bind one of them, depending on whether or not the
265 # MoveToTrash command is available. When available, the hotkey
266 # is bound to MoveToTrash, otherwise it is bound to Delete.
267 if cmds.MoveToTrash.AVAILABLE:
268 self.move_to_trash_action = qtutils.add_action(
269 self,
270 N_('Move files to trash'),
271 self._trash_untracked_files,
272 hotkeys.TRASH,
274 self.move_to_trash_action.setIcon(icons.discard())
275 delete_shortcut = hotkeys.DELETE_FILE
276 else:
277 self.move_to_trash_action = None
278 delete_shortcut = hotkeys.DELETE_FILE_SECONDARY
280 self.delete_untracked_files_action = qtutils.add_action(
281 self, N_('Delete Files...'), self._delete_untracked_files, delete_shortcut
283 self.delete_untracked_files_action.setIcon(icons.discard())
285 about_to_update = self._about_to_update
286 self.about_to_update.connect(about_to_update, type=Qt.QueuedConnection)
287 self.updated.connect(self.refresh, type=Qt.QueuedConnection)
288 self.diff_text_changed.connect(
289 self._make_current_item_visible, type=Qt.QueuedConnection
292 self.m = context.model
293 self.m.add_observer(self.m.message_about_to_update, self.about_to_update.emit)
294 self.m.add_observer(self.m.message_updated, self.updated.emit)
295 self.m.add_observer(
296 self.m.message_diff_text_changed, self.diff_text_changed.emit
298 # pylint: disable=no-member
299 self.itemSelectionChanged.connect(self.show_selection)
300 self.itemDoubleClicked.connect(self._double_clicked)
301 self.itemCollapsed.connect(lambda x: self._update_column_widths())
302 self.itemExpanded.connect(lambda x: self._update_column_widths())
304 def _make_current_item_visible(self):
305 item = self.currentItem()
306 if item:
307 self.scroll_to_item(item)
309 def _add_toplevel_item(self, txt, icon, hide=False):
310 context = self.context
311 font = self.font()
312 if prefs.bold_headers(context):
313 font.setBold(True)
314 else:
315 font.setItalic(True)
317 item = QtWidgets.QTreeWidgetItem(self)
318 item.setFont(0, font)
319 item.setText(0, txt)
320 item.setIcon(0, icon)
321 if prefs.bold_headers(context):
322 item.setBackground(0, self.palette().midlight())
323 if hide:
324 item.setHidden(True)
326 def _restore_selection(self):
327 """Apply the old selection to the newly updated items"""
328 # This function is called after a new set of items have been added to
329 # the per-category file lists. Its purpose is to either restore the
330 # existing selection or to create a new intuitive selection based on
331 # a combination of the old items, the old selection and the new items.
332 if not self.old_selection or not self.old_contents:
333 return
334 # The old set of categorized files.
335 old_c = self.old_contents
336 # The old selection.
337 old_s = self.old_selection
338 # The current/new set of categorized files.
339 new_c = self.contents()
341 def mkselect(lst, widget_getter):
342 """Return a closure to select items in the specified category"""
343 def select(item, current=False):
344 """Select the widget item based on the list index"""
345 # The item lists and widget indexes have a 1:1 correspondence.
346 # Lookup the item filename in the list and use that index to
347 # retrieve the widget item and select it.
348 idx = lst.index(item)
349 item = widget_getter(idx)
350 if current:
351 self.setCurrentItem(item)
352 item.setSelected(True)
353 return select
355 select_staged = mkselect(new_c.staged, self._staged_item)
356 select_unmerged = mkselect(new_c.unmerged, self._unmerged_item)
357 select_modified = mkselect(new_c.modified, self._modified_item)
358 select_untracked = mkselect(new_c.untracked, self._untracked_item)
360 saved_selection = [
361 (set(new_c.staged), old_c.staged, set(old_s.staged), select_staged),
362 (set(new_c.unmerged), old_c.unmerged, set(old_s.unmerged), select_unmerged),
363 (set(new_c.modified), old_c.modified, set(old_s.modified), select_modified),
365 set(new_c.untracked),
366 old_c.untracked,
367 set(old_s.untracked),
368 select_untracked,
372 # Restore the current item
373 if self.old_current_item:
374 category, idx = self.old_current_item
375 if category == self.idx_header:
376 item = self.invisibleRootItem().child(idx)
377 if item is not None:
378 with qtutils.BlockSignals(self):
379 self.setCurrentItem(item)
380 item.setSelected(True)
381 self.show_selection()
382 return
383 # Reselect the current item
384 selection_info = saved_selection[category]
385 new = selection_info[0]
386 old = selection_info[1]
387 reselect = selection_info[3]
388 try:
389 item = old[idx]
390 except IndexError:
391 return
392 if item in new:
393 reselect(item, current=True)
395 # Restore selection
396 # When reselecting we only care that the items are selected;
397 # we do not need to rerun the callbacks which were triggered
398 # above. Block signals to skip the callbacks.
400 with qtutils.BlockSignals(self):
401 for (new, old, sel, reselect) in saved_selection:
402 for item in sel:
403 if item in new:
404 reselect(item, current=False)
406 # The status widget is used to interactively work your way down the
407 # list of Staged, Unmerged, Modified and Untracked items and perform
408 # an operation on them.
410 # For Staged items we intend to work our way down the list of Staged
411 # items while we unstage each item. For every other category we work
412 # our way down the list of {Unmerged,Modified,Untracked} items while
413 # we stage each item.
415 # The following block of code implements the behavior of selecting
416 # the next item based on the previous selection.
417 for (new, old, sel, reselect) in saved_selection:
418 # When modified is staged, select the next modified item
419 # When unmerged is staged, select the next unmerged item
420 # When unstaging, select the next staged item
421 # When staging untracked files, select the next untracked item
422 if len(new) >= len(old):
423 # The list did not shrink so it is not one of these cases.
424 continue
425 for item in sel:
426 # The item still exists so ignore it
427 if item in new or item not in old:
428 continue
429 # The item no longer exists in this list so search for
430 # its nearest neighbors and select them instead.
431 idx = old.index(item)
432 for j in itertools.chain(old[idx + 1 :], reversed(old[:idx])):
433 if j in new:
434 reselect(j, current=True)
435 return
437 def _restore_scrollbars(self):
438 vscroll = self.verticalScrollBar()
439 if vscroll and self.old_vscroll is not None:
440 vscroll.setValue(self.old_vscroll)
441 self.old_vscroll = None
443 hscroll = self.horizontalScrollBar()
444 if hscroll and self.old_hscroll is not None:
445 hscroll.setValue(self.old_hscroll)
446 self.old_hscroll = None
448 def _stage_selection(self):
449 """Stage or unstage files according to the selection"""
450 context = self.context
451 selected_indexes = self.selected_indexes()
452 is_header = any(
453 category == self.idx_header for (category, idx) in selected_indexes
455 if is_header:
456 is_staged = any(
457 idx == self.idx_staged and category == self.idx_header
458 for (category, idx) in selected_indexes
460 is_modified = any(
461 idx == self.idx_modified and category == self.idx_header
462 for (category, idx) in selected_indexes
464 is_untracked = any(
465 idx == self.idx_untracked and category == self.idx_header
466 for (category, idx) in selected_indexes
468 # A header item: 'Staged', 'Modified' or 'Untracked'.
469 if is_staged:
470 # If we have the staged header selected then the only sensible
471 # thing to do is to unstage everything and nothing else, even
472 # if the modified or untracked headers are selected.
473 cmds.do(cmds.UnstageAll, context)
474 return # Everything was unstaged. There's nothing more to be done.
475 elif is_modified and is_untracked:
476 # If both modified and untracked headers are selected then
477 # stage everything.
478 cmds.do(cmds.StageModifiedAndUntracked, context)
479 return # Nothing more to do.
480 # At this point we may stage all modified and untracked, and then
481 # possibly a subset of the other category (eg. all modified and
482 # some untracked). We don't return here so that StageOrUnstage
483 # gets a chance to run below.
484 elif is_modified:
485 cmds.do(cmds.StageModified, context)
486 elif is_untracked:
487 cmds.do(cmds.StageUntracked, context)
488 else:
489 # Do nothing for unmerged items, by design
490 pass
491 # Now handle individual files
492 cmds.do(cmds.StageOrUnstage, context)
494 def _staged_item(self, itemidx):
495 return self._subtree_item(self.idx_staged, itemidx)
497 def _modified_item(self, itemidx):
498 return self._subtree_item(self.idx_modified, itemidx)
500 def _unmerged_item(self, itemidx):
501 return self._subtree_item(self.idx_unmerged, itemidx)
503 def _untracked_item(self, itemidx):
504 return self._subtree_item(self.idx_untracked, itemidx)
506 def _unstaged_item(self, itemidx):
507 # is it modified?
508 item = self.topLevelItem(self.idx_modified)
509 count = item.childCount()
510 if itemidx < count:
511 return item.child(itemidx)
512 # is it unmerged?
513 item = self.topLevelItem(self.idx_unmerged)
514 count += item.childCount()
515 if itemidx < count:
516 return item.child(itemidx)
517 # is it untracked?
518 item = self.topLevelItem(self.idx_untracked)
519 count += item.childCount()
520 if itemidx < count:
521 return item.child(itemidx)
522 # Nope..
523 return None
525 def _subtree_item(self, idx, itemidx):
526 parent = self.topLevelItem(idx)
527 return parent.child(itemidx)
529 def _about_to_update(self):
530 self._save_scrollbars()
531 self._save_selection()
533 def _save_scrollbars(self):
534 vscroll = self.verticalScrollBar()
535 if vscroll:
536 self.old_vscroll = get(vscroll)
538 hscroll = self.horizontalScrollBar()
539 if hscroll:
540 self.old_hscroll = get(hscroll)
542 def current_item(self):
543 s = self.selected_indexes()
544 if not s:
545 return None
546 current = self.currentItem()
547 if not current:
548 return None
549 idx = self.indexFromItem(current)
550 if idx.parent().isValid():
551 parent_idx = idx.parent()
552 entry = (parent_idx.row(), idx.row())
553 else:
554 entry = (self.idx_header, idx.row())
555 return entry
557 def _save_selection(self):
558 self.old_contents = self.contents()
559 self.old_selection = self.selection()
560 self.old_current_item = self.current_item()
562 def refresh(self):
563 self._set_staged(self.m.staged)
564 self._set_modified(self.m.modified)
565 self._set_unmerged(self.m.unmerged)
566 self._set_untracked(self.m.untracked)
567 self._update_column_widths()
568 self._update_actions()
569 self._restore_selection()
570 self._restore_scrollbars()
572 def _update_actions(self, selected=None):
573 if selected is None:
574 selected = self.selection_model.selection()
575 can_revert_edits = bool(selected.staged or selected.modified)
576 self.revert_unstaged_edits_action.setEnabled(can_revert_edits)
578 def _set_staged(self, items):
579 """Adds items to the 'Staged' subtree."""
580 with qtutils.BlockSignals(self):
581 self._set_subtree(
582 items,
583 self.idx_staged,
584 N_('Staged'),
585 staged=True,
586 deleted_set=self.m.staged_deleted,
589 def _set_modified(self, items):
590 """Adds items to the 'Modified' subtree."""
591 with qtutils.BlockSignals(self):
592 self._set_subtree(
593 items,
594 self.idx_modified,
595 N_('Modified'),
596 deleted_set=self.m.unstaged_deleted,
599 def _set_unmerged(self, items):
600 """Adds items to the 'Unmerged' subtree."""
601 deleted_set = set([path for path in items if not core.exists(path)])
602 with qtutils.BlockSignals(self):
603 self._set_subtree(
604 items, self.idx_unmerged, N_('Unmerged'), deleted_set=deleted_set
607 def _set_untracked(self, items):
608 """Adds items to the 'Untracked' subtree."""
609 with qtutils.BlockSignals(self):
610 self._set_subtree(
611 items, self.idx_untracked, N_('Untracked'), untracked=True
614 def _set_subtree(
615 self, items, idx, parent_title, staged=False, untracked=False, deleted_set=None
617 """Add a list of items to a treewidget item."""
618 parent = self.topLevelItem(idx)
619 hide = not bool(items)
620 parent.setHidden(hide)
622 # sip v4.14.7 and below leak memory in parent.takeChildren()
623 # so we use this backwards-compatible construct instead
624 while parent.takeChild(0) is not None:
625 pass
627 for item in items:
628 deleted = deleted_set is not None and item in deleted_set
629 treeitem = qtutils.create_treeitem(
630 item, staged=staged, deleted=deleted, untracked=untracked
632 parent.addChild(treeitem)
633 self._expand_items(idx, items)
635 if prefs.status_show_totals(self.context):
636 parent.setText(0, '%s (%s)' % (parent_title, len(items)))
638 def _update_column_widths(self):
639 self.resizeColumnToContents(0)
641 def _expand_items(self, idx, items):
642 """Expand the top-level category "folder" once and only once."""
643 # Don't do this if items is empty; this makes it so that we
644 # don't add the top-level index into the expanded_items set
645 # until an item appears in a particular category.
646 if not items:
647 return
648 # Only run this once; we don't want to re-expand items that
649 # we've clicked on to re-collapse on updated().
650 if idx in self.expanded_items:
651 return
652 self.expanded_items.add(idx)
653 item = self.topLevelItem(idx)
654 if item:
655 self.expandItem(item)
657 def contextMenuEvent(self, event):
658 """Create context menus for the repo status tree."""
659 menu = self._create_context_menu()
660 menu.exec_(self.mapToGlobal(event.pos()))
662 def _create_context_menu(self):
663 """Set up the status menu for the repo status tree."""
664 s = self.selection()
665 menu = qtutils.create_menu('Status', self)
666 selected_indexes = self.selected_indexes()
667 if selected_indexes:
668 category, idx = selected_indexes[0]
669 # A header item e.g. 'Staged', 'Modified', etc.
670 if category == self.idx_header:
671 return self._create_header_context_menu(menu, idx)
673 if s.staged:
674 self._create_staged_context_menu(menu, s)
675 elif s.unmerged:
676 self._create_unmerged_context_menu(menu, s)
677 else:
678 self._create_unstaged_context_menu(menu, s)
680 if not utils.is_win32():
681 if not menu.isEmpty():
682 menu.addSeparator()
683 if not self.selection_model.is_empty():
684 menu.addAction(self.default_app_action)
685 menu.addAction(self.parent_dir_action)
687 if self.terminal_action is not None:
688 menu.addAction(self.terminal_action)
690 self._add_copy_actions(menu)
692 return menu
694 def _add_copy_actions(self, menu):
695 """Add the "Copy" sub-menu"""
696 enabled = self.selection_model.filename() is not None
697 self.copy_path_action.setEnabled(enabled)
698 self.copy_relpath_action.setEnabled(enabled)
699 self.copy_leading_path_action.setEnabled(enabled)
700 self.copy_basename_action.setEnabled(enabled)
701 copy_icon = icons.copy()
703 menu.addSeparator()
704 copy_menu = QtWidgets.QMenu(N_('Copy...'), menu)
705 menu.addMenu(copy_menu)
707 copy_menu.setIcon(copy_icon)
708 copy_menu.addAction(self.copy_path_action)
709 copy_menu.addAction(self.copy_relpath_action)
710 copy_menu.addAction(self.copy_leading_path_action)
711 copy_menu.addAction(self.copy_basename_action)
713 settings = Settings.read()
714 copy_formats = settings.copy_formats
715 if copy_formats:
716 copy_menu.addSeparator()
718 context = self.context
719 for entry in copy_formats:
720 name = entry.get('name', '')
721 fmt = entry.get('format', '')
722 if name and fmt:
723 action = copy_menu.addAction(name, partial(copy_format, context, fmt))
724 action.setIcon(copy_icon)
725 action.setEnabled(enabled)
727 copy_menu.addSeparator()
728 copy_menu.addAction(self.copy_customize_action)
730 def _create_header_context_menu(self, menu, idx):
731 context = self.context
732 if idx == self.idx_staged:
733 menu.addAction(
734 icons.remove(), N_('Unstage All'), cmds.run(cmds.UnstageAll, context)
736 elif idx == self.idx_unmerged:
737 action = menu.addAction(
738 icons.add(),
739 cmds.StageUnmerged.name(),
740 cmds.run(cmds.StageUnmerged, context),
742 action.setShortcut(hotkeys.STAGE_SELECTION)
743 elif idx == self.idx_modified:
744 action = menu.addAction(
745 icons.add(),
746 cmds.StageModified.name(),
747 cmds.run(cmds.StageModified, context),
749 action.setShortcut(hotkeys.STAGE_SELECTION)
750 elif idx == self.idx_untracked:
751 action = menu.addAction(
752 icons.add(),
753 cmds.StageUntracked.name(),
754 cmds.run(cmds.StageUntracked, context),
756 action.setShortcut(hotkeys.STAGE_SELECTION)
757 return menu
759 def _create_staged_context_menu(self, menu, s):
760 if s.staged[0] in self.m.submodules:
761 return self._create_staged_submodule_context_menu(menu, s)
763 context = self.context
764 if self.m.unstageable():
765 action = menu.addAction(
766 icons.remove(),
767 N_('Unstage Selected'),
768 cmds.run(cmds.Unstage, context, self.staged()),
770 action.setShortcut(hotkeys.STAGE_SELECTION)
772 menu.addAction(self.launch_editor_action)
774 # Do all of the selected items exist?
775 all_exist = all(
776 i not in self.m.staged_deleted and core.exists(i) for i in self.staged()
779 if all_exist:
780 menu.addAction(self.launch_difftool_action)
782 if self.m.undoable():
783 menu.addAction(self.revert_unstaged_edits_action)
785 menu.addAction(self.view_history_action)
786 menu.addAction(self.view_blame_action)
787 return menu
789 def _create_staged_submodule_context_menu(self, menu, s):
790 context = self.context
791 path = core.abspath(s.staged[0])
792 if len(self.staged()) == 1:
793 menu.addAction(
794 icons.cola(),
795 N_('Launch git-cola'),
796 cmds.run(cmds.OpenRepo, context, path),
798 menu.addSeparator()
799 action = menu.addAction(
800 icons.remove(),
801 N_('Unstage Selected'),
802 cmds.run(cmds.Unstage, context, self.staged()),
804 action.setShortcut(hotkeys.STAGE_SELECTION)
806 menu.addAction(self.view_history_action)
807 return menu
809 def _create_unmerged_context_menu(self, menu, _s):
810 context = self.context
811 menu.addAction(self.launch_difftool_action)
813 action = menu.addAction(
814 icons.add(),
815 N_('Stage Selected'),
816 cmds.run(cmds.Stage, context, self.unstaged()),
818 action.setShortcut(hotkeys.STAGE_SELECTION)
820 menu.addAction(self.launch_editor_action)
821 menu.addAction(self.view_history_action)
822 menu.addAction(self.view_blame_action)
823 return menu
825 def _create_unstaged_context_menu(self, menu, s):
826 context = self.context
827 modified_submodule = s.modified and s.modified[0] in self.m.submodules
828 if modified_submodule:
829 return self._create_modified_submodule_context_menu(menu, s)
831 if self.m.stageable():
832 action = menu.addAction(
833 icons.add(),
834 N_('Stage Selected'),
835 cmds.run(cmds.Stage, context, self.unstaged()),
837 action.setShortcut(hotkeys.STAGE_SELECTION)
839 if not self.selection_model.is_empty():
840 menu.addAction(self.launch_editor_action)
842 # Do all of the selected items exist?
843 all_exist = all(
844 i not in self.m.unstaged_deleted and core.exists(i) for i in self.staged()
847 if all_exist and s.modified and self.m.stageable():
848 menu.addAction(self.launch_difftool_action)
850 if s.modified and self.m.stageable():
851 if self.m.undoable():
852 menu.addSeparator()
853 menu.addAction(self.revert_unstaged_edits_action)
855 if all_exist and s.untracked:
856 # Git Annex / Git LFS
857 annex = self.m.annex
858 lfs = core.find_executable('git-lfs')
859 if annex or lfs:
860 menu.addSeparator()
861 if annex:
862 menu.addAction(self.annex_add_action)
863 if lfs:
864 menu.addAction(self.lfs_track_action)
866 menu.addSeparator()
867 if self.move_to_trash_action is not None:
868 menu.addAction(self.move_to_trash_action)
869 menu.addAction(self.delete_untracked_files_action)
870 menu.addSeparator()
871 menu.addAction(
872 icons.edit(),
873 N_('Ignore...'),
874 partial(gitignore.gitignore_view, self.context),
877 if not self.selection_model.is_empty():
878 menu.addAction(self.view_history_action)
879 menu.addAction(self.view_blame_action)
880 return menu
882 def _create_modified_submodule_context_menu(self, menu, s):
883 context = self.context
884 path = core.abspath(s.modified[0])
885 if len(self.unstaged()) == 1:
886 menu.addAction(
887 icons.cola(),
888 N_('Launch git-cola'),
889 cmds.run(cmds.OpenRepo, context, path),
891 menu.addAction(
892 icons.pull(),
893 N_('Update this submodule'),
894 cmds.run(cmds.SubmoduleUpdate, context, path),
896 menu.addSeparator()
898 if self.m.stageable():
899 menu.addSeparator()
900 action = menu.addAction(
901 icons.add(),
902 N_('Stage Selected'),
903 cmds.run(cmds.Stage, context, self.unstaged()),
905 action.setShortcut(hotkeys.STAGE_SELECTION)
907 menu.addAction(self.view_history_action)
908 return menu
910 def _delete_untracked_files(self):
911 cmds.do(cmds.Delete, self.context, self.untracked())
913 def _trash_untracked_files(self):
914 cmds.do(cmds.MoveToTrash, self.context, self.untracked())
916 def selected_path(self):
917 s = self.single_selection()
918 return s.staged or s.unmerged or s.modified or s.untracked or None
920 def single_selection(self):
921 """Scan across staged, modified, etc. and return a single item."""
922 staged = None
923 unmerged = None
924 modified = None
925 untracked = None
927 s = self.selection()
928 if s.staged:
929 staged = s.staged[0]
930 elif s.unmerged:
931 unmerged = s.unmerged[0]
932 elif s.modified:
933 modified = s.modified[0]
934 elif s.untracked:
935 untracked = s.untracked[0]
937 return selection.State(staged, unmerged, modified, untracked)
939 def selected_indexes(self):
940 """Returns a list of (category, row) representing the tree selection."""
941 selected = self.selectedIndexes()
942 result = []
943 for idx in selected:
944 if idx.parent().isValid():
945 parent_idx = idx.parent()
946 entry = (parent_idx.row(), idx.row())
947 else:
948 entry = (self.idx_header, idx.row())
949 result.append(entry)
950 return result
952 def selection(self):
953 """Return the current selection in the repo status tree."""
954 return selection.State(
955 self.staged(), self.unmerged(), self.modified(), self.untracked()
958 def contents(self):
959 return selection.State(
960 self.m.staged, self.m.unmerged, self.m.modified, self.m.untracked
963 def all_files(self):
964 c = self.contents()
965 return c.staged + c.unmerged + c.modified + c.untracked
967 def selected_group(self):
968 """A list of selected files in various states of being"""
969 return selection.pick(self.selection())
971 def selected_idx(self):
972 c = self.contents()
973 s = self.single_selection()
974 offset = 0
975 for content, sel in zip(c, s):
976 if not content:
977 continue
978 if sel is not None:
979 return offset + content.index(sel)
980 offset += len(content)
981 return None
983 def select_by_index(self, idx):
984 c = self.contents()
985 to_try = [
986 (c.staged, self.idx_staged),
987 (c.unmerged, self.idx_unmerged),
988 (c.modified, self.idx_modified),
989 (c.untracked, self.idx_untracked),
991 for content, toplevel_idx in to_try:
992 if not content:
993 continue
994 if idx < len(content):
995 parent = self.topLevelItem(toplevel_idx)
996 item = parent.child(idx)
997 if item is not None:
998 self.select_item(item)
999 return
1000 idx -= len(content)
1002 def scroll_to_item(self, item):
1003 # First, scroll to the item, but keep the original hscroll
1004 hscroll = None
1005 hscrollbar = self.horizontalScrollBar()
1006 if hscrollbar:
1007 hscroll = get(hscrollbar)
1008 self.scrollToItem(item)
1009 if hscroll is not None:
1010 hscrollbar.setValue(hscroll)
1012 def select_item(self, item):
1013 self.scroll_to_item(item)
1014 self.setCurrentItem(item)
1015 item.setSelected(True)
1017 def staged(self):
1018 return self._subtree_selection(self.idx_staged, self.m.staged)
1020 def unstaged(self):
1021 return self.unmerged() + self.modified() + self.untracked()
1023 def modified(self):
1024 return self._subtree_selection(self.idx_modified, self.m.modified)
1026 def unmerged(self):
1027 return self._subtree_selection(self.idx_unmerged, self.m.unmerged)
1029 def untracked(self):
1030 return self._subtree_selection(self.idx_untracked, self.m.untracked)
1032 def staged_items(self):
1033 return self._subtree_selection_items(self.idx_staged)
1035 def unstaged_items(self):
1036 return self.unmerged_items() + self.modified_items() + self.untracked_items()
1038 def modified_items(self):
1039 return self._subtree_selection_items(self.idx_modified)
1041 def unmerged_items(self):
1042 return self._subtree_selection_items(self.idx_unmerged)
1044 def untracked_items(self):
1045 return self._subtree_selection_items(self.idx_untracked)
1047 def _subtree_selection(self, idx, items):
1048 item = self.topLevelItem(idx)
1049 return qtutils.tree_selection(item, items)
1051 def _subtree_selection_items(self, idx):
1052 item = self.topLevelItem(idx)
1053 return qtutils.tree_selection_items(item)
1055 def _double_clicked(self, _item, _idx):
1056 """Called when an item is double-clicked in the repo status tree."""
1057 cmds.do(cmds.StageOrUnstage, self.context)
1059 def show_selection(self):
1060 """Show the selected item."""
1061 context = self.context
1062 self.scroll_to_item(self.currentItem())
1063 # Sync the selection model
1064 selected = self.selection()
1065 selection_model = self.selection_model
1066 selection_model.set_selection(selected)
1067 self._update_actions(selected=selected)
1069 selected_indexes = self.selected_indexes()
1070 if not selected_indexes:
1071 if self.m.amending():
1072 cmds.do(cmds.SetDiffText, context, '')
1073 else:
1074 cmds.do(cmds.ResetMode, context)
1075 return
1077 # A header item e.g. 'Staged', 'Modified', etc.
1078 category, idx = selected_indexes[0]
1079 header = category == self.idx_header
1080 if header:
1081 cls = {
1082 self.idx_staged: cmds.DiffStagedSummary,
1083 self.idx_modified: cmds.Diffstat,
1084 # TODO implement UnmergedSummary
1085 # self.idx_unmerged: cmds.UnmergedSummary,
1086 self.idx_untracked: cmds.UntrackedSummary,
1087 }.get(idx, cmds.Diffstat)
1088 cmds.do(cls, context)
1089 return
1091 staged = category == self.idx_staged
1092 modified = category == self.idx_modified
1093 unmerged = category == self.idx_unmerged
1094 untracked = category == self.idx_untracked
1096 if staged:
1097 item = self.staged_items()[0]
1098 elif unmerged:
1099 item = self.unmerged_items()[0]
1100 elif modified:
1101 item = self.modified_items()[0]
1102 elif untracked:
1103 item = self.unstaged_items()[0]
1104 else:
1105 item = None # this shouldn't happen
1106 assert item is not None
1108 path = item.path
1109 deleted = item.deleted
1110 image = self.image_formats.ok(path)
1112 # Update the diff text
1113 if staged:
1114 cmds.do(cmds.DiffStaged, context, path, deleted=deleted)
1115 elif modified:
1116 cmds.do(cmds.Diff, context, path, deleted=deleted)
1117 elif unmerged:
1118 cmds.do(cmds.Diff, context, path)
1119 elif untracked:
1120 cmds.do(cmds.ShowUntracked, context, path)
1122 # Images are diffed differently.
1123 # DiffImage transitions the diff mode to image.
1124 # DiffText transitions the diff mode to text.
1125 if image:
1126 cmds.do(
1127 cmds.DiffImage,
1128 context,
1129 path,
1130 deleted,
1131 staged,
1132 modified,
1133 unmerged,
1134 untracked,
1136 else:
1137 cmds.do(cmds.DiffText, context)
1139 def select_header(self):
1140 """Select an active header, which triggers a diffstat"""
1141 for idx in (
1142 self.idx_staged,
1143 self.idx_unmerged,
1144 self.idx_modified,
1145 self.idx_untracked,
1147 item = self.topLevelItem(idx)
1148 if item.childCount() > 0:
1149 self.clearSelection()
1150 self.setCurrentItem(item)
1151 return
1153 def move_up(self):
1154 idx = self.selected_idx()
1155 all_files = self.all_files()
1156 if idx is None:
1157 selected_indexes = self.selected_indexes()
1158 if selected_indexes:
1159 category, toplevel_idx = selected_indexes[0]
1160 if category == self.idx_header:
1161 item = self.itemAbove(self.topLevelItem(toplevel_idx))
1162 if item is not None:
1163 self.select_item(item)
1164 return
1165 if all_files:
1166 self.select_by_index(len(all_files) - 1)
1167 return
1168 if idx - 1 >= 0:
1169 self.select_by_index(idx - 1)
1170 else:
1171 self.select_by_index(len(all_files) - 1)
1173 def move_down(self):
1174 idx = self.selected_idx()
1175 all_files = self.all_files()
1176 if idx is None:
1177 selected_indexes = self.selected_indexes()
1178 if selected_indexes:
1179 category, toplevel_idx = selected_indexes[0]
1180 if category == self.idx_header:
1181 item = self.itemBelow(self.topLevelItem(toplevel_idx))
1182 if item is not None:
1183 self.select_item(item)
1184 return
1185 if all_files:
1186 self.select_by_index(0)
1187 return
1188 if idx + 1 < len(all_files):
1189 self.select_by_index(idx + 1)
1190 else:
1191 self.select_by_index(0)
1193 def mimeData(self, items):
1194 """Return a list of absolute-path URLs"""
1195 context = self.context
1196 paths = qtutils.paths_from_items(items, item_filter=_item_filter)
1197 return qtutils.mimedata_from_paths(context, paths)
1199 # pylint: disable=no-self-use
1200 def mimeTypes(self):
1201 return qtutils.path_mimetypes()
1204 def _item_filter(item):
1205 return not item.deleted and core.exists(item.path)
1208 def view_blame(context):
1209 """Signal that we should view blame for paths."""
1210 cmds.do(cmds.BlamePaths, context)
1213 def view_history(context):
1214 """Signal that we should view history for paths."""
1215 cmds.do(cmds.VisualizePaths, context, context.selection.union())
1218 def copy_path(context, absolute=True):
1219 """Copy a selected path to the clipboard"""
1220 filename = context.selection.filename()
1221 qtutils.copy_path(filename, absolute=absolute)
1224 def copy_relpath(context):
1225 """Copy a selected relative path to the clipboard"""
1226 copy_path(context, absolute=False)
1229 def copy_basename(context):
1230 filename = os.path.basename(context.selection.filename())
1231 basename, _ = os.path.splitext(filename)
1232 qtutils.copy_path(basename, absolute=False)
1235 def copy_leading_path(context):
1236 """Copy the selected leading path to the clipboard"""
1237 filename = context.selection.filename()
1238 dirname = os.path.dirname(filename)
1239 qtutils.copy_path(dirname, absolute=False)
1242 def copy_format(context, fmt):
1243 values = {}
1244 values['path'] = path = context.selection.filename()
1245 values['abspath'] = abspath = os.path.abspath(path)
1246 values['absdirname'] = os.path.dirname(abspath)
1247 values['dirname'] = os.path.dirname(path)
1248 values['filename'] = os.path.basename(path)
1249 values['basename'], values['ext'] = os.path.splitext(os.path.basename(path))
1250 qtutils.set_clipboard(fmt % values)
1253 def show_help(context):
1254 help_text = N_(
1255 r"""
1256 Format String Variables
1257 -----------------------
1258 %(path)s = relative file path
1259 %(abspath)s = absolute file path
1260 %(dirname)s = relative directory path
1261 %(absdirname)s = absolute directory path
1262 %(filename)s = file basename
1263 %(basename)s = file basename without extension
1264 %(ext)s = file extension
1267 title = N_('Help - Custom Copy Actions')
1268 return text.text_dialog(context, help_text, title)
1271 class StatusFilterWidget(QtWidgets.QWidget):
1272 def __init__(self, context, parent=None):
1273 QtWidgets.QWidget.__init__(self, parent)
1274 self.context = context
1276 hint = N_('Filter paths...')
1277 self.text = completion.GitStatusFilterLineEdit(context, hint=hint, parent=self)
1278 self.text.setToolTip(hint)
1279 self.setFocusProxy(self.text)
1280 self._filter = None
1282 self.main_layout = qtutils.hbox(defs.no_margin, defs.spacing, self.text)
1283 self.setLayout(self.main_layout)
1285 widget = self.text
1286 # pylint: disable=no-member
1287 widget.changed.connect(self.apply_filter)
1288 widget.cleared.connect(self.apply_filter)
1289 widget.enter.connect(self.apply_filter)
1290 widget.editingFinished.connect(self.apply_filter)
1292 def apply_filter(self):
1293 value = get(self.text)
1294 if value == self._filter:
1295 return
1296 self._filter = value
1297 paths = utils.shell_split(value)
1298 self.context.model.update_path_filter(paths)
1301 def customize_copy_actions(context, parent):
1302 """Customize copy actions"""
1303 dialog = CustomizeCopyActions(context, parent)
1304 dialog.show()
1305 dialog.exec_()
1308 class CustomizeCopyActions(standard.Dialog):
1309 def __init__(self, context, parent):
1310 standard.Dialog.__init__(self, parent=parent)
1311 self.setWindowTitle(N_('Custom Copy Actions'))
1313 self.context = context
1314 self.table = QtWidgets.QTableWidget(self)
1315 self.table.setColumnCount(2)
1316 self.table.setHorizontalHeaderLabels([N_('Action Name'), N_('Format String')])
1317 self.table.setSortingEnabled(False)
1318 self.table.verticalHeader().hide()
1319 self.table.horizontalHeader().setStretchLastSection(True)
1321 self.add_button = qtutils.create_button(N_('Add'))
1322 self.remove_button = qtutils.create_button(N_('Remove'))
1323 self.remove_button.setEnabled(False)
1324 self.show_help_button = qtutils.create_button(N_('Show Help'))
1325 self.show_help_button.setShortcut(hotkeys.QUESTION)
1327 self.close_button = qtutils.close_button()
1328 self.save_button = qtutils.ok_button(N_('Save'))
1330 self.buttons = qtutils.hbox(
1331 defs.no_margin,
1332 defs.button_spacing,
1333 self.add_button,
1334 self.remove_button,
1335 self.show_help_button,
1336 qtutils.STRETCH,
1337 self.close_button,
1338 self.save_button,
1341 layout = qtutils.vbox(defs.margin, defs.spacing, self.table, self.buttons)
1342 self.setLayout(layout)
1344 qtutils.connect_button(self.add_button, self.add)
1345 qtutils.connect_button(self.remove_button, self.remove)
1346 qtutils.connect_button(self.show_help_button, partial(show_help, context))
1347 qtutils.connect_button(self.close_button, self.reject)
1348 qtutils.connect_button(self.save_button, self.save)
1349 qtutils.add_close_action(self)
1350 # pylint: disable=no-member
1351 self.table.itemSelectionChanged.connect(self.table_selection_changed)
1353 self.init_size(parent=parent)
1355 QtCore.QTimer.singleShot(0, self.reload_settings)
1357 def reload_settings(self):
1358 # Called once after the GUI is initialized
1359 settings = self.context.settings
1360 settings.load()
1361 table = self.table
1362 for entry in settings.copy_formats:
1363 name_string = entry.get('name', '')
1364 format_string = entry.get('format', '')
1365 if name_string and format_string:
1366 name = QtWidgets.QTableWidgetItem(name_string)
1367 fmt = QtWidgets.QTableWidgetItem(format_string)
1368 rows = table.rowCount()
1369 table.setRowCount(rows + 1)
1370 table.setItem(rows, 0, name)
1371 table.setItem(rows, 1, fmt)
1373 def export_state(self):
1374 state = super(CustomizeCopyActions, self).export_state()
1375 standard.export_header_columns(self.table, state)
1376 return state
1378 def apply_state(self, state):
1379 result = super(CustomizeCopyActions, self).apply_state(state)
1380 standard.apply_header_columns(self.table, state)
1381 return result
1383 def add(self):
1384 self.table.setFocus()
1385 rows = self.table.rowCount()
1386 self.table.setRowCount(rows + 1)
1388 name = QtWidgets.QTableWidgetItem(N_('Name'))
1389 fmt = QtWidgets.QTableWidgetItem(r'%(path)s')
1390 self.table.setItem(rows, 0, name)
1391 self.table.setItem(rows, 1, fmt)
1393 self.table.setCurrentCell(rows, 0)
1394 self.table.editItem(name)
1396 def remove(self):
1397 """Remove selected items"""
1398 # Gather a unique set of rows and remove them in reverse order
1399 rows = set()
1400 items = self.table.selectedItems()
1401 for item in items:
1402 rows.add(self.table.row(item))
1404 for row in reversed(sorted(rows)):
1405 self.table.removeRow(row)
1407 def save(self):
1408 copy_formats = []
1409 for row in range(self.table.rowCount()):
1410 name = self.table.item(row, 0)
1411 fmt = self.table.item(row, 1)
1412 if name and fmt:
1413 entry = {
1414 'name': name.text(),
1415 'format': fmt.text(),
1417 copy_formats.append(entry)
1419 settings = self.context.settings
1420 while settings.copy_formats:
1421 settings.copy_formats.pop()
1423 settings.copy_formats.extend(copy_formats)
1424 settings.save()
1426 self.accept()
1428 def table_selection_changed(self):
1429 items = self.table.selectedItems()
1430 self.remove_button.setEnabled(bool(items))