1 from __future__
import division
, absolute_import
, unicode_literals
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
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
21 from .. import hotkeys
23 from .. import qtutils
26 from . import completion
31 # Top-level status widget item indexes.
39 # Indexes into the saved_selection entries.
46 class StatusWidget(QtWidgets
.QFrame
):
48 Provides a git-status-like repository widget.
50 This widget observes the main model and broadcasts
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
)
83 self
.filter_widget
.setFocus()
87 def set_initial_size(self
):
88 self
.setMaximumWidth(222)
89 QtCore
.QTimer
.singleShot(1, lambda: self
.setMaximumWidth(2 ** 13))
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()
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
):
112 about_to_update
= Signal()
113 set_previous_contents
= Signal(list, list, list, list)
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)
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(
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(
168 cmds
.StageOrUnstageAll
.name(),
169 cmds
.run(cmds
.StageOrUnstageAll
, self
.context
),
172 self
.stage_or_unstage_all_action
.setIcon(icons
.add())
174 self
.revert_unstaged_edits_action
= qtutils
.add_action(
176 cmds
.RevertUnstagedEdits
.name(),
177 cmds
.run(cmds
.RevertUnstagedEdits
, context
),
180 self
.revert_unstaged_edits_action
.setIcon(icons
.undo())
182 self
.launch_difftool_action
= qtutils
.add_action(
184 cmds
.LaunchDifftool
.name(),
185 cmds
.run(cmds
.LaunchDifftool
, context
),
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(
212 hotkeys
.MOVE_UP_SECONDARY
,
215 self
.down_action
= qtutils
.add_action(
220 hotkeys
.MOVE_DOWN_SECONDARY
,
223 self
.copy_path_action
= qtutils
.add_action(
225 N_('Copy Path to Clipboard'),
226 partial(copy_path
, context
),
229 self
.copy_path_action
.setIcon(icons
.copy())
231 self
.copy_relpath_action
= qtutils
.add_action(
233 N_('Copy Relative Path to Clipboard'),
234 partial(copy_relpath
, context
),
237 self
.copy_relpath_action
.setIcon(icons
.copy())
239 self
.copy_leading_path_action
= qtutils
.add_action(
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(
279 N_('Move files to trash'),
280 self
._trash
_untracked
_files
,
283 self
.move_to_trash_action
.setIcon(icons
.discard())
284 delete_shortcut
= hotkeys
.DELETE_FILE
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.
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
)
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()
325 qtutils
.scroll_to_item(self
, item
)
327 def _add_toplevel_item(self
, txt
, icon
, hide
=False):
328 context
= self
.context
330 if prefs
.bold_headers(context
):
335 item
= QtWidgets
.QTreeWidgetItem(self
)
336 item
.setFont(0, font
)
338 item
.setIcon(0, icon
)
339 if prefs
.bold_headers(context
):
340 item
.setBackground(0, self
.palette().midlight())
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
:
352 # The old set of categorized files.
353 old_c
= self
.old_contents
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
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
),
379 set(old_s
.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
):
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
]
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
:
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.
436 # The item still exists so ignore it
437 if item
in new
or item
not in old
:
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
])):
444 reselect(j
, current
=True)
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()
458 category
== HEADER_IDX
for (category
, idx
) in selected_indexes
462 idx
== STAGED_IDX
and category
== HEADER_IDX
463 for (category
, idx
) in selected_indexes
466 idx
== MODIFIED_IDX
and category
== HEADER_IDX
467 for (category
, idx
) in selected_indexes
470 idx
== UNTRACKED_IDX
and category
== HEADER_IDX
471 for (category
, idx
) in selected_indexes
473 # A header item: 'Staged', 'Modified' or 'Untracked'.
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
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.
490 cmds
.do(cmds
.StageModified
, context
)
492 cmds
.do(cmds
.StageUntracked
, context
)
494 # Do nothing for unmerged items, by design
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
):
513 item
= self
.topLevelItem(MODIFIED_IDX
)
514 count
= item
.childCount()
516 return item
.child(itemidx
)
518 item
= self
.topLevelItem(UNMERGED_IDX
)
519 count
+= item
.childCount()
521 return item
.child(itemidx
)
523 item
= self
.topLevelItem(UNTRACKED_IDX
)
524 count
+= item
.childCount()
526 return item
.child(itemidx
)
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()
554 current
= self
.currentItem()
557 idx
= self
.indexFromItem(current
)
558 if idx
.parent().isValid():
559 parent_idx
= idx
.parent()
560 entry
= (parent_idx
.row(), idx
.row())
562 entry
= (HEADER_IDX
, idx
.row())
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()
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):
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
):
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
):
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
):
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
):
619 items
, UNTRACKED_IDX
, N_('Untracked'), untracked
=True
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:
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.
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
:
660 self
.expanded_items
.add(idx
)
661 item
= self
.topLevelItem(idx
)
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."""
673 menu
= qtutils
.create_menu('Status', self
)
674 selected_indexes
= self
.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
)
682 self
._create
_staged
_context
_menu
(menu
, s
)
684 self
._create
_unmerged
_context
_menu
(menu
, s
)
686 self
._create
_unstaged
_context
_menu
(menu
, s
)
688 if not utils
.is_win32():
689 if not menu
.isEmpty():
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
)
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()
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
724 copy_menu
.addSeparator()
726 context
= self
.context
727 for entry
in copy_formats
:
728 name
= entry
.get('name', '')
729 fmt
= entry
.get('format', '')
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
:
742 icons
.remove(), N_('Unstage All'), cmds
.run(cmds
.UnstageAll
, context
)
744 elif idx
== UNMERGED_IDX
:
745 action
= menu
.addAction(
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(
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(
761 cmds
.StageUntracked
.name(),
762 cmds
.run(cmds
.StageUntracked
, context
),
764 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
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(
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?
784 i
not in self
.m
.staged_deleted
and core
.exists(i
) for i
in self
.staged()
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
)
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:
803 N_('Launch git-cola'),
804 cmds
.run(cmds
.OpenRepo
, context
, path
),
807 action
= menu
.addAction(
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
)
817 def _create_unmerged_context_menu(self
, menu
, _s
):
818 context
= self
.context
819 menu
.addAction(self
.launch_difftool_action
)
821 action
= menu
.addAction(
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
)
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(
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?
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():
861 menu
.addAction(self
.revert_unstaged_edits_action
)
863 if all_exist
and s
.untracked
:
864 # Git Annex / Git LFS
866 lfs
= core
.find_executable('git-lfs')
870 menu
.addAction(self
.annex_add_action
)
872 menu
.addAction(self
.lfs_track_action
)
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
)
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
)
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:
896 N_('Launch git-cola'),
897 cmds
.run(cmds
.OpenRepo
, context
, path
),
901 N_('Update this submodule'),
902 cmds
.run(cmds
.SubmoduleUpdate
, context
, path
),
906 if self
.m
.stageable():
908 action
= menu
.addAction(
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
)
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."""
939 unmerged
= s
.unmerged
[0]
941 modified
= s
.modified
[0]
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()
952 if idx
.parent().isValid():
953 parent_idx
= idx
.parent()
954 entry
= (parent_idx
.row(), idx
.row())
956 entry
= (HEADER_IDX
, idx
.row())
961 """Return the current selection in the repo status tree."""
962 return selection
.State(
963 self
.staged(), self
.unmerged(), self
.modified(), self
.untracked()
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
973 """Return all of the current active files as a flast list"""
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
):
983 s
= self
.single_selection()
985 for content
, sel
in zip(c
, s
):
989 return offset
+ content
.index(sel
)
990 offset
+= len(content
)
993 def select_by_index(self
, idx
):
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
:
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
)
1013 return qtutils
.get_selected_values(self
, STAGED_IDX
, self
.m
.staged
)
1016 return self
.unmerged() + self
.modified() + self
.untracked()
1019 return qtutils
.get_selected_values(self
, MODIFIED_IDX
, self
.m
.modified
)
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
, '')
1057 cmds
.do(cmds
.ResetMode
, context
)
1060 # A header item e.g. 'Staged', 'Modified', etc.
1061 category
, idx
= selected_indexes
[0]
1062 header
= category
== HEADER_IDX
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
)
1074 staged
= category
== STAGED_IDX
1075 modified
= category
== MODIFIED_IDX
1076 unmerged
= category
== UNMERGED_IDX
1077 untracked
= category
== UNTRACKED_IDX
1080 item
= self
.staged_items()[0]
1082 item
= self
.unmerged_items()[0]
1084 item
= self
.modified_items()[0]
1086 item
= self
.unstaged_items()[0]
1088 item
= None # this shouldn't happen
1089 assert item
is not None
1092 deleted
= item
.deleted
1093 image
= self
.image_formats
.ok(path
)
1095 # Update the diff text
1097 cmds
.do(cmds
.DiffStaged
, context
, path
, deleted
=deleted
)
1099 cmds
.do(cmds
.Diff
, context
, path
, deleted
=deleted
)
1101 cmds
.do(cmds
.Diff
, context
, path
)
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.
1120 cmds
.do(cmds
.DiffText
, context
)
1122 def select_header(self
):
1123 """Select an active header, which triggers a diffstat"""
1130 item
= self
.topLevelItem(idx
)
1131 if item
.childCount() > 0:
1132 self
.clearSelection()
1133 self
.setCurrentItem(item
)
1137 idx
= self
.selected_idx()
1138 all_files
= self
.all_files()
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
)
1149 self
.select_by_index(len(all_files
) - 1)
1152 self
.select_by_index(idx
- 1)
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()
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
)
1169 self
.select_by_index(0)
1171 if idx
+ 1 < len(all_files
):
1172 self
.select_by_index(idx
+ 1)
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
):
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
):
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
)
1265 self
.main_layout
= qtutils
.hbox(defs
.no_margin
, defs
.spacing
, self
.text
)
1266 self
.setLayout(self
.main_layout
)
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
:
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
)
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(
1315 defs
.button_spacing
,
1318 self
.show_help_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
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
)
1361 def apply_state(self
, state
):
1362 result
= super(CustomizeCopyActions
, self
).apply_state(state
)
1363 standard
.apply_header_columns(self
.table
, state
)
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
)
1380 """Remove selected items"""
1381 # Gather a unique set of rows and remove them in reverse order
1383 items
= self
.table
.selectedItems()
1385 rows
.add(self
.table
.row(item
))
1387 for row
in reversed(sorted(rows
)):
1388 self
.table
.removeRow(row
)
1392 for row
in range(self
.table
.rowCount()):
1393 name
= self
.table
.item(row
, 0)
1394 fmt
= self
.table
.item(row
, 1)
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
)
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
)
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