1 from __future__
import absolute_import
, division
, print_function
, unicode_literals
4 from functools
import partial
6 from qtpy
.QtCore
import Qt
7 from qtpy
import QtCore
8 from qtpy
import QtWidgets
11 from ..models
import prefs
12 from ..models
import selection
13 from ..widgets
import gitignore
14 from ..widgets
import standard
15 from ..qtutils
import get
16 from ..settings
import Settings
17 from .. import actions
20 from .. import difftool
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 tooltip
= N_('Exit "Diff" mode')
68 icon
= icons
.circle_slash_red()
69 self
.exit_diff_mode_button
= qtutils
.create_action_button(
70 tooltip
=tooltip
, icon
=icon
, visible
=False
73 self
.main_layout
= qtutils
.vbox(
74 defs
.no_margin
, defs
.no_spacing
, self
.filter_widget
, self
.tree
76 self
.setLayout(self
.main_layout
)
78 self
.toggle_action
= qtutils
.add_action(
79 self
, tooltip
, self
.toggle_filter
, hotkeys
.FILTER
82 titlebar
.add_corner_widget(self
.exit_diff_mode_button
)
83 titlebar
.add_corner_widget(self
.filter_button
)
85 qtutils
.connect_button(self
.filter_button
, self
.toggle_filter
)
86 qtutils
.connect_button(
87 self
.exit_diff_mode_button
, cmds
.run(cmds
.ResetMode
, self
.context
)
90 def toggle_filter(self
):
91 """Toggle the paths filter"""
92 shown
= not self
.filter_widget
.isVisible()
93 self
.filter_widget
.setVisible(shown
)
95 self
.filter_widget
.setFocus()
99 def set_initial_size(self
):
100 """Set the initial size of the status widget"""
101 self
.setMaximumWidth(222)
102 QtCore
.QTimer
.singleShot(1, lambda: self
.setMaximumWidth(2**13))
105 """Refresh the tree and rerun the diff to see updates"""
106 self
.tree
.show_selection()
108 def set_filter(self
, txt
):
109 """Set the filter text"""
110 self
.filter_widget
.setVisible(True)
111 self
.filter_widget
.text
.set_value(txt
)
112 self
.filter_widget
.apply_filter()
114 def set_mode(self
, mode
):
115 """React to changes in model's editing mode"""
116 exit_diff_mode_visible
= mode
== self
.context
.model
.mode_diff
117 self
.exit_diff_mode_button
.setVisible(exit_diff_mode_visible
)
123 self
.tree
.move_down()
125 def select_header(self
):
126 self
.tree
.select_header()
129 # pylint: disable=too-many-ancestors
130 class StatusTreeWidget(QtWidgets
.QTreeWidget
):
131 # Read-only access to the mode state
132 mode
= property(lambda self
: self
._model
.mode
)
134 def __init__(self
, context
, parent
=None):
135 QtWidgets
.QTreeWidget
.__init
__(self
, parent
)
136 self
.context
= context
137 self
.selection_model
= context
.selection
139 self
.setSelectionMode(QtWidgets
.QAbstractItemView
.ExtendedSelection
)
140 self
.headerItem().setHidden(True)
141 self
.setAllColumnsShowFocus(True)
142 self
.setSortingEnabled(False)
143 self
.setUniformRowHeights(True)
144 self
.setAnimated(True)
145 self
.setRootIsDecorated(False)
146 self
.setAutoScroll(False)
147 self
.setDragEnabled(True)
148 self
.setDragDropMode(QtWidgets
.QAbstractItemView
.DragOnly
)
149 self
._alt
_drag
= False
151 if not prefs
.status_indent(context
):
152 self
.setIndentation(0)
155 compare
= icons
.compare()
156 question
= icons
.question()
157 self
._add
_toplevel
_item
(N_('Staged'), ok_icon
, hide
=True)
158 self
._add
_toplevel
_item
(N_('Unmerged'), compare
, hide
=True)
159 self
._add
_toplevel
_item
(N_('Modified'), compare
, hide
=True)
160 self
._add
_toplevel
_item
(N_('Untracked'), question
, hide
=True)
162 # Used to restore the selection
163 self
.old_vscroll
= None
164 self
.old_hscroll
= None
165 self
.old_selection
= None
166 self
.old_contents
= None
167 self
.old_current_item
= None
168 self
.previous_contents
= None
169 self
.was_visible
= True
170 self
.expanded_items
= set()
172 self
.image_formats
= qtutils
.ImageFormats()
174 self
.process_selection_action
= qtutils
.add_action(
176 cmds
.StageOrUnstage
.name(),
177 self
._stage
_selection
,
178 hotkeys
.STAGE_SELECTION
,
180 self
.process_selection_action
.setIcon(icons
.add())
182 self
.stage_or_unstage_all_action
= qtutils
.add_action(
184 cmds
.StageOrUnstageAll
.name(),
185 cmds
.run(cmds
.StageOrUnstageAll
, self
.context
),
188 self
.stage_or_unstage_all_action
.setIcon(icons
.add())
190 self
.revert_unstaged_edits_action
= qtutils
.add_action(
192 cmds
.RevertUnstagedEdits
.name(),
193 cmds
.run(cmds
.RevertUnstagedEdits
, context
),
196 self
.revert_unstaged_edits_action
.setIcon(icons
.undo())
198 self
.launch_difftool_action
= qtutils
.add_action(
200 difftool
.LaunchDifftool
.name(),
201 cmds
.run(difftool
.LaunchDifftool
, context
),
204 self
.launch_difftool_action
.setIcon(icons
.diff())
206 self
.launch_editor_action
= actions
.launch_editor_at_line(
207 context
, self
, *hotkeys
.ACCEPT
210 self
.default_app_action
= common
.default_app_action(
211 context
, self
, self
.selected_group
214 self
.parent_dir_action
= common
.parent_dir_action(
215 context
, self
, self
.selected_group
218 self
.worktree_dir_action
= common
.worktree_dir_action(context
, self
)
220 self
.terminal_action
= common
.terminal_action(
221 context
, self
, func
=self
.selected_group
224 self
.up_action
= qtutils
.add_action(
229 hotkeys
.MOVE_UP_SECONDARY
,
232 self
.down_action
= qtutils
.add_action(
237 hotkeys
.MOVE_DOWN_SECONDARY
,
240 # Checkout the selected paths using "git checkout --ours".
241 self
.checkout_ours_action
= qtutils
.add_action(
242 self
, cmds
.CheckoutOurs
.name(), cmds
.run(cmds
.CheckoutOurs
, context
)
245 # Checkout the selected paths using "git checkout --theirs".
246 self
.checkout_theirs_action
= qtutils
.add_action(
247 self
, cmds
.CheckoutTheirs
.name(), cmds
.run(cmds
.CheckoutTheirs
, context
)
250 self
.copy_path_action
= qtutils
.add_action(
252 N_('Copy Path to Clipboard'),
253 partial(copy_path
, context
),
256 self
.copy_path_action
.setIcon(icons
.copy())
258 self
.copy_relpath_action
= qtutils
.add_action(
260 N_('Copy Relative Path to Clipboard'),
261 partial(copy_relpath
, context
),
264 self
.copy_relpath_action
.setIcon(icons
.copy())
266 self
.copy_leading_paths_value
= 1
268 self
.copy_basename_action
= qtutils
.add_action(
269 self
, N_('Copy Basename to Clipboard'), partial(copy_basename
, context
)
271 self
.copy_basename_action
.setIcon(icons
.copy())
273 self
.copy_customize_action
= qtutils
.add_action(
274 self
, N_('Customize...'), partial(customize_copy_actions
, context
, self
)
276 self
.copy_customize_action
.setIcon(icons
.configure())
278 self
.view_history_action
= qtutils
.add_action(
279 self
, N_('View History...'), partial(view_history
, context
), hotkeys
.HISTORY
282 self
.view_blame_action
= qtutils
.add_action(
283 self
, N_('Blame...'), partial(view_blame
, context
), hotkeys
.BLAME
286 self
.annex_add_action
= qtutils
.add_action(
287 self
, N_('Add to Git Annex'), cmds
.run(cmds
.AnnexAdd
, context
)
290 self
.lfs_track_action
= qtutils
.add_action(
291 self
, N_('Add to Git LFS'), cmds
.run(cmds
.LFSTrack
, context
)
294 # MoveToTrash and Delete use the same shortcut.
295 # We will only bind one of them, depending on whether or not the
296 # MoveToTrash command is available. When available, the hotkey
297 # is bound to MoveToTrash, otherwise it is bound to Delete.
298 if cmds
.MoveToTrash
.AVAILABLE
:
299 self
.move_to_trash_action
= qtutils
.add_action(
301 N_('Move files to trash'),
302 self
._trash
_untracked
_files
,
305 self
.move_to_trash_action
.setIcon(icons
.discard())
306 delete_shortcut
= hotkeys
.DELETE_FILE
308 self
.move_to_trash_action
= None
309 delete_shortcut
= hotkeys
.DELETE_FILE_SECONDARY
311 self
.delete_untracked_files_action
= qtutils
.add_action(
312 self
, N_('Delete Files...'), self
._delete
_untracked
_files
, delete_shortcut
314 self
.delete_untracked_files_action
.setIcon(icons
.discard())
316 # The model is stored as self._model because self.model() is a
317 # QTreeWidgetItem method that returns a QAbstractItemModel.
318 self
._model
= context
.model
319 self
._model
.previous_contents
.connect(
320 self
._set
_previous
_contents
, type=Qt
.QueuedConnection
322 self
._model
.about_to_update
.connect(
323 self
._about
_to
_update
, type=Qt
.QueuedConnection
325 self
._model
.updated
.connect(self
.refresh
, type=Qt
.QueuedConnection
)
326 self
._model
.diff_text_changed
.connect(
327 self
._make
_current
_item
_visible
, type=Qt
.QueuedConnection
329 # pylint: disable=no-member
330 self
.itemSelectionChanged
.connect(self
.show_selection
)
331 self
.itemDoubleClicked
.connect(cmds
.run(cmds
.StageOrUnstage
, self
.context
))
332 self
.itemCollapsed
.connect(lambda x
: self
._update
_column
_widths
())
333 self
.itemExpanded
.connect(lambda x
: self
._update
_column
_widths
())
335 def _make_current_item_visible(self
):
336 item
= self
.currentItem()
338 qtutils
.scroll_to_item(self
, item
)
340 def _add_toplevel_item(self
, txt
, icon
, hide
=False):
341 context
= self
.context
343 if prefs
.bold_headers(context
):
348 item
= QtWidgets
.QTreeWidgetItem(self
)
349 item
.setFont(0, font
)
351 item
.setIcon(0, icon
)
352 if prefs
.bold_headers(context
):
353 item
.setBackground(0, self
.palette().midlight())
357 def _restore_selection(self
):
358 """Apply the old selection to the newly updated items"""
359 # This function is called after a new set of items have been added to
360 # the per-category file lists. Its purpose is to either restore the
361 # existing selection or to create a new intuitive selection based on
362 # a combination of the old items, the old selection and the new items.
363 if not self
.old_selection
or not self
.old_contents
:
365 # The old set of categorized files.
366 old_c
= self
.old_contents
368 old_s
= self
.old_selection
369 # The current/new set of categorized files.
370 new_c
= self
.contents()
372 select_staged
= partial(_select_item
, self
, new_c
.staged
, self
._staged
_item
)
373 select_unmerged
= partial(
374 _select_item
, self
, new_c
.unmerged
, self
._unmerged
_item
376 select_modified
= partial(
377 _select_item
, self
, new_c
.modified
, self
._modified
_item
379 select_untracked
= partial(
380 _select_item
, self
, new_c
.untracked
, self
._untracked
_item
384 (set(new_c
.staged
), old_c
.staged
, set(old_s
.staged
), select_staged
),
385 (set(new_c
.unmerged
), old_c
.unmerged
, set(old_s
.unmerged
), select_unmerged
),
386 (set(new_c
.modified
), old_c
.modified
, set(old_s
.modified
), select_modified
),
388 set(new_c
.untracked
),
390 set(old_s
.untracked
),
395 # Restore the current item
396 if self
.old_current_item
:
397 category
, idx
= self
.old_current_item
398 if _apply_toplevel_selection(self
, category
, idx
):
400 # Reselect the current item
401 selection_info
= saved_selection
[category
]
402 new
= selection_info
[NEW_PATHS_IDX
]
403 old
= selection_info
[OLD_PATHS_IDX
]
404 reselect
= selection_info
[SELECT_FN_IDX
]
409 if item
and item
in new
:
410 reselect(item
, current
=True)
412 # Restore previously selected items.
413 # When reselecting in this section we only care that the items are
414 # selected; we do not need to rerun the callbacks which were triggered
415 # above for the current item. Block signals to skip the callbacks.
417 # Reselect items that were previously selected and still exist in the
418 # current path lists. This handles a common case such as a Ctrl-R
419 # refresh which results in the same exact path state.
422 with qtutils
.BlockSignals(self
):
423 for new
, old
, sel
, reselect
in saved_selection
:
426 reselect(item
, current
=False)
429 # The status widget is used to interactively work your way down the
430 # list of Staged, Unmerged, Modified and Untracked items and perform
431 # an operation on them.
433 # For Staged items we intend to work our way down the list of Staged
434 # items while we unstage each item. For every other category we work
435 # our way down the list of {Unmerged,Modified,Untracked} items while
436 # we stage each item.
438 # The following block of code implements the behavior of selecting
439 # the next item based on the previous selection.
440 for new
, old
, sel
, reselect
in saved_selection
:
441 # When modified is staged, select the next modified item
442 # When unmerged is staged, select the next unmerged item
443 # When unstaging, select the next staged item
444 # When staging untracked files, select the next untracked item
445 if len(new
) >= len(old
):
446 # The list did not shrink so it is not one of these cases.
449 # The item still exists so ignore it
450 if item
in new
or item
not in old
:
452 # The item no longer exists in this list so search for
453 # its nearest neighbors and select them instead.
454 idx
= old
.index(item
)
455 for j
in itertools
.chain(old
[idx
+ 1 :], reversed(old
[:idx
])):
457 reselect(j
, current
=True)
460 # If we already reselected stuff then there's nothing more to do.
463 # If we got this far then nothing was reselected and made current.
464 # Try a few more heuristics that we can use to keep something selected.
465 if self
.old_current_item
:
466 category
, idx
= self
.old_current_item
467 _transplant_selection_across_sections(
468 category
, idx
, self
.previous_contents
, saved_selection
471 def _restore_scrollbars(self
):
472 """Restore scrollbars to the stored values"""
473 qtutils
.set_scrollbar_values(self
, self
.old_hscroll
, self
.old_vscroll
)
474 self
.old_hscroll
= None
475 self
.old_vscroll
= None
477 def _stage_selection(self
):
478 """Stage or unstage files according to the selection"""
479 context
= self
.context
480 selected_indexes
= self
.selected_indexes()
481 is_header
= any(category
== HEADER_IDX
for (category
, idx
) in selected_indexes
)
484 idx
== STAGED_IDX
and category
== HEADER_IDX
485 for (category
, idx
) in selected_indexes
488 idx
== MODIFIED_IDX
and category
== HEADER_IDX
489 for (category
, idx
) in selected_indexes
492 idx
== UNTRACKED_IDX
and category
== HEADER_IDX
493 for (category
, idx
) in selected_indexes
495 # A header item: 'Staged', 'Modified' or 'Untracked'.
497 # If we have the staged header selected then the only sensible
498 # thing to do is to unstage everything and nothing else, even
499 # if the modified or untracked headers are selected.
500 cmds
.do(cmds
.UnstageAll
, context
)
501 return # Everything was unstaged. There's nothing more to be done.
502 if is_modified
and is_untracked
:
503 # If both modified and untracked headers are selected then
505 cmds
.do(cmds
.StageModifiedAndUntracked
, context
)
506 return # Nothing more to do.
507 # At this point we may stage all modified and untracked, and then
508 # possibly a subset of the other category (eg. all modified and
509 # some untracked). We don't return here so that StageOrUnstage
510 # gets a chance to run below.
512 cmds
.do(cmds
.StageModified
, context
)
514 cmds
.do(cmds
.StageUntracked
, context
)
516 # Do nothing for unmerged items, by design
518 # Now handle individual files
519 cmds
.do(cmds
.StageOrUnstage
, context
)
521 def _staged_item(self
, itemidx
):
522 return self
._subtree
_item
(STAGED_IDX
, itemidx
)
524 def _modified_item(self
, itemidx
):
525 return self
._subtree
_item
(MODIFIED_IDX
, itemidx
)
527 def _unmerged_item(self
, itemidx
):
528 return self
._subtree
_item
(UNMERGED_IDX
, itemidx
)
530 def _untracked_item(self
, itemidx
):
531 return self
._subtree
_item
(UNTRACKED_IDX
, itemidx
)
533 def _unstaged_item(self
, itemidx
):
535 item
= self
.topLevelItem(MODIFIED_IDX
)
536 count
= item
.childCount()
538 return item
.child(itemidx
)
540 item
= self
.topLevelItem(UNMERGED_IDX
)
541 count
+= item
.childCount()
543 return item
.child(itemidx
)
545 item
= self
.topLevelItem(UNTRACKED_IDX
)
546 count
+= item
.childCount()
548 return item
.child(itemidx
)
552 def _subtree_item(self
, idx
, itemidx
):
553 parent
= self
.topLevelItem(idx
)
554 return parent
.child(itemidx
)
556 def _set_previous_contents(self
, staged
, unmerged
, modified
, untracked
):
557 """Callback triggered right before the model changes its contents"""
558 self
.previous_contents
= selection
.State(staged
, unmerged
, modified
, untracked
)
560 def _about_to_update(self
):
561 self
._save
_scrollbars
()
562 self
._save
_selection
()
564 def _save_scrollbars(self
):
565 """Store the scrollbar values for later application"""
566 hscroll
, vscroll
= qtutils
.get_scrollbar_values(self
)
567 if hscroll
is not None:
568 self
.old_hscroll
= hscroll
569 if vscroll
is not None:
570 self
.old_vscroll
= vscroll
572 def current_item(self
):
573 s
= self
.selected_indexes()
576 current
= self
.currentItem()
579 idx
= self
.indexFromItem(current
)
580 if idx
.parent().isValid():
581 parent_idx
= idx
.parent()
582 entry
= (parent_idx
.row(), idx
.row())
584 entry
= (HEADER_IDX
, idx
.row())
587 def _save_selection(self
):
588 self
.old_contents
= self
.contents()
589 self
.old_selection
= self
.selection()
590 self
.old_current_item
= self
.current_item()
593 self
._set
_staged
(self
._model
.staged
)
594 self
._set
_modified
(self
._model
.modified
)
595 self
._set
_unmerged
(self
._model
.unmerged
)
596 self
._set
_untracked
(self
._model
.untracked
)
597 self
._update
_column
_widths
()
598 self
._update
_actions
()
599 self
._restore
_selection
()
600 self
._restore
_scrollbars
()
602 def _update_actions(self
, selected
=None):
604 selected
= self
.selection_model
.selection()
605 can_revert_edits
= bool(selected
.staged
or selected
.modified
)
606 self
.revert_unstaged_edits_action
.setEnabled(can_revert_edits
)
608 enabled
= self
.selection_model
.filename() is not None
609 self
.default_app_action
.setEnabled(enabled
)
610 self
.parent_dir_action
.setEnabled(enabled
)
611 self
.copy_path_action
.setEnabled(enabled
)
612 self
.copy_relpath_action
.setEnabled(enabled
)
613 self
.copy_basename_action
.setEnabled(enabled
)
615 def _set_staged(self
, items
):
616 """Adds items to the 'Staged' subtree."""
617 with qtutils
.BlockSignals(self
):
623 deleted_set
=self
._model
.staged_deleted
,
626 def _set_modified(self
, items
):
627 """Adds items to the 'Modified' subtree."""
628 with qtutils
.BlockSignals(self
):
633 deleted_set
=self
._model
.unstaged_deleted
,
636 def _set_unmerged(self
, items
):
637 """Adds items to the 'Unmerged' subtree."""
638 deleted_set
= {path
for path
in items
if not core
.exists(path
)}
639 with qtutils
.BlockSignals(self
):
641 items
, UNMERGED_IDX
, N_('Unmerged'), deleted_set
=deleted_set
644 def _set_untracked(self
, items
):
645 """Adds items to the 'Untracked' subtree."""
646 with qtutils
.BlockSignals(self
):
647 self
._set
_subtree
(items
, UNTRACKED_IDX
, N_('Untracked'), untracked
=True)
650 self
, items
, idx
, parent_title
, staged
=False, untracked
=False, deleted_set
=None
652 """Add a list of items to a treewidget item."""
653 parent
= self
.topLevelItem(idx
)
654 hide
= not bool(items
)
655 parent
.setHidden(hide
)
657 # sip v4.14.7 and below leak memory in parent.takeChildren()
658 # so we use this backwards-compatible construct instead
659 while parent
.takeChild(0) is not None:
663 deleted
= deleted_set
is not None and item
in deleted_set
664 treeitem
= qtutils
.create_treeitem(
665 item
, staged
=staged
, deleted
=deleted
, untracked
=untracked
667 parent
.addChild(treeitem
)
668 self
._expand
_items
(idx
, items
)
670 if prefs
.status_show_totals(self
.context
):
671 parent
.setText(0, '%s (%s)' % (parent_title
, len(items
)))
673 def _update_column_widths(self
):
674 self
.resizeColumnToContents(0)
676 def _expand_items(self
, idx
, items
):
677 """Expand the top-level category "folder" once and only once."""
678 # Don't do this if items is empty; this makes it so that we
679 # don't add the top-level index into the expanded_items set
680 # until an item appears in a particular category.
683 # Only run this once; we don't want to re-expand items that
684 # we've clicked on to re-collapse on updated().
685 if idx
in self
.expanded_items
:
687 self
.expanded_items
.add(idx
)
688 item
= self
.topLevelItem(idx
)
690 self
.expandItem(item
)
692 def contextMenuEvent(self
, event
):
693 """Create context menus for the repo status tree."""
694 menu
= self
._create
_context
_menu
()
695 menu
.exec_(self
.mapToGlobal(event
.pos()))
697 def _create_context_menu(self
):
698 """Set up the status menu for the repo status tree."""
699 sel
= self
.selection()
700 menu
= qtutils
.create_menu('Status', self
)
701 selected_indexes
= self
.selected_indexes()
703 category
, idx
= selected_indexes
[0]
704 # A header item e.g. 'Staged', 'Modified', etc.
705 if category
== HEADER_IDX
:
706 return self
._create
_header
_context
_menu
(menu
, idx
)
709 self
._create
_staged
_context
_menu
(menu
, sel
)
711 self
._create
_unmerged
_context
_menu
(menu
, sel
)
713 self
._create
_unstaged
_context
_menu
(menu
, sel
)
715 if not menu
.isEmpty():
718 if not self
.selection_model
.is_empty():
719 menu
.addAction(self
.default_app_action
)
720 menu
.addAction(self
.parent_dir_action
)
722 if self
.terminal_action
is not None:
723 menu
.addAction(self
.terminal_action
)
725 menu
.addAction(self
.worktree_dir_action
)
727 self
._add
_copy
_actions
(menu
)
731 def _add_copy_actions(self
, menu
):
732 """Add the "Copy" sub-menu"""
733 enabled
= self
.selection_model
.filename() is not None
734 self
.copy_path_action
.setEnabled(enabled
)
735 self
.copy_relpath_action
.setEnabled(enabled
)
736 self
.copy_basename_action
.setEnabled(enabled
)
738 copy_menu
= QtWidgets
.QMenu(N_('Copy...'), menu
)
739 copy_icon
= icons
.copy()
740 copy_menu
.setIcon(copy_icon
)
742 copy_leading_path_action
= QtWidgets
.QWidgetAction(copy_menu
)
743 copy_leading_path_action
.setEnabled(enabled
)
745 widget
= CopyLeadingPathWidget(
746 N_('Copy Leading Path to Clipboard'), self
.context
, copy_menu
749 # Store the value of the leading paths spinbox so that the value does not reset
750 # everytime the menu is shown and recreated.
751 widget
.set_value(self
.copy_leading_paths_value
)
752 widget
.spinbox
.valueChanged
.connect(
753 partial(setattr, self
, 'copy_leading_paths_value')
755 copy_leading_path_action
.setDefaultWidget(widget
)
757 # Copy the leading path when the action is activated.
758 qtutils
.connect_action(
759 copy_leading_path_action
,
760 lambda widget
=widget
: copy_leading_path(context
, widget
.value()),
764 menu
.addMenu(copy_menu
)
765 copy_menu
.addAction(self
.copy_path_action
)
766 copy_menu
.addAction(self
.copy_relpath_action
)
767 copy_menu
.addAction(copy_leading_path_action
)
768 copy_menu
.addAction(self
.copy_basename_action
)
770 settings
= Settings
.read()
771 copy_formats
= settings
.copy_formats
773 copy_menu
.addSeparator()
775 context
= self
.context
776 for entry
in copy_formats
:
777 name
= entry
.get('name', '')
778 fmt
= entry
.get('format', '')
780 action
= copy_menu
.addAction(name
, partial(copy_format
, context
, fmt
))
781 action
.setIcon(copy_icon
)
782 action
.setEnabled(enabled
)
784 copy_menu
.addSeparator()
785 copy_menu
.addAction(self
.copy_customize_action
)
787 def _create_header_context_menu(self
, menu
, idx
):
788 context
= self
.context
789 if idx
== STAGED_IDX
:
791 icons
.remove(), N_('Unstage All'), cmds
.run(cmds
.UnstageAll
, context
)
793 elif idx
== UNMERGED_IDX
:
794 action
= menu
.addAction(
796 cmds
.StageUnmerged
.name(),
797 cmds
.run(cmds
.StageUnmerged
, context
),
799 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
800 elif idx
== MODIFIED_IDX
:
801 action
= menu
.addAction(
803 cmds
.StageModified
.name(),
804 cmds
.run(cmds
.StageModified
, context
),
806 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
807 elif idx
== UNTRACKED_IDX
:
808 action
= menu
.addAction(
810 cmds
.StageUntracked
.name(),
811 cmds
.run(cmds
.StageUntracked
, context
),
813 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
816 def _create_staged_context_menu(self
, menu
, s
):
817 if s
.staged
[0] in self
._model
.submodules
:
818 return self
._create
_staged
_submodule
_context
_menu
(menu
, s
)
820 context
= self
.context
821 if self
._model
.is_unstageable():
822 action
= menu
.addAction(
824 N_('Unstage Selected'),
825 cmds
.run(cmds
.Unstage
, context
, self
.staged()),
827 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
829 menu
.addAction(self
.launch_editor_action
)
831 # Do all of the selected items exist?
833 i
not in self
._model
.staged_deleted
and core
.exists(i
)
834 for i
in self
.staged()
838 menu
.addAction(self
.launch_difftool_action
)
840 if self
._model
.is_undoable():
841 menu
.addAction(self
.revert_unstaged_edits_action
)
843 menu
.addAction(self
.view_history_action
)
844 menu
.addAction(self
.view_blame_action
)
847 def _create_staged_submodule_context_menu(self
, menu
, s
):
848 context
= self
.context
849 path
= core
.abspath(s
.staged
[0])
850 if len(self
.staged()) == 1:
853 N_('Launch git-cola'),
854 cmds
.run(cmds
.OpenRepo
, context
, path
),
857 action
= menu
.addAction(
859 N_('Unstage Selected'),
860 cmds
.run(cmds
.Unstage
, context
, self
.staged()),
862 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
864 menu
.addAction(self
.view_history_action
)
867 def _create_unmerged_context_menu(self
, menu
, _s
):
868 context
= self
.context
869 menu
.addAction(self
.launch_difftool_action
)
871 action
= menu
.addAction(
873 N_('Stage Selected'),
874 cmds
.run(cmds
.Stage
, context
, self
.unstaged()),
876 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
878 menu
.addAction(self
.launch_editor_action
)
879 menu
.addAction(self
.view_history_action
)
880 menu
.addAction(self
.view_blame_action
)
882 menu
.addAction(self
.checkout_ours_action
)
883 menu
.addAction(self
.checkout_theirs_action
)
886 def _create_unstaged_context_menu(self
, menu
, s
):
887 context
= self
.context
888 modified_submodule
= s
.modified
and s
.modified
[0] in self
._model
.submodules
889 if modified_submodule
:
890 return self
._create
_modified
_submodule
_context
_menu
(menu
, s
)
892 if self
._model
.is_stageable():
893 action
= menu
.addAction(
895 N_('Stage Selected'),
896 cmds
.run(cmds
.Stage
, context
, self
.unstaged()),
898 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
900 if not self
.selection_model
.is_empty():
901 menu
.addAction(self
.launch_editor_action
)
903 # Do all of the selected items exist?
905 i
not in self
._model
.unstaged_deleted
and core
.exists(i
)
906 for i
in self
.staged()
909 if all_exist
and s
.modified
and self
._model
.is_stageable():
910 menu
.addAction(self
.launch_difftool_action
)
912 if s
.modified
and self
._model
.is_stageable() and self
._model
.is_undoable():
914 menu
.addAction(self
.revert_unstaged_edits_action
)
916 if all_exist
and s
.untracked
:
917 # Git Annex / Git LFS
918 annex
= self
._model
.annex
919 lfs
= core
.find_executable('git-lfs')
923 menu
.addAction(self
.annex_add_action
)
925 menu
.addAction(self
.lfs_track_action
)
928 if self
.move_to_trash_action
is not None:
929 menu
.addAction(self
.move_to_trash_action
)
930 menu
.addAction(self
.delete_untracked_files_action
)
935 partial(gitignore
.gitignore_view
, self
.context
),
938 if not self
.selection_model
.is_empty():
939 menu
.addAction(self
.view_history_action
)
940 menu
.addAction(self
.view_blame_action
)
943 def _create_modified_submodule_context_menu(self
, menu
, s
):
944 context
= self
.context
945 path
= core
.abspath(s
.modified
[0])
946 if len(self
.unstaged()) == 1:
949 N_('Launch git-cola'),
950 cmds
.run(cmds
.OpenRepo
, context
, path
),
954 N_('Update this submodule'),
955 cmds
.run(cmds
.SubmoduleUpdate
, context
, path
),
959 if self
._model
.is_stageable():
961 action
= menu
.addAction(
963 N_('Stage Selected'),
964 cmds
.run(cmds
.Stage
, context
, self
.unstaged()),
966 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
968 menu
.addAction(self
.view_history_action
)
971 def _delete_untracked_files(self
):
972 cmds
.do(cmds
.Delete
, self
.context
, self
.untracked())
974 def _trash_untracked_files(self
):
975 cmds
.do(cmds
.MoveToTrash
, self
.context
, self
.untracked())
977 def selected_path(self
):
978 s
= self
.single_selection()
979 return s
.staged
or s
.unmerged
or s
.modified
or s
.untracked
or None
981 def single_selection(self
):
982 """Scan across staged, modified, etc. and return a single item."""
992 unmerged
= s
.unmerged
[0]
994 modified
= s
.modified
[0]
996 untracked
= s
.untracked
[0]
998 return selection
.State(staged
, unmerged
, modified
, untracked
)
1000 def selected_indexes(self
):
1001 """Returns a list of (category, row) representing the tree selection."""
1002 selected
= self
.selectedIndexes()
1004 for idx
in selected
:
1005 if idx
.parent().isValid():
1006 parent_idx
= idx
.parent()
1007 entry
= (parent_idx
.row(), idx
.row())
1009 entry
= (HEADER_IDX
, idx
.row())
1010 result
.append(entry
)
1013 def selection(self
):
1014 """Return the current selection in the repo status tree."""
1015 return selection
.State(
1016 self
.staged(), self
.unmerged(), self
.modified(), self
.untracked()
1020 """Return all of the current files in a selection.State container"""
1021 return selection
.State(
1023 self
._model
.unmerged
,
1024 self
._model
.modified
,
1025 self
._model
.untracked
,
1028 def all_files(self
):
1029 """Return all of the current active files as a flast list"""
1031 return c
.staged
+ c
.unmerged
+ c
.modified
+ c
.untracked
1033 def selected_group(self
):
1034 """A list of selected files in various states of being"""
1035 return selection
.pick(self
.selection())
1037 def selected_idx(self
):
1039 s
= self
.single_selection()
1041 for content
, sel
in zip(c
, s
):
1045 return offset
+ content
.index(sel
)
1046 offset
+= len(content
)
1049 def select_by_index(self
, idx
):
1052 (c
.staged
, STAGED_IDX
),
1053 (c
.unmerged
, UNMERGED_IDX
),
1054 (c
.modified
, MODIFIED_IDX
),
1055 (c
.untracked
, UNTRACKED_IDX
),
1057 for content
, toplevel_idx
in to_try
:
1060 if idx
< len(content
):
1061 parent
= self
.topLevelItem(toplevel_idx
)
1062 item
= parent
.child(idx
)
1063 if item
is not None:
1064 qtutils
.select_item(self
, item
)
1069 return qtutils
.get_selected_values(self
, STAGED_IDX
, self
._model
.staged
)
1072 return self
.unmerged() + self
.modified() + self
.untracked()
1075 return qtutils
.get_selected_values(self
, MODIFIED_IDX
, self
._model
.modified
)
1078 return qtutils
.get_selected_values(self
, UNMERGED_IDX
, self
._model
.unmerged
)
1080 def untracked(self
):
1081 return qtutils
.get_selected_values(self
, UNTRACKED_IDX
, self
._model
.untracked
)
1083 def staged_items(self
):
1084 return qtutils
.get_selected_items(self
, STAGED_IDX
)
1086 def unstaged_items(self
):
1087 return self
.unmerged_items() + self
.modified_items() + self
.untracked_items()
1089 def modified_items(self
):
1090 return qtutils
.get_selected_items(self
, MODIFIED_IDX
)
1092 def unmerged_items(self
):
1093 return qtutils
.get_selected_items(self
, UNMERGED_IDX
)
1095 def untracked_items(self
):
1096 return qtutils
.get_selected_items(self
, UNTRACKED_IDX
)
1098 def show_selection(self
):
1099 """Show the selected item."""
1100 context
= self
.context
1101 qtutils
.scroll_to_item(self
, self
.currentItem())
1102 # Sync the selection model
1103 selected
= self
.selection()
1104 selection_model
= self
.selection_model
1105 selection_model
.set_selection(selected
)
1106 self
._update
_actions
(selected
=selected
)
1108 selected_indexes
= self
.selected_indexes()
1109 if not selected_indexes
:
1110 if self
._model
.is_amend_mode() or self
._model
.is_diff_mode():
1111 cmds
.do(cmds
.SetDiffText
, context
, '')
1113 cmds
.do(cmds
.ResetMode
, context
)
1116 # A header item e.g. 'Staged', 'Modified', etc.
1117 category
, idx
= selected_indexes
[0]
1118 header
= category
== HEADER_IDX
1121 STAGED_IDX
: cmds
.DiffStagedSummary
,
1122 MODIFIED_IDX
: cmds
.Diffstat
,
1123 UNMERGED_IDX
: cmds
.UnmergedSummary
,
1124 UNTRACKED_IDX
: cmds
.UntrackedSummary
,
1125 }.get(idx
, cmds
.Diffstat
)
1126 cmds
.do(cls
, context
)
1129 staged
= category
== STAGED_IDX
1130 modified
= category
== MODIFIED_IDX
1131 unmerged
= category
== UNMERGED_IDX
1132 untracked
= category
== UNTRACKED_IDX
1135 item
= self
.staged_items()[0]
1137 item
= self
.unmerged_items()[0]
1139 item
= self
.modified_items()[0]
1141 item
= self
.unstaged_items()[0]
1143 item
= None # this shouldn't happen
1144 assert item
is not None
1147 deleted
= item
.deleted
1148 image
= self
.image_formats
.ok(path
)
1150 # Update the diff text
1152 cmds
.do(cmds
.DiffStaged
, context
, path
, deleted
=deleted
)
1154 cmds
.do(cmds
.Diff
, context
, path
, deleted
=deleted
)
1156 cmds
.do(cmds
.Diff
, context
, path
)
1158 cmds
.do(cmds
.ShowUntracked
, context
, path
)
1160 # Images are diffed differently.
1161 # DiffImage transitions the diff mode to image.
1162 # DiffText transitions the diff mode to text.
1175 cmds
.do(cmds
.DiffText
, context
)
1177 def select_header(self
):
1178 """Select an active header, which triggers a diffstat"""
1185 item
= self
.topLevelItem(idx
)
1186 if item
.childCount() > 0:
1187 self
.clearSelection()
1188 self
.setCurrentItem(item
)
1192 """Select the item above the currently selected item"""
1193 idx
= self
.selected_idx()
1194 all_files
= self
.all_files()
1196 selected_indexes
= self
.selected_indexes()
1197 if selected_indexes
:
1198 category
, toplevel_idx
= selected_indexes
[0]
1199 if category
== HEADER_IDX
:
1200 item
= self
.itemAbove(self
.topLevelItem(toplevel_idx
))
1201 if item
is not None:
1202 qtutils
.select_item(self
, item
)
1205 self
.select_by_index(len(all_files
) - 1)
1208 self
.select_by_index(idx
- 1)
1210 self
.select_by_index(len(all_files
) - 1)
1212 def move_down(self
):
1213 """Select the item below the currently selected item"""
1214 idx
= self
.selected_idx()
1215 all_files
= self
.all_files()
1217 selected_indexes
= self
.selected_indexes()
1218 if selected_indexes
:
1219 category
, toplevel_idx
= selected_indexes
[0]
1220 if category
== HEADER_IDX
:
1221 item
= self
.itemBelow(self
.topLevelItem(toplevel_idx
))
1222 if item
is not None:
1223 qtutils
.select_item(self
, item
)
1226 self
.select_by_index(0)
1228 if idx
+ 1 < len(all_files
):
1229 self
.select_by_index(idx
+ 1)
1231 self
.select_by_index(0)
1233 def mousePressEvent(self
, event
):
1234 """Keep track of whether to drag URLs or just text"""
1235 self
._alt
_drag
= event
.modifiers() & Qt
.AltModifier
1236 return super(StatusTreeWidget
, self
).mousePressEvent(event
)
1238 def mouseMoveEvent(self
, event
):
1239 """Keep track of whether to drag URLs or just text"""
1240 self
._alt
_drag
= event
.modifiers() & Qt
.AltModifier
1241 return super(StatusTreeWidget
, self
).mouseMoveEvent(event
)
1243 def mimeData(self
, items
):
1244 """Return a list of absolute-path URLs"""
1245 context
= self
.context
1246 paths
= qtutils
.paths_from_items(items
, item_filter
=_item_filter
)
1247 include_urls
= not self
._alt
_drag
1248 return qtutils
.mimedata_from_paths(context
, paths
, include_urls
=include_urls
)
1250 def mimeTypes(self
):
1251 """Return the mimetypes that this widget generates"""
1252 return qtutils
.path_mimetypes(include_urls
=not self
._alt
_drag
)
1255 def _item_filter(item
):
1256 """Filter items down to just those that exist on disk"""
1257 return not item
.deleted
and core
.exists(item
.path
)
1260 def view_blame(context
):
1261 """Signal that we should view blame for paths."""
1262 cmds
.do(cmds
.BlamePaths
, context
)
1265 def view_history(context
):
1266 """Signal that we should view history for paths."""
1267 cmds
.do(cmds
.VisualizePaths
, context
, context
.selection
.union())
1270 def copy_path(context
, absolute
=True):
1271 """Copy a selected path to the clipboard"""
1272 filename
= context
.selection
.filename()
1273 qtutils
.copy_path(filename
, absolute
=absolute
)
1276 def copy_relpath(context
):
1277 """Copy a selected relative path to the clipboard"""
1278 copy_path(context
, absolute
=False)
1281 def copy_basename(context
):
1282 filename
= os
.path
.basename(context
.selection
.filename())
1283 basename
, _
= os
.path
.splitext(filename
)
1284 qtutils
.copy_path(basename
, absolute
=False)
1287 def copy_leading_path(context
, strip_components
):
1288 """Peal off trailing path components and copy the current path to the clipboard"""
1289 filename
= context
.selection
.filename()
1291 for _
in range(strip_components
):
1292 value
= os
.path
.dirname(value
)
1293 qtutils
.copy_path(value
, absolute
=False)
1296 def copy_format(context
, fmt
):
1297 """Add variables usable in the custom Copy format strings"""
1299 values
['path'] = path
= context
.selection
.filename()
1300 values
['abspath'] = abspath
= os
.path
.abspath(path
)
1301 values
['absdirname'] = os
.path
.dirname(abspath
)
1302 values
['dirname'] = os
.path
.dirname(path
)
1303 values
['filename'] = os
.path
.basename(path
)
1304 values
['basename'], values
['ext'] = os
.path
.splitext(os
.path
.basename(path
))
1305 qtutils
.set_clipboard(fmt
% values
)
1308 def show_help(context
):
1309 """Display the help for the custom Copy format strings"""
1312 Format String Variables
1313 -----------------------
1314 %(path)s = relative file path
1315 %(abspath)s = absolute file path
1316 %(dirname)s = relative directory path
1317 %(absdirname)s = absolute directory path
1318 %(filename)s = file basename
1319 %(basename)s = file basename without extension
1320 %(ext)s = file extension
1323 title
= N_('Help - Custom Copy Actions')
1324 return text
.text_dialog(context
, help_text
, title
)
1327 class StatusFilterWidget(QtWidgets
.QWidget
):
1328 """Filter paths displayed by the Status tool"""
1330 def __init__(self
, context
, parent
=None):
1331 QtWidgets
.QWidget
.__init
__(self
, parent
)
1332 self
.context
= context
1334 hint
= N_('Filter paths...')
1335 self
.text
= completion
.GitStatusFilterLineEdit(context
, hint
=hint
, parent
=self
)
1336 self
.text
.setToolTip(hint
)
1337 self
.setFocusProxy(self
.text
)
1340 self
.main_layout
= qtutils
.hbox(defs
.no_margin
, defs
.spacing
, self
.text
)
1341 self
.setLayout(self
.main_layout
)
1344 # pylint: disable=no-member
1345 widget
.changed
.connect(self
.apply_filter
)
1346 widget
.cleared
.connect(self
.apply_filter
)
1347 widget
.enter
.connect(self
.apply_filter
)
1348 widget
.editingFinished
.connect(self
.apply_filter
)
1350 def apply_filter(self
):
1351 """Apply the text filter to the model"""
1352 value
= get(self
.text
)
1353 if value
== self
._filter
:
1355 self
._filter
= value
1356 paths
= utils
.shell_split(value
)
1357 self
.context
.model
.update_path_filter(paths
)
1360 def customize_copy_actions(context
, parent
):
1361 """Customize copy actions"""
1362 dialog
= CustomizeCopyActions(context
, parent
)
1367 class CustomizeCopyActions(standard
.Dialog
):
1368 """A dialog for defining custom Copy actions and format strings"""
1370 def __init__(self
, context
, parent
):
1371 standard
.Dialog
.__init
__(self
, parent
=parent
)
1372 self
.setWindowTitle(N_('Custom Copy Actions'))
1374 self
.context
= context
1375 self
.table
= QtWidgets
.QTableWidget(self
)
1376 self
.table
.setColumnCount(2)
1377 self
.table
.setHorizontalHeaderLabels([N_('Action Name'), N_('Format String')])
1378 self
.table
.setSortingEnabled(False)
1379 self
.table
.verticalHeader().hide()
1380 self
.table
.horizontalHeader().setStretchLastSection(True)
1382 self
.add_button
= qtutils
.create_button(N_('Add'))
1383 self
.remove_button
= qtutils
.create_button(N_('Remove'))
1384 self
.remove_button
.setEnabled(False)
1385 self
.show_help_button
= qtutils
.create_button(N_('Show Help'))
1386 self
.show_help_button
.setShortcut(hotkeys
.QUESTION
)
1388 self
.close_button
= qtutils
.close_button()
1389 self
.save_button
= qtutils
.ok_button(N_('Save'))
1391 self
.buttons
= qtutils
.hbox(
1393 defs
.button_spacing
,
1396 self
.show_help_button
,
1402 layout
= qtutils
.vbox(defs
.margin
, defs
.spacing
, self
.table
, self
.buttons
)
1403 self
.setLayout(layout
)
1405 qtutils
.connect_button(self
.add_button
, self
.add
)
1406 qtutils
.connect_button(self
.remove_button
, self
.remove
)
1407 qtutils
.connect_button(self
.show_help_button
, partial(show_help
, context
))
1408 qtutils
.connect_button(self
.close_button
, self
.reject
)
1409 qtutils
.connect_button(self
.save_button
, self
.save
)
1410 qtutils
.add_close_action(self
)
1411 # pylint: disable=no-member
1412 self
.table
.itemSelectionChanged
.connect(self
.table_selection_changed
)
1414 self
.init_size(parent
=parent
)
1416 QtCore
.QTimer
.singleShot(0, self
.reload_settings
)
1418 def reload_settings(self
):
1419 """Update the view to match the current settings"""
1420 # Called once after the GUI is initialized
1421 settings
= self
.context
.settings
1424 for entry
in settings
.copy_formats
:
1425 name_string
= entry
.get('name', '')
1426 format_string
= entry
.get('format', '')
1427 if name_string
and format_string
:
1428 name
= QtWidgets
.QTableWidgetItem(name_string
)
1429 fmt
= QtWidgets
.QTableWidgetItem(format_string
)
1430 rows
= table
.rowCount()
1431 table
.setRowCount(rows
+ 1)
1432 table
.setItem(rows
, 0, name
)
1433 table
.setItem(rows
, 1, fmt
)
1435 def export_state(self
):
1436 """Export the current state into the saved settings"""
1437 state
= super(CustomizeCopyActions
, self
).export_state()
1438 standard
.export_header_columns(self
.table
, state
)
1441 def apply_state(self
, state
):
1442 """Restore state from the saved settings"""
1443 result
= super(CustomizeCopyActions
, self
).apply_state(state
)
1444 standard
.apply_header_columns(self
.table
, state
)
1448 """Add a custom Copy action and format string"""
1449 self
.table
.setFocus()
1450 rows
= self
.table
.rowCount()
1451 self
.table
.setRowCount(rows
+ 1)
1453 name
= QtWidgets
.QTableWidgetItem(N_('Name'))
1454 fmt
= QtWidgets
.QTableWidgetItem(r
'%(path)s')
1455 self
.table
.setItem(rows
, 0, name
)
1456 self
.table
.setItem(rows
, 1, fmt
)
1458 self
.table
.setCurrentCell(rows
, 0)
1459 self
.table
.editItem(name
)
1462 """Remove selected items"""
1463 # Gather a unique set of rows and remove them in reverse order
1465 items
= self
.table
.selectedItems()
1467 rows
.add(self
.table
.row(item
))
1469 for row
in reversed(sorted(rows
)):
1470 self
.table
.removeRow(row
)
1473 """Save custom copy actions to the settings"""
1475 for row
in range(self
.table
.rowCount()):
1476 name
= self
.table
.item(row
, 0)
1477 fmt
= self
.table
.item(row
, 1)
1480 'name': name
.text(),
1481 'format': fmt
.text(),
1483 copy_formats
.append(entry
)
1485 settings
= self
.context
.settings
1486 while settings
.copy_formats
:
1487 settings
.copy_formats
.pop()
1489 settings
.copy_formats
.extend(copy_formats
)
1494 def table_selection_changed(self
):
1495 """Update the enabled state of action buttons based on the current selection"""
1496 items
= self
.table
.selectedItems()
1497 self
.remove_button
.setEnabled(bool(items
))
1500 def _select_item(widget
, path_list
, widget_getter
, item
, current
=False):
1501 """Select the widget item based on the list index"""
1502 # The path lists and widget indexes have a 1:1 correspondence.
1503 # Lookup the item filename in the list and use that index to
1504 # retrieve the widget item and select it.
1505 idx
= path_list
.index(item
)
1506 item
= widget_getter(idx
)
1508 widget
.setCurrentItem(item
)
1509 item
.setSelected(True)
1512 def _apply_toplevel_selection(widget
, category
, idx
):
1513 """Select a top-level "header" item (ex: the Staged parent item)
1515 Return True when a top-level item is selected.
1517 is_top_level_item
= category
== HEADER_IDX
1518 if is_top_level_item
:
1519 root_item
= widget
.invisibleRootItem()
1520 item
= root_item
.child(idx
)
1522 if item
is not None and item
.childCount() == 0:
1523 # The item now has no children. Select a different top-level item
1524 # corresponding to the previously selected item.
1525 if idx
== STAGED_IDX
:
1526 # If "Staged" was previously selected try "Modified" and "Untracked".
1527 item
= _get_first_item_with_children(
1528 root_item
.child(MODIFIED_IDX
), root_item
.child(UNTRACKED_IDX
)
1530 elif idx
== UNMERGED_IDX
:
1531 # If "Unmerged" was previously selected try "Staged".
1532 item
= _get_first_item_with_children(root_item
.child(STAGED_IDX
))
1533 elif idx
== MODIFIED_IDX
:
1534 # If "Modified" was previously selected try "Staged" or "Untracked".
1535 item
= _get_first_item_with_children(
1536 root_item
.child(STAGED_IDX
), root_item
.child(UNTRACKED_IDX
)
1538 elif idx
== UNTRACKED_IDX
:
1539 # If "Untracked" was previously selected try "Staged".
1540 item
= _get_first_item_with_children(root_item
.child(STAGED_IDX
))
1542 if item
is not None:
1543 with qtutils
.BlockSignals(widget
):
1544 widget
.setCurrentItem(item
)
1545 item
.setSelected(True)
1546 widget
.show_selection()
1547 return is_top_level_item
1550 def _get_first_item_with_children(*items
):
1551 """Return the first item that contains child items"""
1553 if item
.childCount() > 0:
1558 def _transplant_selection_across_sections(
1559 category
, idx
, previous_contents
, saved_selection
1561 """Transplant the selection to a different category"""
1562 # This function is used when the selection would otherwise become empty.
1563 # Apply heuristics to select the items based on the previous state.
1564 if not previous_contents
:
1566 staged
, unmerged
, modified
, untracked
= saved_selection
1567 prev_staged
, prev_unmerged
, prev_modified
, prev_untracked
= previous_contents
1569 # The current set of paths.
1570 staged_paths
= staged
[NEW_PATHS_IDX
]
1571 unmerged_paths
= unmerged
[NEW_PATHS_IDX
]
1572 modified_paths
= modified
[NEW_PATHS_IDX
]
1573 untracked_paths
= untracked
[NEW_PATHS_IDX
]
1575 # These callbacks select a path in the corresponding widget subtree lists.
1576 select_staged
= staged
[SELECT_FN_IDX
]
1577 select_unmerged
= unmerged
[SELECT_FN_IDX
]
1578 select_modified
= modified
[SELECT_FN_IDX
]
1579 select_untracked
= untracked
[SELECT_FN_IDX
]
1581 if category
== STAGED_IDX
:
1582 # Staged files can become Unmerged, Modified or Untracked.
1583 # If we previously had a staged file selected then try to select
1584 # it in either the Unmerged, Modified or Untracked sections.
1586 old_path
= prev_staged
[idx
]
1589 if old_path
in unmerged_paths
:
1590 select_unmerged(old_path
, current
=True)
1591 elif old_path
in modified_paths
:
1592 select_modified(old_path
, current
=True)
1593 elif old_path
in untracked_paths
:
1594 select_untracked(old_path
, current
=True)
1596 elif category
== UNMERGED_IDX
:
1597 # Unmerged files can become Staged, Modified or Untracked.
1598 # If we previously had an unmerged file selected then try to select it in
1599 # the Staged, Modified or Untracked sections.
1601 old_path
= prev_unmerged
[idx
]
1604 if old_path
in staged_paths
:
1605 select_staged(old_path
, current
=True)
1606 elif old_path
in modified_paths
:
1607 select_modified(old_path
, current
=True)
1608 elif old_path
in untracked_paths
:
1609 select_untracked(old_path
, current
=True)
1611 elif category
== MODIFIED_IDX
:
1612 # If we previously had a modified file selected then try to select
1613 # it in either the Staged or Untracked sections.
1615 old_path
= prev_modified
[idx
]
1618 if old_path
in staged_paths
:
1619 select_staged(old_path
, current
=True)
1620 elif old_path
in untracked_paths
:
1621 select_untracked(old_path
, current
=True)
1623 elif category
== UNTRACKED_IDX
:
1624 # If we previously had an untracked file selected then try to select
1625 # it in the Modified or Staged section. Modified is less common, but
1626 # it's possible for a file to be untracked and then the user adds and
1627 # modifies the file before we've refreshed our state.
1629 old_path
= prev_untracked
[idx
]
1632 if old_path
in modified_paths
:
1633 select_modified(old_path
, current
=True)
1634 elif old_path
in staged_paths
:
1635 select_staged(old_path
, current
=True)
1638 class CopyLeadingPathWidget(QtWidgets
.QWidget
):
1639 """A widget that holds a label and a spinbox for the number of paths to strip"""
1641 def __init__(self
, title
, context
, parent
):
1642 QtWidgets
.QWidget
.__init
__(self
, parent
)
1643 self
.context
= context
1644 self
.icon
= QtWidgets
.QLabel(self
)
1645 self
.label
= QtWidgets
.QLabel(self
)
1646 self
.spinbox
= standard
.SpinBox(value
=1, mini
=1, maxi
=99, parent
=self
)
1647 self
.spinbox
.setToolTip(N_('The number of leading paths to strip'))
1650 pixmap
= icon
.pixmap(defs
.default_icon
, defs
.default_icon
)
1651 self
.icon
.setPixmap(pixmap
)
1652 self
.label
.setText(title
)
1654 layout
= qtutils
.hbox(
1656 defs
.titlebar_spacing
,
1662 self
.setLayout(layout
)
1664 theme
= context
.app
.theme
1665 highlight_rgb
= theme
.highlight_color_rgb()
1666 text_rgb
, highlight_text_rgb
= theme
.text_colors_rgb()
1667 disabled_text_rgb
= theme
.disabled_text_color_rgb()
1671 show-decoration-selected: 1
1674 color: %(text_rgb)s;
1675 show-decoration-selected: 1
1678 color: %(highlight_text_rgb)s;
1679 background-color: %(highlight_rgb)s;
1680 background-clip: padding;
1681 show-decoration-selected: 1
1684 color: %(disabled_text_rgb)s;
1687 'disabled_text_rgb': disabled_text_rgb
,
1688 'text_rgb': text_rgb
,
1689 'highlight_text_rgb': highlight_text_rgb
,
1690 'highlight_rgb': highlight_rgb
,
1693 self
.setStyleSheet(stylesheet
)
1696 """Return the current value of the spinbox"""
1697 return self
.spinbox
.value()
1699 def set_value(self
, value
):
1700 """Set the spinbox value"""
1701 self
.spinbox
.setValue(value
)