models: emit a notification with model contents
[git-cola.git] / cola / widgets / status.py
blob26f2c51b78c57c57fa956cc396583b8b280fbc57
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 # Top-level status widget item indexes.
32 HEADER_IDX = -1
33 STAGED_IDX = 0
34 UNMERGED_IDX = 1
35 MODIFIED_IDX = 2
36 UNTRACKED_IDX = 3
37 END_IDX = 4
39 # Indexes into the saved_selection entries.
40 NEW_PATHS_IDX = 0
41 OLD_PATHS_IDX = 1
42 SELECTION_IDX = 2
43 SELECT_FN_IDX = 3
46 class StatusWidget(QtWidgets.QFrame):
47 """
48 Provides a git-status-like repository widget.
50 This widget observes the main model and broadcasts
51 Qt signals.
53 """
55 def __init__(self, context, titlebar, parent):
56 QtWidgets.QFrame.__init__(self, parent)
57 self.context = context
59 tooltip = N_('Toggle the paths filter')
60 icon = icons.ellipsis()
61 self.filter_button = qtutils.create_action_button(tooltip=tooltip, icon=icon)
62 self.filter_widget = StatusFilterWidget(context)
63 self.filter_widget.hide()
64 self.tree = StatusTreeWidget(context, parent=self)
65 self.setFocusProxy(self.tree)
67 self.main_layout = qtutils.vbox(
68 defs.no_margin, defs.no_spacing, self.filter_widget, self.tree
70 self.setLayout(self.main_layout)
72 self.toggle_action = qtutils.add_action(
73 self, tooltip, self.toggle_filter, hotkeys.FILTER
76 titlebar.add_corner_widget(self.filter_button)
77 qtutils.connect_button(self.filter_button, self.toggle_filter)
79 def toggle_filter(self):
80 shown = not self.filter_widget.isVisible()
81 self.filter_widget.setVisible(shown)
82 if shown:
83 self.filter_widget.setFocus()
84 else:
85 self.tree.setFocus()
87 def set_initial_size(self):
88 self.setMaximumWidth(222)
89 QtCore.QTimer.singleShot(1, lambda: self.setMaximumWidth(2 ** 13))
91 def refresh(self):
92 self.tree.show_selection()
94 def set_filter(self, txt):
95 self.filter_widget.setVisible(True)
96 self.filter_widget.text.set_value(txt)
97 self.filter_widget.apply_filter()
99 def move_up(self):
100 self.tree.move_up()
102 def move_down(self):
103 self.tree.move_down()
105 def select_header(self):
106 self.tree.select_header()
109 # pylint: disable=too-many-ancestors
110 class StatusTreeWidget(QtWidgets.QTreeWidget):
111 # Signals
112 about_to_update = Signal()
113 set_previous_contents = Signal(list, list, list, list)
114 updated = Signal()
115 diff_text_changed = Signal()
117 # Read-only access to the mode state
118 mode = property(lambda self: self.m.mode)
120 def __init__(self, context, parent=None):
121 QtWidgets.QTreeWidget.__init__(self, parent)
122 self.context = context
123 self.selection_model = context.selection
125 self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
126 self.headerItem().setHidden(True)
127 self.setAllColumnsShowFocus(True)
128 self.setSortingEnabled(False)
129 self.setUniformRowHeights(True)
130 self.setAnimated(True)
131 self.setRootIsDecorated(False)
132 self.setDragEnabled(True)
133 self.setAutoScroll(False)
135 if not prefs.status_indent(context):
136 self.setIndentation(0)
138 ok = icons.ok()
139 compare = icons.compare()
140 question = icons.question()
141 self._add_toplevel_item(N_('Staged'), ok, hide=True)
142 self._add_toplevel_item(N_('Unmerged'), compare, hide=True)
143 self._add_toplevel_item(N_('Modified'), compare, hide=True)
144 self._add_toplevel_item(N_('Untracked'), question, hide=True)
146 # Used to restore the selection
147 self.old_vscroll = None
148 self.old_hscroll = None
149 self.old_selection = None
150 self.old_contents = None
151 self.old_current_item = None
152 self.previous_contents = None
153 self.was_visible = True
154 self.expanded_items = set()
156 self.image_formats = qtutils.ImageFormats()
158 self.process_selection_action = qtutils.add_action(
159 self,
160 cmds.StageOrUnstage.name(),
161 self._stage_selection,
162 hotkeys.STAGE_SELECTION,
164 self.process_selection_action.setIcon(icons.add())
166 self.stage_or_unstage_all_action = qtutils.add_action(
167 self,
168 cmds.StageOrUnstageAll.name(),
169 cmds.run(cmds.StageOrUnstageAll, self.context),
170 hotkeys.STAGE_ALL,
172 self.stage_or_unstage_all_action.setIcon(icons.add())
174 self.revert_unstaged_edits_action = qtutils.add_action(
175 self,
176 cmds.RevertUnstagedEdits.name(),
177 cmds.run(cmds.RevertUnstagedEdits, context),
178 hotkeys.REVERT,
180 self.revert_unstaged_edits_action.setIcon(icons.undo())
182 self.launch_difftool_action = qtutils.add_action(
183 self,
184 cmds.LaunchDifftool.name(),
185 cmds.run(cmds.LaunchDifftool, context),
186 hotkeys.DIFF,
188 self.launch_difftool_action.setIcon(icons.diff())
190 self.launch_editor_action = actions.launch_editor_at_line(
191 context, self, *hotkeys.ACCEPT
194 if not utils.is_win32():
195 self.default_app_action = common.default_app_action(
196 context, self, self.selected_group
199 self.parent_dir_action = common.parent_dir_action(
200 context, self, self.selected_group
203 self.terminal_action = common.terminal_action(
204 context, self, self.selected_group
207 self.up_action = qtutils.add_action(
208 self,
209 N_('Move Up'),
210 self.move_up,
211 hotkeys.MOVE_UP,
212 hotkeys.MOVE_UP_SECONDARY,
215 self.down_action = qtutils.add_action(
216 self,
217 N_('Move Down'),
218 self.move_down,
219 hotkeys.MOVE_DOWN,
220 hotkeys.MOVE_DOWN_SECONDARY,
223 self.copy_path_action = qtutils.add_action(
224 self,
225 N_('Copy Path to Clipboard'),
226 partial(copy_path, context),
227 hotkeys.COPY,
229 self.copy_path_action.setIcon(icons.copy())
231 self.copy_relpath_action = qtutils.add_action(
232 self,
233 N_('Copy Relative Path to Clipboard'),
234 partial(copy_relpath, context),
235 hotkeys.CUT,
237 self.copy_relpath_action.setIcon(icons.copy())
239 self.copy_leading_path_action = qtutils.add_action(
240 self,
241 N_('Copy Leading Path to Clipboard'),
242 partial(copy_leading_path, context),
244 self.copy_leading_path_action.setIcon(icons.copy())
246 self.copy_basename_action = qtutils.add_action(
247 self, N_('Copy Basename to Clipboard'), partial(copy_basename, context)
249 self.copy_basename_action.setIcon(icons.copy())
251 self.copy_customize_action = qtutils.add_action(
252 self, N_('Customize...'), partial(customize_copy_actions, context, self)
254 self.copy_customize_action.setIcon(icons.configure())
256 self.view_history_action = qtutils.add_action(
257 self, N_('View History...'), partial(view_history, context), hotkeys.HISTORY
260 self.view_blame_action = qtutils.add_action(
261 self, N_('Blame...'), partial(view_blame, context), hotkeys.BLAME
264 self.annex_add_action = qtutils.add_action(
265 self, N_('Add to Git Annex'), cmds.run(cmds.AnnexAdd, context)
268 self.lfs_track_action = qtutils.add_action(
269 self, N_('Add to Git LFS'), cmds.run(cmds.LFSTrack, context)
272 # MoveToTrash and Delete use the same shortcut.
273 # We will only bind one of them, depending on whether or not the
274 # MoveToTrash command is available. When available, the hotkey
275 # is bound to MoveToTrash, otherwise it is bound to Delete.
276 if cmds.MoveToTrash.AVAILABLE:
277 self.move_to_trash_action = qtutils.add_action(
278 self,
279 N_('Move files to trash'),
280 self._trash_untracked_files,
281 hotkeys.TRASH,
283 self.move_to_trash_action.setIcon(icons.discard())
284 delete_shortcut = hotkeys.DELETE_FILE
285 else:
286 self.move_to_trash_action = None
287 delete_shortcut = hotkeys.DELETE_FILE_SECONDARY
289 self.delete_untracked_files_action = qtutils.add_action(
290 self, N_('Delete Files...'), self._delete_untracked_files, delete_shortcut
292 self.delete_untracked_files_action.setIcon(icons.discard())
294 self.about_to_update.connect(self._about_to_update, type=Qt.QueuedConnection)
295 self.set_previous_contents.connect(
296 self._set_previous_contents, type=Qt.QueuedConnection)
297 self.updated.connect(self.refresh, type=Qt.QueuedConnection)
298 self.diff_text_changed.connect(
299 self._make_current_item_visible, type=Qt.QueuedConnection
302 # The model is stored as self.m because self.model() is a
303 # QTreeWidgetItem method that returns a QAbstractItemModel.
304 self.m = context.model
305 # Forward the previous_contents notification through self.set_previous_contents.
306 self.m.add_observer(
307 self.m.message_previous_contents, self.set_previous_contents.emit
309 # Forward the about_to_update notification through self.about_to_udpate.
310 self.m.add_observer(self.m.message_about_to_update, self.about_to_update.emit)
311 # Foward the updated notification through self.updated.
312 self.m.add_observer(self.m.message_updated, self.updated.emit)
313 self.m.add_observer(
314 self.m.message_diff_text_changed, self.diff_text_changed.emit
316 # pylint: disable=no-member
317 self.itemSelectionChanged.connect(self.show_selection)
318 self.itemDoubleClicked.connect(cmds.run(cmds.StageOrUnstage, self.context))
319 self.itemCollapsed.connect(lambda x: self._update_column_widths())
320 self.itemExpanded.connect(lambda x: self._update_column_widths())
322 def _make_current_item_visible(self):
323 item = self.currentItem()
324 if item:
325 qtutils.scroll_to_item(self, item)
327 def _add_toplevel_item(self, txt, icon, hide=False):
328 context = self.context
329 font = self.font()
330 if prefs.bold_headers(context):
331 font.setBold(True)
332 else:
333 font.setItalic(True)
335 item = QtWidgets.QTreeWidgetItem(self)
336 item.setFont(0, font)
337 item.setText(0, txt)
338 item.setIcon(0, icon)
339 if prefs.bold_headers(context):
340 item.setBackground(0, self.palette().midlight())
341 if hide:
342 item.setHidden(True)
344 def _restore_selection(self):
345 """Apply the old selection to the newly updated items"""
346 # This function is called after a new set of items have been added to
347 # the per-category file lists. Its purpose is to either restore the
348 # existing selection or to create a new intuitive selection based on
349 # a combination of the old items, the old selection and the new items.
350 if not self.old_selection or not self.old_contents:
351 return
352 # The old set of categorized files.
353 old_c = self.old_contents
354 # The old selection.
355 old_s = self.old_selection
356 # The current/new set of categorized files.
357 new_c = self.contents()
359 select_staged = partial(
360 _select_item, self, new_c.staged, self._staged_item
362 select_unmerged = partial(
363 _select_item, self, new_c.unmerged, self._unmerged_item
365 select_modified = partial(
366 _select_item, self, new_c.modified, self._modified_item
368 select_untracked = partial(
369 _select_item, self, new_c.untracked, self._untracked_item
372 saved_selection = [
373 (set(new_c.staged), old_c.staged, set(old_s.staged), select_staged),
374 (set(new_c.unmerged), old_c.unmerged, set(old_s.unmerged), select_unmerged),
375 (set(new_c.modified), old_c.modified, set(old_s.modified), select_modified),
377 set(new_c.untracked),
378 old_c.untracked,
379 set(old_s.untracked),
380 select_untracked,
384 # Restore the current item
385 if self.old_current_item:
386 category, idx = self.old_current_item
387 if _apply_toplevel_selection(self, category, idx):
388 return
389 # Reselect the current item
390 selection_info = saved_selection[category]
391 new = selection_info[NEW_PATHS_IDX]
392 old = selection_info[OLD_PATHS_IDX]
393 reselect = selection_info[SELECT_FN_IDX]
394 try:
395 item = old[idx]
396 except IndexError:
397 item = None
398 if item and item in new:
399 reselect(item, current=True)
401 # Restore previously selected items.
402 # When reselecting in this section we only care that the items are
403 # selected; we do not need to rerun the callbacks which were triggered
404 # above for the current item. Block signals to skip the callbacks.
406 # Reselect items that were previously selected and still exist in the
407 # current path lists. This handles a common case such as a Ctrl-R
408 # refresh which results in the same exact path state.
410 with qtutils.BlockSignals(self):
411 for (new, old, sel, reselect) in saved_selection:
412 for item in sel:
413 if item in new:
414 reselect(item, current=False)
416 # The status widget is used to interactively work your way down the
417 # list of Staged, Unmerged, Modified and Untracked items and perform
418 # an operation on them.
420 # For Staged items we intend to work our way down the list of Staged
421 # items while we unstage each item. For every other category we work
422 # our way down the list of {Unmerged,Modified,Untracked} items while
423 # we stage each item.
425 # The following block of code implements the behavior of selecting
426 # the next item based on the previous selection.
427 for (new, old, sel, reselect) in saved_selection:
428 # When modified is staged, select the next modified item
429 # When unmerged is staged, select the next unmerged item
430 # When unstaging, select the next staged item
431 # When staging untracked files, select the next untracked item
432 if len(new) >= len(old):
433 # The list did not shrink so it is not one of these cases.
434 continue
435 for item in sel:
436 # The item still exists so ignore it
437 if item in new or item not in old:
438 continue
439 # The item no longer exists in this list so search for
440 # its nearest neighbors and select them instead.
441 idx = old.index(item)
442 for j in itertools.chain(old[idx + 1 :], reversed(old[:idx])):
443 if j in new:
444 reselect(j, current=True)
445 return
447 def _restore_scrollbars(self):
448 """Restore scrollbars to the stored values"""
449 qtutils.set_scrollbar_values(self, self.old_hscroll, self.old_vscroll)
450 self.old_hscroll = None
451 self.old_vscroll = None
453 def _stage_selection(self):
454 """Stage or unstage files according to the selection"""
455 context = self.context
456 selected_indexes = self.selected_indexes()
457 is_header = any(
458 category == HEADER_IDX for (category, idx) in selected_indexes
460 if is_header:
461 is_staged = any(
462 idx == STAGED_IDX and category == HEADER_IDX
463 for (category, idx) in selected_indexes
465 is_modified = any(
466 idx == MODIFIED_IDX and category == HEADER_IDX
467 for (category, idx) in selected_indexes
469 is_untracked = any(
470 idx == UNTRACKED_IDX and category == HEADER_IDX
471 for (category, idx) in selected_indexes
473 # A header item: 'Staged', 'Modified' or 'Untracked'.
474 if is_staged:
475 # If we have the staged header selected then the only sensible
476 # thing to do is to unstage everything and nothing else, even
477 # if the modified or untracked headers are selected.
478 cmds.do(cmds.UnstageAll, context)
479 return # Everything was unstaged. There's nothing more to be done.
480 elif is_modified and is_untracked:
481 # If both modified and untracked headers are selected then
482 # stage everything.
483 cmds.do(cmds.StageModifiedAndUntracked, context)
484 return # Nothing more to do.
485 # At this point we may stage all modified and untracked, and then
486 # possibly a subset of the other category (eg. all modified and
487 # some untracked). We don't return here so that StageOrUnstage
488 # gets a chance to run below.
489 elif is_modified:
490 cmds.do(cmds.StageModified, context)
491 elif is_untracked:
492 cmds.do(cmds.StageUntracked, context)
493 else:
494 # Do nothing for unmerged items, by design
495 pass
496 # Now handle individual files
497 cmds.do(cmds.StageOrUnstage, context)
499 def _staged_item(self, itemidx):
500 return self._subtree_item(STAGED_IDX, itemidx)
502 def _modified_item(self, itemidx):
503 return self._subtree_item(MODIFIED_IDX, itemidx)
505 def _unmerged_item(self, itemidx):
506 return self._subtree_item(UNMERGED_IDX, itemidx)
508 def _untracked_item(self, itemidx):
509 return self._subtree_item(UNTRACKED_IDX, itemidx)
511 def _unstaged_item(self, itemidx):
512 # is it modified?
513 item = self.topLevelItem(MODIFIED_IDX)
514 count = item.childCount()
515 if itemidx < count:
516 return item.child(itemidx)
517 # is it unmerged?
518 item = self.topLevelItem(UNMERGED_IDX)
519 count += item.childCount()
520 if itemidx < count:
521 return item.child(itemidx)
522 # is it untracked?
523 item = self.topLevelItem(UNTRACKED_IDX)
524 count += item.childCount()
525 if itemidx < count:
526 return item.child(itemidx)
527 # Nope..
528 return None
530 def _subtree_item(self, idx, itemidx):
531 parent = self.topLevelItem(idx)
532 return parent.child(itemidx)
534 def _set_previous_contents(self, staged, unmerged, modified, untracked):
535 """Callback triggered right before the model changes its contents"""
536 self.previous_contents = selection.State(staged, unmerged, modified, untracked)
538 def _about_to_update(self):
539 self._save_scrollbars()
540 self._save_selection()
542 def _save_scrollbars(self):
543 """Store the scrollbar values for later application"""
544 hscroll, vscroll = qtutils.get_scrollbar_values(self)
545 if hscroll is not None:
546 self.old_hscroll = hscroll
547 if vscroll is not None:
548 self.old_vscroll = vscroll
550 def current_item(self):
551 s = self.selected_indexes()
552 if not s:
553 return None
554 current = self.currentItem()
555 if not current:
556 return None
557 idx = self.indexFromItem(current)
558 if idx.parent().isValid():
559 parent_idx = idx.parent()
560 entry = (parent_idx.row(), idx.row())
561 else:
562 entry = (HEADER_IDX, idx.row())
563 return entry
565 def _save_selection(self):
566 self.old_contents = self.contents()
567 self.old_selection = self.selection()
568 self.old_current_item = self.current_item()
570 def refresh(self):
571 self._set_staged(self.m.staged)
572 self._set_modified(self.m.modified)
573 self._set_unmerged(self.m.unmerged)
574 self._set_untracked(self.m.untracked)
575 self._update_column_widths()
576 self._update_actions()
577 self._restore_selection()
578 self._restore_scrollbars()
580 def _update_actions(self, selected=None):
581 if selected is None:
582 selected = self.selection_model.selection()
583 can_revert_edits = bool(selected.staged or selected.modified)
584 self.revert_unstaged_edits_action.setEnabled(can_revert_edits)
586 def _set_staged(self, items):
587 """Adds items to the 'Staged' subtree."""
588 with qtutils.BlockSignals(self):
589 self._set_subtree(
590 items,
591 STAGED_IDX,
592 N_('Staged'),
593 staged=True,
594 deleted_set=self.m.staged_deleted,
597 def _set_modified(self, items):
598 """Adds items to the 'Modified' subtree."""
599 with qtutils.BlockSignals(self):
600 self._set_subtree(
601 items,
602 MODIFIED_IDX,
603 N_('Modified'),
604 deleted_set=self.m.unstaged_deleted,
607 def _set_unmerged(self, items):
608 """Adds items to the 'Unmerged' subtree."""
609 deleted_set = set([path for path in items if not core.exists(path)])
610 with qtutils.BlockSignals(self):
611 self._set_subtree(
612 items, UNMERGED_IDX, N_('Unmerged'), deleted_set=deleted_set
615 def _set_untracked(self, items):
616 """Adds items to the 'Untracked' subtree."""
617 with qtutils.BlockSignals(self):
618 self._set_subtree(
619 items, UNTRACKED_IDX, N_('Untracked'), untracked=True
622 def _set_subtree(
623 self, items, idx, parent_title, staged=False, untracked=False, deleted_set=None
625 """Add a list of items to a treewidget item."""
626 parent = self.topLevelItem(idx)
627 hide = not bool(items)
628 parent.setHidden(hide)
630 # sip v4.14.7 and below leak memory in parent.takeChildren()
631 # so we use this backwards-compatible construct instead
632 while parent.takeChild(0) is not None:
633 pass
635 for item in items:
636 deleted = deleted_set is not None and item in deleted_set
637 treeitem = qtutils.create_treeitem(
638 item, staged=staged, deleted=deleted, untracked=untracked
640 parent.addChild(treeitem)
641 self._expand_items(idx, items)
643 if prefs.status_show_totals(self.context):
644 parent.setText(0, '%s (%s)' % (parent_title, len(items)))
646 def _update_column_widths(self):
647 self.resizeColumnToContents(0)
649 def _expand_items(self, idx, items):
650 """Expand the top-level category "folder" once and only once."""
651 # Don't do this if items is empty; this makes it so that we
652 # don't add the top-level index into the expanded_items set
653 # until an item appears in a particular category.
654 if not items:
655 return
656 # Only run this once; we don't want to re-expand items that
657 # we've clicked on to re-collapse on updated().
658 if idx in self.expanded_items:
659 return
660 self.expanded_items.add(idx)
661 item = self.topLevelItem(idx)
662 if item:
663 self.expandItem(item)
665 def contextMenuEvent(self, event):
666 """Create context menus for the repo status tree."""
667 menu = self._create_context_menu()
668 menu.exec_(self.mapToGlobal(event.pos()))
670 def _create_context_menu(self):
671 """Set up the status menu for the repo status tree."""
672 s = self.selection()
673 menu = qtutils.create_menu('Status', self)
674 selected_indexes = self.selected_indexes()
675 if selected_indexes:
676 category, idx = selected_indexes[0]
677 # A header item e.g. 'Staged', 'Modified', etc.
678 if category == HEADER_IDX:
679 return self._create_header_context_menu(menu, idx)
681 if s.staged:
682 self._create_staged_context_menu(menu, s)
683 elif s.unmerged:
684 self._create_unmerged_context_menu(menu, s)
685 else:
686 self._create_unstaged_context_menu(menu, s)
688 if not utils.is_win32():
689 if not menu.isEmpty():
690 menu.addSeparator()
691 if not self.selection_model.is_empty():
692 menu.addAction(self.default_app_action)
693 menu.addAction(self.parent_dir_action)
695 if self.terminal_action is not None:
696 menu.addAction(self.terminal_action)
698 self._add_copy_actions(menu)
700 return menu
702 def _add_copy_actions(self, menu):
703 """Add the "Copy" sub-menu"""
704 enabled = self.selection_model.filename() is not None
705 self.copy_path_action.setEnabled(enabled)
706 self.copy_relpath_action.setEnabled(enabled)
707 self.copy_leading_path_action.setEnabled(enabled)
708 self.copy_basename_action.setEnabled(enabled)
709 copy_icon = icons.copy()
711 menu.addSeparator()
712 copy_menu = QtWidgets.QMenu(N_('Copy...'), menu)
713 menu.addMenu(copy_menu)
715 copy_menu.setIcon(copy_icon)
716 copy_menu.addAction(self.copy_path_action)
717 copy_menu.addAction(self.copy_relpath_action)
718 copy_menu.addAction(self.copy_leading_path_action)
719 copy_menu.addAction(self.copy_basename_action)
721 settings = Settings.read()
722 copy_formats = settings.copy_formats
723 if copy_formats:
724 copy_menu.addSeparator()
726 context = self.context
727 for entry in copy_formats:
728 name = entry.get('name', '')
729 fmt = entry.get('format', '')
730 if name and fmt:
731 action = copy_menu.addAction(name, partial(copy_format, context, fmt))
732 action.setIcon(copy_icon)
733 action.setEnabled(enabled)
735 copy_menu.addSeparator()
736 copy_menu.addAction(self.copy_customize_action)
738 def _create_header_context_menu(self, menu, idx):
739 context = self.context
740 if idx == STAGED_IDX:
741 menu.addAction(
742 icons.remove(), N_('Unstage All'), cmds.run(cmds.UnstageAll, context)
744 elif idx == UNMERGED_IDX:
745 action = menu.addAction(
746 icons.add(),
747 cmds.StageUnmerged.name(),
748 cmds.run(cmds.StageUnmerged, context),
750 action.setShortcut(hotkeys.STAGE_SELECTION)
751 elif idx == MODIFIED_IDX:
752 action = menu.addAction(
753 icons.add(),
754 cmds.StageModified.name(),
755 cmds.run(cmds.StageModified, context),
757 action.setShortcut(hotkeys.STAGE_SELECTION)
758 elif idx == UNTRACKED_IDX:
759 action = menu.addAction(
760 icons.add(),
761 cmds.StageUntracked.name(),
762 cmds.run(cmds.StageUntracked, context),
764 action.setShortcut(hotkeys.STAGE_SELECTION)
765 return menu
767 def _create_staged_context_menu(self, menu, s):
768 if s.staged[0] in self.m.submodules:
769 return self._create_staged_submodule_context_menu(menu, s)
771 context = self.context
772 if self.m.unstageable():
773 action = menu.addAction(
774 icons.remove(),
775 N_('Unstage Selected'),
776 cmds.run(cmds.Unstage, context, self.staged()),
778 action.setShortcut(hotkeys.STAGE_SELECTION)
780 menu.addAction(self.launch_editor_action)
782 # Do all of the selected items exist?
783 all_exist = all(
784 i not in self.m.staged_deleted and core.exists(i) for i in self.staged()
787 if all_exist:
788 menu.addAction(self.launch_difftool_action)
790 if self.m.undoable():
791 menu.addAction(self.revert_unstaged_edits_action)
793 menu.addAction(self.view_history_action)
794 menu.addAction(self.view_blame_action)
795 return menu
797 def _create_staged_submodule_context_menu(self, menu, s):
798 context = self.context
799 path = core.abspath(s.staged[0])
800 if len(self.staged()) == 1:
801 menu.addAction(
802 icons.cola(),
803 N_('Launch git-cola'),
804 cmds.run(cmds.OpenRepo, context, path),
806 menu.addSeparator()
807 action = menu.addAction(
808 icons.remove(),
809 N_('Unstage Selected'),
810 cmds.run(cmds.Unstage, context, self.staged()),
812 action.setShortcut(hotkeys.STAGE_SELECTION)
814 menu.addAction(self.view_history_action)
815 return menu
817 def _create_unmerged_context_menu(self, menu, _s):
818 context = self.context
819 menu.addAction(self.launch_difftool_action)
821 action = menu.addAction(
822 icons.add(),
823 N_('Stage Selected'),
824 cmds.run(cmds.Stage, context, self.unstaged()),
826 action.setShortcut(hotkeys.STAGE_SELECTION)
828 menu.addAction(self.launch_editor_action)
829 menu.addAction(self.view_history_action)
830 menu.addAction(self.view_blame_action)
831 return menu
833 def _create_unstaged_context_menu(self, menu, s):
834 context = self.context
835 modified_submodule = s.modified and s.modified[0] in self.m.submodules
836 if modified_submodule:
837 return self._create_modified_submodule_context_menu(menu, s)
839 if self.m.stageable():
840 action = menu.addAction(
841 icons.add(),
842 N_('Stage Selected'),
843 cmds.run(cmds.Stage, context, self.unstaged()),
845 action.setShortcut(hotkeys.STAGE_SELECTION)
847 if not self.selection_model.is_empty():
848 menu.addAction(self.launch_editor_action)
850 # Do all of the selected items exist?
851 all_exist = all(
852 i not in self.m.unstaged_deleted and core.exists(i) for i in self.staged()
855 if all_exist and s.modified and self.m.stageable():
856 menu.addAction(self.launch_difftool_action)
858 if s.modified and self.m.stageable():
859 if self.m.undoable():
860 menu.addSeparator()
861 menu.addAction(self.revert_unstaged_edits_action)
863 if all_exist and s.untracked:
864 # Git Annex / Git LFS
865 annex = self.m.annex
866 lfs = core.find_executable('git-lfs')
867 if annex or lfs:
868 menu.addSeparator()
869 if annex:
870 menu.addAction(self.annex_add_action)
871 if lfs:
872 menu.addAction(self.lfs_track_action)
874 menu.addSeparator()
875 if self.move_to_trash_action is not None:
876 menu.addAction(self.move_to_trash_action)
877 menu.addAction(self.delete_untracked_files_action)
878 menu.addSeparator()
879 menu.addAction(
880 icons.edit(),
881 N_('Ignore...'),
882 partial(gitignore.gitignore_view, self.context),
885 if not self.selection_model.is_empty():
886 menu.addAction(self.view_history_action)
887 menu.addAction(self.view_blame_action)
888 return menu
890 def _create_modified_submodule_context_menu(self, menu, s):
891 context = self.context
892 path = core.abspath(s.modified[0])
893 if len(self.unstaged()) == 1:
894 menu.addAction(
895 icons.cola(),
896 N_('Launch git-cola'),
897 cmds.run(cmds.OpenRepo, context, path),
899 menu.addAction(
900 icons.pull(),
901 N_('Update this submodule'),
902 cmds.run(cmds.SubmoduleUpdate, context, path),
904 menu.addSeparator()
906 if self.m.stageable():
907 menu.addSeparator()
908 action = menu.addAction(
909 icons.add(),
910 N_('Stage Selected'),
911 cmds.run(cmds.Stage, context, self.unstaged()),
913 action.setShortcut(hotkeys.STAGE_SELECTION)
915 menu.addAction(self.view_history_action)
916 return menu
918 def _delete_untracked_files(self):
919 cmds.do(cmds.Delete, self.context, self.untracked())
921 def _trash_untracked_files(self):
922 cmds.do(cmds.MoveToTrash, self.context, self.untracked())
924 def selected_path(self):
925 s = self.single_selection()
926 return s.staged or s.unmerged or s.modified or s.untracked or None
928 def single_selection(self):
929 """Scan across staged, modified, etc. and return a single item."""
930 staged = None
931 unmerged = None
932 modified = None
933 untracked = None
935 s = self.selection()
936 if s.staged:
937 staged = s.staged[0]
938 elif s.unmerged:
939 unmerged = s.unmerged[0]
940 elif s.modified:
941 modified = s.modified[0]
942 elif s.untracked:
943 untracked = s.untracked[0]
945 return selection.State(staged, unmerged, modified, untracked)
947 def selected_indexes(self):
948 """Returns a list of (category, row) representing the tree selection."""
949 selected = self.selectedIndexes()
950 result = []
951 for idx in selected:
952 if idx.parent().isValid():
953 parent_idx = idx.parent()
954 entry = (parent_idx.row(), idx.row())
955 else:
956 entry = (HEADER_IDX, idx.row())
957 result.append(entry)
958 return result
960 def selection(self):
961 """Return the current selection in the repo status tree."""
962 return selection.State(
963 self.staged(), self.unmerged(), self.modified(), self.untracked()
966 def contents(self):
967 """Return all of the current files in a selection.State container"""
968 return selection.State(
969 self.m.staged, self.m.unmerged, self.m.modified, self.m.untracked
972 def all_files(self):
973 """Return all of the current active files as a flast list"""
974 c = self.contents()
975 return c.staged + c.unmerged + c.modified + c.untracked
977 def selected_group(self):
978 """A list of selected files in various states of being"""
979 return selection.pick(self.selection())
981 def selected_idx(self):
982 c = self.contents()
983 s = self.single_selection()
984 offset = 0
985 for content, sel in zip(c, s):
986 if not content:
987 continue
988 if sel is not None:
989 return offset + content.index(sel)
990 offset += len(content)
991 return None
993 def select_by_index(self, idx):
994 c = self.contents()
995 to_try = [
996 (c.staged, STAGED_IDX),
997 (c.unmerged, UNMERGED_IDX),
998 (c.modified, MODIFIED_IDX),
999 (c.untracked, UNTRACKED_IDX),
1001 for content, toplevel_idx in to_try:
1002 if not content:
1003 continue
1004 if idx < len(content):
1005 parent = self.topLevelItem(toplevel_idx)
1006 item = parent.child(idx)
1007 if item is not None:
1008 qtutils.select_item(self, item)
1009 return
1010 idx -= len(content)
1012 def staged(self):
1013 return qtutils.get_selected_values(self, STAGED_IDX, self.m.staged)
1015 def unstaged(self):
1016 return self.unmerged() + self.modified() + self.untracked()
1018 def modified(self):
1019 return qtutils.get_selected_values(self, MODIFIED_IDX, self.m.modified)
1021 def unmerged(self):
1022 return qtutils.get_selected_values(self, UNMERGED_IDX, self.m.unmerged)
1024 def untracked(self):
1025 return qtutils.get_selected_values(self, UNTRACKED_IDX, self.m.untracked)
1027 def staged_items(self):
1028 return qtutils.get_selected_items(self, STAGED_IDX)
1030 def unstaged_items(self):
1031 return self.unmerged_items() + self.modified_items() + self.untracked_items()
1033 def modified_items(self):
1034 return qtutils.get_selected_items(self, MODIFIED_IDX)
1036 def unmerged_items(self):
1037 return qtutils.get_selected_items(self, UNMERGED_IDX)
1039 def untracked_items(self):
1040 return qtutils.get_selected_items(self, UNTRACKED_IDX)
1042 def show_selection(self):
1043 """Show the selected item."""
1044 context = self.context
1045 qtutils.scroll_to_item(self, self.currentItem())
1046 # Sync the selection model
1047 selected = self.selection()
1048 selection_model = self.selection_model
1049 selection_model.set_selection(selected)
1050 self._update_actions(selected=selected)
1052 selected_indexes = self.selected_indexes()
1053 if not selected_indexes:
1054 if self.m.amending():
1055 cmds.do(cmds.SetDiffText, context, '')
1056 else:
1057 cmds.do(cmds.ResetMode, context)
1058 return
1060 # A header item e.g. 'Staged', 'Modified', etc.
1061 category, idx = selected_indexes[0]
1062 header = category == HEADER_IDX
1063 if header:
1064 cls = {
1065 STAGED_IDX: cmds.DiffStagedSummary,
1066 MODIFIED_IDX: cmds.Diffstat,
1067 # TODO implement UnmergedSummary
1068 # UNMERGED_IDX: cmds.UnmergedSummary,
1069 UNTRACKED_IDX: cmds.UntrackedSummary,
1070 }.get(idx, cmds.Diffstat)
1071 cmds.do(cls, context)
1072 return
1074 staged = category == STAGED_IDX
1075 modified = category == MODIFIED_IDX
1076 unmerged = category == UNMERGED_IDX
1077 untracked = category == UNTRACKED_IDX
1079 if staged:
1080 item = self.staged_items()[0]
1081 elif unmerged:
1082 item = self.unmerged_items()[0]
1083 elif modified:
1084 item = self.modified_items()[0]
1085 elif untracked:
1086 item = self.unstaged_items()[0]
1087 else:
1088 item = None # this shouldn't happen
1089 assert item is not None
1091 path = item.path
1092 deleted = item.deleted
1093 image = self.image_formats.ok(path)
1095 # Update the diff text
1096 if staged:
1097 cmds.do(cmds.DiffStaged, context, path, deleted=deleted)
1098 elif modified:
1099 cmds.do(cmds.Diff, context, path, deleted=deleted)
1100 elif unmerged:
1101 cmds.do(cmds.Diff, context, path)
1102 elif untracked:
1103 cmds.do(cmds.ShowUntracked, context, path)
1105 # Images are diffed differently.
1106 # DiffImage transitions the diff mode to image.
1107 # DiffText transitions the diff mode to text.
1108 if image:
1109 cmds.do(
1110 cmds.DiffImage,
1111 context,
1112 path,
1113 deleted,
1114 staged,
1115 modified,
1116 unmerged,
1117 untracked,
1119 else:
1120 cmds.do(cmds.DiffText, context)
1122 def select_header(self):
1123 """Select an active header, which triggers a diffstat"""
1124 for idx in (
1125 STAGED_IDX,
1126 UNMERGED_IDX,
1127 MODIFIED_IDX,
1128 UNTRACKED_IDX,
1130 item = self.topLevelItem(idx)
1131 if item.childCount() > 0:
1132 self.clearSelection()
1133 self.setCurrentItem(item)
1134 return
1136 def move_up(self):
1137 idx = self.selected_idx()
1138 all_files = self.all_files()
1139 if idx is None:
1140 selected_indexes = self.selected_indexes()
1141 if selected_indexes:
1142 category, toplevel_idx = selected_indexes[0]
1143 if category == HEADER_IDX:
1144 item = self.itemAbove(self.topLevelItem(toplevel_idx))
1145 if item is not None:
1146 qtutils.select_item(self, item)
1147 return
1148 if all_files:
1149 self.select_by_index(len(all_files) - 1)
1150 return
1151 if idx - 1 >= 0:
1152 self.select_by_index(idx - 1)
1153 else:
1154 self.select_by_index(len(all_files) - 1)
1156 def move_down(self):
1157 idx = self.selected_idx()
1158 all_files = self.all_files()
1159 if idx is None:
1160 selected_indexes = self.selected_indexes()
1161 if selected_indexes:
1162 category, toplevel_idx = selected_indexes[0]
1163 if category == HEADER_IDX:
1164 item = self.itemBelow(self.topLevelItem(toplevel_idx))
1165 if item is not None:
1166 qtutils.select_item(self, item)
1167 return
1168 if all_files:
1169 self.select_by_index(0)
1170 return
1171 if idx + 1 < len(all_files):
1172 self.select_by_index(idx + 1)
1173 else:
1174 self.select_by_index(0)
1176 def mimeData(self, items):
1177 """Return a list of absolute-path URLs"""
1178 context = self.context
1179 paths = qtutils.paths_from_items(items, item_filter=_item_filter)
1180 return qtutils.mimedata_from_paths(context, paths)
1182 # pylint: disable=no-self-use
1183 def mimeTypes(self):
1184 return qtutils.path_mimetypes()
1187 def _item_filter(item):
1188 return not item.deleted and core.exists(item.path)
1191 def view_blame(context):
1192 """Signal that we should view blame for paths."""
1193 cmds.do(cmds.BlamePaths, context)
1196 def view_history(context):
1197 """Signal that we should view history for paths."""
1198 cmds.do(cmds.VisualizePaths, context, context.selection.union())
1201 def copy_path(context, absolute=True):
1202 """Copy a selected path to the clipboard"""
1203 filename = context.selection.filename()
1204 qtutils.copy_path(filename, absolute=absolute)
1207 def copy_relpath(context):
1208 """Copy a selected relative path to the clipboard"""
1209 copy_path(context, absolute=False)
1212 def copy_basename(context):
1213 filename = os.path.basename(context.selection.filename())
1214 basename, _ = os.path.splitext(filename)
1215 qtutils.copy_path(basename, absolute=False)
1218 def copy_leading_path(context):
1219 """Copy the selected leading path to the clipboard"""
1220 filename = context.selection.filename()
1221 dirname = os.path.dirname(filename)
1222 qtutils.copy_path(dirname, absolute=False)
1225 def copy_format(context, fmt):
1226 values = {}
1227 values['path'] = path = context.selection.filename()
1228 values['abspath'] = abspath = os.path.abspath(path)
1229 values['absdirname'] = os.path.dirname(abspath)
1230 values['dirname'] = os.path.dirname(path)
1231 values['filename'] = os.path.basename(path)
1232 values['basename'], values['ext'] = os.path.splitext(os.path.basename(path))
1233 qtutils.set_clipboard(fmt % values)
1236 def show_help(context):
1237 help_text = N_(
1238 r"""
1239 Format String Variables
1240 -----------------------
1241 %(path)s = relative file path
1242 %(abspath)s = absolute file path
1243 %(dirname)s = relative directory path
1244 %(absdirname)s = absolute directory path
1245 %(filename)s = file basename
1246 %(basename)s = file basename without extension
1247 %(ext)s = file extension
1250 title = N_('Help - Custom Copy Actions')
1251 return text.text_dialog(context, help_text, title)
1254 class StatusFilterWidget(QtWidgets.QWidget):
1255 def __init__(self, context, parent=None):
1256 QtWidgets.QWidget.__init__(self, parent)
1257 self.context = context
1259 hint = N_('Filter paths...')
1260 self.text = completion.GitStatusFilterLineEdit(context, hint=hint, parent=self)
1261 self.text.setToolTip(hint)
1262 self.setFocusProxy(self.text)
1263 self._filter = None
1265 self.main_layout = qtutils.hbox(defs.no_margin, defs.spacing, self.text)
1266 self.setLayout(self.main_layout)
1268 widget = self.text
1269 # pylint: disable=no-member
1270 widget.changed.connect(self.apply_filter)
1271 widget.cleared.connect(self.apply_filter)
1272 widget.enter.connect(self.apply_filter)
1273 widget.editingFinished.connect(self.apply_filter)
1275 def apply_filter(self):
1276 value = get(self.text)
1277 if value == self._filter:
1278 return
1279 self._filter = value
1280 paths = utils.shell_split(value)
1281 self.context.model.update_path_filter(paths)
1284 def customize_copy_actions(context, parent):
1285 """Customize copy actions"""
1286 dialog = CustomizeCopyActions(context, parent)
1287 dialog.show()
1288 dialog.exec_()
1291 class CustomizeCopyActions(standard.Dialog):
1292 def __init__(self, context, parent):
1293 standard.Dialog.__init__(self, parent=parent)
1294 self.setWindowTitle(N_('Custom Copy Actions'))
1296 self.context = context
1297 self.table = QtWidgets.QTableWidget(self)
1298 self.table.setColumnCount(2)
1299 self.table.setHorizontalHeaderLabels([N_('Action Name'), N_('Format String')])
1300 self.table.setSortingEnabled(False)
1301 self.table.verticalHeader().hide()
1302 self.table.horizontalHeader().setStretchLastSection(True)
1304 self.add_button = qtutils.create_button(N_('Add'))
1305 self.remove_button = qtutils.create_button(N_('Remove'))
1306 self.remove_button.setEnabled(False)
1307 self.show_help_button = qtutils.create_button(N_('Show Help'))
1308 self.show_help_button.setShortcut(hotkeys.QUESTION)
1310 self.close_button = qtutils.close_button()
1311 self.save_button = qtutils.ok_button(N_('Save'))
1313 self.buttons = qtutils.hbox(
1314 defs.no_margin,
1315 defs.button_spacing,
1316 self.add_button,
1317 self.remove_button,
1318 self.show_help_button,
1319 qtutils.STRETCH,
1320 self.close_button,
1321 self.save_button,
1324 layout = qtutils.vbox(defs.margin, defs.spacing, self.table, self.buttons)
1325 self.setLayout(layout)
1327 qtutils.connect_button(self.add_button, self.add)
1328 qtutils.connect_button(self.remove_button, self.remove)
1329 qtutils.connect_button(self.show_help_button, partial(show_help, context))
1330 qtutils.connect_button(self.close_button, self.reject)
1331 qtutils.connect_button(self.save_button, self.save)
1332 qtutils.add_close_action(self)
1333 # pylint: disable=no-member
1334 self.table.itemSelectionChanged.connect(self.table_selection_changed)
1336 self.init_size(parent=parent)
1338 QtCore.QTimer.singleShot(0, self.reload_settings)
1340 def reload_settings(self):
1341 # Called once after the GUI is initialized
1342 settings = self.context.settings
1343 settings.load()
1344 table = self.table
1345 for entry in settings.copy_formats:
1346 name_string = entry.get('name', '')
1347 format_string = entry.get('format', '')
1348 if name_string and format_string:
1349 name = QtWidgets.QTableWidgetItem(name_string)
1350 fmt = QtWidgets.QTableWidgetItem(format_string)
1351 rows = table.rowCount()
1352 table.setRowCount(rows + 1)
1353 table.setItem(rows, 0, name)
1354 table.setItem(rows, 1, fmt)
1356 def export_state(self):
1357 state = super(CustomizeCopyActions, self).export_state()
1358 standard.export_header_columns(self.table, state)
1359 return state
1361 def apply_state(self, state):
1362 result = super(CustomizeCopyActions, self).apply_state(state)
1363 standard.apply_header_columns(self.table, state)
1364 return result
1366 def add(self):
1367 self.table.setFocus()
1368 rows = self.table.rowCount()
1369 self.table.setRowCount(rows + 1)
1371 name = QtWidgets.QTableWidgetItem(N_('Name'))
1372 fmt = QtWidgets.QTableWidgetItem(r'%(path)s')
1373 self.table.setItem(rows, 0, name)
1374 self.table.setItem(rows, 1, fmt)
1376 self.table.setCurrentCell(rows, 0)
1377 self.table.editItem(name)
1379 def remove(self):
1380 """Remove selected items"""
1381 # Gather a unique set of rows and remove them in reverse order
1382 rows = set()
1383 items = self.table.selectedItems()
1384 for item in items:
1385 rows.add(self.table.row(item))
1387 for row in reversed(sorted(rows)):
1388 self.table.removeRow(row)
1390 def save(self):
1391 copy_formats = []
1392 for row in range(self.table.rowCount()):
1393 name = self.table.item(row, 0)
1394 fmt = self.table.item(row, 1)
1395 if name and fmt:
1396 entry = {
1397 'name': name.text(),
1398 'format': fmt.text(),
1400 copy_formats.append(entry)
1402 settings = self.context.settings
1403 while settings.copy_formats:
1404 settings.copy_formats.pop()
1406 settings.copy_formats.extend(copy_formats)
1407 settings.save()
1409 self.accept()
1411 def table_selection_changed(self):
1412 items = self.table.selectedItems()
1413 self.remove_button.setEnabled(bool(items))
1416 def _select_item(widget, path_list, widget_getter, item, current=False):
1417 """Select the widget item based on the list index"""
1418 # The path lists and widget indexes have a 1:1 correspondence.
1419 # Lookup the item filename in the list and use that index to
1420 # retrieve the widget item and select it.
1421 idx = path_list.index(item)
1422 item = widget_getter(idx)
1423 if current:
1424 widget.setCurrentItem(item)
1425 item.setSelected(True)
1428 def _apply_toplevel_selection(widget, category, idx):
1429 """Select a top-level "header" item (ex: the Staged parent item)
1431 Return True when a top-level item is selected.
1433 is_top_level_item = category == HEADER_IDX
1434 if is_top_level_item:
1435 item = widget.invisibleRootItem().child(idx)
1436 if item is not None:
1437 with qtutils.BlockSignals(widget):
1438 widget.setCurrentItem(item)
1439 item.setSelected(True)
1440 widget.show_selection()
1442 return is_top_level_item