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 class StatusWidget(QtWidgets
.QFrame
):
33 Provides a git-status-like repository widget.
35 This widget observes the main model and broadcasts
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
)
68 self
.filter_widget
.setFocus()
72 def set_initial_size(self
):
73 self
.setMaximumWidth(222)
74 QtCore
.QTimer
.singleShot(1, lambda: self
.setMaximumWidth(2 ** 13))
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()
90 def select_header(self
):
91 self
.tree
.select_header()
94 # pylint: disable=too-many-ancestors
95 class StatusTreeWidget(QtWidgets
.QTreeWidget
):
97 about_to_update
= Signal()
99 diff_text_changed
= Signal()
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)
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(
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(
159 cmds
.StageOrUnstageAll
.name(),
160 cmds
.run(cmds
.StageOrUnstageAll
, self
.context
),
163 self
.stage_or_unstage_all_action
.setIcon(icons
.add())
165 self
.revert_unstaged_edits_action
= qtutils
.add_action(
167 cmds
.RevertUnstagedEdits
.name(),
168 cmds
.run(cmds
.RevertUnstagedEdits
, context
),
171 self
.revert_unstaged_edits_action
.setIcon(icons
.undo())
173 self
.launch_difftool_action
= qtutils
.add_action(
175 cmds
.LaunchDifftool
.name(),
176 cmds
.run(cmds
.LaunchDifftool
, context
),
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(
203 hotkeys
.MOVE_UP_SECONDARY
,
206 self
.down_action
= qtutils
.add_action(
211 hotkeys
.MOVE_DOWN_SECONDARY
,
214 self
.copy_path_action
= qtutils
.add_action(
216 N_('Copy Path to Clipboard'),
217 partial(copy_path
, context
),
220 self
.copy_path_action
.setIcon(icons
.copy())
222 self
.copy_relpath_action
= qtutils
.add_action(
224 N_('Copy Relative Path to Clipboard'),
225 partial(copy_relpath
, context
),
228 self
.copy_relpath_action
.setIcon(icons
.copy())
230 self
.copy_leading_path_action
= qtutils
.add_action(
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(
270 N_('Move files to trash'),
271 self
._trash
_untracked
_files
,
274 self
.move_to_trash_action
.setIcon(icons
.discard())
275 delete_shortcut
= hotkeys
.DELETE_FILE
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
)
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()
307 self
.scroll_to_item(item
)
309 def _add_toplevel_item(self
, txt
, icon
, hide
=False):
310 context
= self
.context
312 if prefs
.bold_headers(context
):
317 item
= QtWidgets
.QTreeWidgetItem(self
)
318 item
.setFont(0, font
)
320 item
.setIcon(0, icon
)
321 if prefs
.bold_headers(context
):
322 item
.setBackground(0, self
.palette().midlight())
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
:
334 # The old set of categorized files.
335 old_c
= self
.old_contents
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
)
351 self
.setCurrentItem(item
)
352 item
.setSelected(True)
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
)
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
),
367 set(old_s
.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
)
378 with qtutils
.BlockSignals(self
):
379 self
.setCurrentItem(item
)
380 item
.setSelected(True)
381 self
.show_selection()
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]
393 reselect(item
, current
=True)
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
:
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.
426 # The item still exists so ignore it
427 if item
in new
or item
not in old
:
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
])):
434 reselect(j
, current
=True)
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()
453 category
== self
.idx_header
for (category
, idx
) in selected_indexes
457 idx
== self
.idx_staged
and category
== self
.idx_header
458 for (category
, idx
) in selected_indexes
461 idx
== self
.idx_modified
and category
== self
.idx_header
462 for (category
, idx
) in selected_indexes
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'.
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
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.
485 cmds
.do(cmds
.StageModified
, context
)
487 cmds
.do(cmds
.StageUntracked
, context
)
489 # Do nothing for unmerged items, by design
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
):
508 item
= self
.topLevelItem(self
.idx_modified
)
509 count
= item
.childCount()
511 return item
.child(itemidx
)
513 item
= self
.topLevelItem(self
.idx_unmerged
)
514 count
+= item
.childCount()
516 return item
.child(itemidx
)
518 item
= self
.topLevelItem(self
.idx_untracked
)
519 count
+= item
.childCount()
521 return item
.child(itemidx
)
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()
536 self
.old_vscroll
= get(vscroll
)
538 hscroll
= self
.horizontalScrollBar()
540 self
.old_hscroll
= get(hscroll
)
542 def current_item(self
):
543 s
= self
.selected_indexes()
546 current
= self
.currentItem()
549 idx
= self
.indexFromItem(current
)
550 if idx
.parent().isValid():
551 parent_idx
= idx
.parent()
552 entry
= (parent_idx
.row(), idx
.row())
554 entry
= (self
.idx_header
, idx
.row())
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()
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):
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
):
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
):
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
):
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
):
611 items
, self
.idx_untracked
, N_('Untracked'), untracked
=True
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:
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.
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
:
652 self
.expanded_items
.add(idx
)
653 item
= self
.topLevelItem(idx
)
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."""
665 menu
= qtutils
.create_menu('Status', self
)
666 selected_indexes
= self
.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
)
674 self
._create
_staged
_context
_menu
(menu
, s
)
676 self
._create
_unmerged
_context
_menu
(menu
, s
)
678 self
._create
_unstaged
_context
_menu
(menu
, s
)
680 if not utils
.is_win32():
681 if not menu
.isEmpty():
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
)
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()
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
716 copy_menu
.addSeparator()
718 context
= self
.context
719 for entry
in copy_formats
:
720 name
= entry
.get('name', '')
721 fmt
= entry
.get('format', '')
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
:
734 icons
.remove(), N_('Unstage All'), cmds
.run(cmds
.UnstageAll
, context
)
736 elif idx
== self
.idx_unmerged
:
737 action
= menu
.addAction(
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(
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(
753 cmds
.StageUntracked
.name(),
754 cmds
.run(cmds
.StageUntracked
, context
),
756 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
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(
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?
776 i
not in self
.m
.staged_deleted
and core
.exists(i
) for i
in self
.staged()
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
)
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:
795 N_('Launch git-cola'),
796 cmds
.run(cmds
.OpenRepo
, context
, path
),
799 action
= menu
.addAction(
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
)
809 def _create_unmerged_context_menu(self
, menu
, _s
):
810 context
= self
.context
811 menu
.addAction(self
.launch_difftool_action
)
813 action
= menu
.addAction(
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
)
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(
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?
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():
853 menu
.addAction(self
.revert_unstaged_edits_action
)
855 if all_exist
and s
.untracked
:
856 # Git Annex / Git LFS
858 lfs
= core
.find_executable('git-lfs')
862 menu
.addAction(self
.annex_add_action
)
864 menu
.addAction(self
.lfs_track_action
)
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
)
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
)
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:
888 N_('Launch git-cola'),
889 cmds
.run(cmds
.OpenRepo
, context
, path
),
893 N_('Update this submodule'),
894 cmds
.run(cmds
.SubmoduleUpdate
, context
, path
),
898 if self
.m
.stageable():
900 action
= menu
.addAction(
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
)
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."""
931 unmerged
= s
.unmerged
[0]
933 modified
= s
.modified
[0]
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()
944 if idx
.parent().isValid():
945 parent_idx
= idx
.parent()
946 entry
= (parent_idx
.row(), idx
.row())
948 entry
= (self
.idx_header
, idx
.row())
953 """Return the current selection in the repo status tree."""
954 return selection
.State(
955 self
.staged(), self
.unmerged(), self
.modified(), self
.untracked()
959 return selection
.State(
960 self
.m
.staged
, self
.m
.unmerged
, self
.m
.modified
, self
.m
.untracked
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
):
973 s
= self
.single_selection()
975 for content
, sel
in zip(c
, s
):
979 return offset
+ content
.index(sel
)
980 offset
+= len(content
)
983 def select_by_index(self
, idx
):
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
:
994 if idx
< len(content
):
995 parent
= self
.topLevelItem(toplevel_idx
)
996 item
= parent
.child(idx
)
998 self
.select_item(item
)
1002 def scroll_to_item(self
, item
):
1003 # First, scroll to the item, but keep the original hscroll
1005 hscrollbar
= self
.horizontalScrollBar()
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)
1018 return self
._subtree
_selection
(self
.idx_staged
, self
.m
.staged
)
1021 return self
.unmerged() + self
.modified() + self
.untracked()
1024 return self
._subtree
_selection
(self
.idx_modified
, self
.m
.modified
)
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
, '')
1074 cmds
.do(cmds
.ResetMode
, context
)
1077 # A header item e.g. 'Staged', 'Modified', etc.
1078 category
, idx
= selected_indexes
[0]
1079 header
= category
== self
.idx_header
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
)
1091 staged
= category
== self
.idx_staged
1092 modified
= category
== self
.idx_modified
1093 unmerged
= category
== self
.idx_unmerged
1094 untracked
= category
== self
.idx_untracked
1097 item
= self
.staged_items()[0]
1099 item
= self
.unmerged_items()[0]
1101 item
= self
.modified_items()[0]
1103 item
= self
.unstaged_items()[0]
1105 item
= None # this shouldn't happen
1106 assert item
is not None
1109 deleted
= item
.deleted
1110 image
= self
.image_formats
.ok(path
)
1112 # Update the diff text
1114 cmds
.do(cmds
.DiffStaged
, context
, path
, deleted
=deleted
)
1116 cmds
.do(cmds
.Diff
, context
, path
, deleted
=deleted
)
1118 cmds
.do(cmds
.Diff
, context
, path
)
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.
1137 cmds
.do(cmds
.DiffText
, context
)
1139 def select_header(self
):
1140 """Select an active header, which triggers a diffstat"""
1147 item
= self
.topLevelItem(idx
)
1148 if item
.childCount() > 0:
1149 self
.clearSelection()
1150 self
.setCurrentItem(item
)
1154 idx
= self
.selected_idx()
1155 all_files
= self
.all_files()
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
)
1166 self
.select_by_index(len(all_files
) - 1)
1169 self
.select_by_index(idx
- 1)
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()
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
)
1186 self
.select_by_index(0)
1188 if idx
+ 1 < len(all_files
):
1189 self
.select_by_index(idx
+ 1)
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
):
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
):
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
)
1282 self
.main_layout
= qtutils
.hbox(defs
.no_margin
, defs
.spacing
, self
.text
)
1283 self
.setLayout(self
.main_layout
)
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
:
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
)
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(
1332 defs
.button_spacing
,
1335 self
.show_help_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
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
)
1378 def apply_state(self
, state
):
1379 result
= super(CustomizeCopyActions
, self
).apply_state(state
)
1380 standard
.apply_header_columns(self
.table
, state
)
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
)
1397 """Remove selected items"""
1398 # Gather a unique set of rows and remove them in reverse order
1400 items
= self
.table
.selectedItems()
1402 rows
.add(self
.table
.row(item
))
1404 for row
in reversed(sorted(rows
)):
1405 self
.table
.removeRow(row
)
1409 for row
in range(self
.table
.rowCount()):
1410 name
= self
.table
.item(row
, 0)
1411 fmt
= self
.table
.item(row
, 1)
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
)
1428 def table_selection_changed(self
):
1429 items
= self
.table
.selectedItems()
1430 self
.remove_button
.setEnabled(bool(items
))