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 hotkeys
22 from .. import qtutils
25 from . import completion
30 # Top-level status widget item indexes.
38 # Indexes into the saved_selection entries.
45 class StatusWidget(QtWidgets
.QFrame
):
47 Provides a git-status-like repository widget.
49 This widget observes the main model and broadcasts
54 def __init__(self
, context
, titlebar
, parent
):
55 QtWidgets
.QFrame
.__init
__(self
, parent
)
56 self
.context
= context
58 tooltip
= N_('Toggle the paths filter')
59 icon
= icons
.ellipsis()
60 self
.filter_button
= qtutils
.create_action_button(tooltip
=tooltip
, icon
=icon
)
61 self
.filter_widget
= StatusFilterWidget(context
)
62 self
.filter_widget
.hide()
63 self
.tree
= StatusTreeWidget(context
, parent
=self
)
64 self
.setFocusProxy(self
.tree
)
66 tooltip
= N_('Exit "Diff" mode')
67 icon
= icons
.circle_slash_red()
68 self
.exit_diff_mode_button
= qtutils
.create_action_button(
69 tooltip
=tooltip
, icon
=icon
, visible
=False
72 self
.main_layout
= qtutils
.vbox(
73 defs
.no_margin
, defs
.no_spacing
, self
.filter_widget
, self
.tree
75 self
.setLayout(self
.main_layout
)
77 self
.toggle_action
= qtutils
.add_action(
78 self
, tooltip
, self
.toggle_filter
, hotkeys
.FILTER
81 titlebar
.add_corner_widget(self
.exit_diff_mode_button
)
82 titlebar
.add_corner_widget(self
.filter_button
)
84 qtutils
.connect_button(self
.filter_button
, self
.toggle_filter
)
85 qtutils
.connect_button(
86 self
.exit_diff_mode_button
, cmds
.run(cmds
.ResetMode
, self
.context
)
89 def toggle_filter(self
):
90 """Toggle the paths filter"""
91 shown
= not self
.filter_widget
.isVisible()
92 self
.filter_widget
.setVisible(shown
)
94 self
.filter_widget
.setFocus()
98 def set_initial_size(self
):
99 """Set the initial size of the status widget"""
100 self
.setMaximumWidth(222)
101 QtCore
.QTimer
.singleShot(1, lambda: self
.setMaximumWidth(2**13))
104 """Refresh the tree and rerun the diff to see updates"""
105 self
.tree
.show_selection()
107 def set_filter(self
, txt
):
108 """Set the filter text"""
109 self
.filter_widget
.setVisible(True)
110 self
.filter_widget
.text
.set_value(txt
)
111 self
.filter_widget
.apply_filter()
113 def set_mode(self
, mode
):
114 """React to changes in model's editing mode"""
115 exit_diff_mode_visible
= mode
== self
.context
.model
.mode_diff
116 self
.exit_diff_mode_button
.setVisible(exit_diff_mode_visible
)
122 self
.tree
.move_down()
124 def select_header(self
):
125 self
.tree
.select_header()
128 # pylint: disable=too-many-ancestors
129 class StatusTreeWidget(QtWidgets
.QTreeWidget
):
130 # Read-only access to the mode state
131 mode
= property(lambda self
: self
._model
.mode
)
133 def __init__(self
, context
, parent
=None):
134 QtWidgets
.QTreeWidget
.__init
__(self
, parent
)
135 self
.context
= context
136 self
.selection_model
= context
.selection
138 self
.setSelectionMode(QtWidgets
.QAbstractItemView
.ExtendedSelection
)
139 self
.headerItem().setHidden(True)
140 self
.setAllColumnsShowFocus(True)
141 self
.setSortingEnabled(False)
142 self
.setUniformRowHeights(True)
143 self
.setAnimated(True)
144 self
.setRootIsDecorated(False)
145 self
.setAutoScroll(False)
146 self
.setDragEnabled(True)
147 self
.setDragDropMode(QtWidgets
.QAbstractItemView
.DragOnly
)
148 self
._alt
_drag
= False
150 if not prefs
.status_indent(context
):
151 self
.setIndentation(0)
154 compare
= icons
.compare()
155 question
= icons
.question()
156 self
._add
_toplevel
_item
(N_('Staged'), ok_icon
, hide
=True)
157 self
._add
_toplevel
_item
(N_('Unmerged'), compare
, hide
=True)
158 self
._add
_toplevel
_item
(N_('Modified'), compare
, hide
=True)
159 self
._add
_toplevel
_item
(N_('Untracked'), question
, hide
=True)
161 # Used to restore the selection
162 self
.old_vscroll
= None
163 self
.old_hscroll
= None
164 self
.old_selection
= None
165 self
.old_contents
= None
166 self
.old_current_item
= None
167 self
.previous_contents
= None
168 self
.was_visible
= True
169 self
.expanded_items
= set()
171 self
.image_formats
= qtutils
.ImageFormats()
173 self
.process_selection_action
= qtutils
.add_action(
175 cmds
.StageOrUnstage
.name(),
176 self
._stage
_selection
,
177 hotkeys
.STAGE_SELECTION
,
179 self
.process_selection_action
.setIcon(icons
.add())
181 self
.stage_or_unstage_all_action
= qtutils
.add_action(
183 cmds
.StageOrUnstageAll
.name(),
184 cmds
.run(cmds
.StageOrUnstageAll
, self
.context
),
187 self
.stage_or_unstage_all_action
.setIcon(icons
.add())
189 self
.revert_unstaged_edits_action
= qtutils
.add_action(
191 cmds
.RevertUnstagedEdits
.name(),
192 cmds
.run(cmds
.RevertUnstagedEdits
, context
),
195 self
.revert_unstaged_edits_action
.setIcon(icons
.undo())
197 self
.launch_difftool_action
= qtutils
.add_action(
199 cmds
.LaunchDifftool
.name(),
200 cmds
.run(cmds
.LaunchDifftool
, context
),
203 self
.launch_difftool_action
.setIcon(icons
.diff())
205 self
.launch_editor_action
= actions
.launch_editor_at_line(
206 context
, self
, *hotkeys
.ACCEPT
209 self
.default_app_action
= common
.default_app_action(
210 context
, self
, self
.selected_group
213 self
.parent_dir_action
= common
.parent_dir_action(
214 context
, self
, self
.selected_group
217 self
.worktree_dir_action
= common
.worktree_dir_action(context
, self
)
219 self
.terminal_action
= common
.terminal_action(
220 context
, self
, func
=self
.selected_group
223 self
.up_action
= qtutils
.add_action(
228 hotkeys
.MOVE_UP_SECONDARY
,
231 self
.down_action
= qtutils
.add_action(
236 hotkeys
.MOVE_DOWN_SECONDARY
,
239 # Checkout the selected paths using "git checkout --ours".
240 self
.checkout_ours_action
= qtutils
.add_action(
242 cmds
.CheckoutOurs
.name(),
243 cmds
.run(cmds
.CheckoutOurs
, context
)
246 # Checkout the selected paths using "git checkout --theirs".
247 self
.checkout_theirs_action
= qtutils
.add_action(
249 cmds
.CheckoutTheirs
.name(),
250 cmds
.run(cmds
.CheckoutTheirs
, context
)
253 self
.copy_path_action
= qtutils
.add_action(
255 N_('Copy Path to Clipboard'),
256 partial(copy_path
, context
),
259 self
.copy_path_action
.setIcon(icons
.copy())
261 self
.copy_relpath_action
= qtutils
.add_action(
263 N_('Copy Relative Path to Clipboard'),
264 partial(copy_relpath
, context
),
267 self
.copy_relpath_action
.setIcon(icons
.copy())
269 self
.copy_leading_paths_value
= 1
271 self
.copy_basename_action
= qtutils
.add_action(
272 self
, N_('Copy Basename to Clipboard'), partial(copy_basename
, context
)
274 self
.copy_basename_action
.setIcon(icons
.copy())
276 self
.copy_customize_action
= qtutils
.add_action(
277 self
, N_('Customize...'), partial(customize_copy_actions
, context
, self
)
279 self
.copy_customize_action
.setIcon(icons
.configure())
281 self
.view_history_action
= qtutils
.add_action(
282 self
, N_('View History...'), partial(view_history
, context
), hotkeys
.HISTORY
285 self
.view_blame_action
= qtutils
.add_action(
286 self
, N_('Blame...'), partial(view_blame
, context
), hotkeys
.BLAME
289 self
.annex_add_action
= qtutils
.add_action(
290 self
, N_('Add to Git Annex'), cmds
.run(cmds
.AnnexAdd
, context
)
293 self
.lfs_track_action
= qtutils
.add_action(
294 self
, N_('Add to Git LFS'), cmds
.run(cmds
.LFSTrack
, context
)
297 # MoveToTrash and Delete use the same shortcut.
298 # We will only bind one of them, depending on whether or not the
299 # MoveToTrash command is available. When available, the hotkey
300 # is bound to MoveToTrash, otherwise it is bound to Delete.
301 if cmds
.MoveToTrash
.AVAILABLE
:
302 self
.move_to_trash_action
= qtutils
.add_action(
304 N_('Move files to trash'),
305 self
._trash
_untracked
_files
,
308 self
.move_to_trash_action
.setIcon(icons
.discard())
309 delete_shortcut
= hotkeys
.DELETE_FILE
311 self
.move_to_trash_action
= None
312 delete_shortcut
= hotkeys
.DELETE_FILE_SECONDARY
314 self
.delete_untracked_files_action
= qtutils
.add_action(
315 self
, N_('Delete Files...'), self
._delete
_untracked
_files
, delete_shortcut
317 self
.delete_untracked_files_action
.setIcon(icons
.discard())
319 # The model is stored as self._model because self.model() is a
320 # QTreeWidgetItem method that returns a QAbstractItemModel.
321 self
._model
= context
.model
322 self
._model
.previous_contents
.connect(
323 self
._set
_previous
_contents
, type=Qt
.QueuedConnection
325 self
._model
.about_to_update
.connect(
326 self
._about
_to
_update
, type=Qt
.QueuedConnection
328 self
._model
.updated
.connect(self
.refresh
, type=Qt
.QueuedConnection
)
329 self
._model
.diff_text_changed
.connect(
330 self
._make
_current
_item
_visible
, type=Qt
.QueuedConnection
332 # pylint: disable=no-member
333 self
.itemSelectionChanged
.connect(self
.show_selection
)
334 self
.itemDoubleClicked
.connect(cmds
.run(cmds
.StageOrUnstage
, self
.context
))
335 self
.itemCollapsed
.connect(lambda x
: self
._update
_column
_widths
())
336 self
.itemExpanded
.connect(lambda x
: self
._update
_column
_widths
())
338 def _make_current_item_visible(self
):
339 item
= self
.currentItem()
341 qtutils
.scroll_to_item(self
, item
)
343 def _add_toplevel_item(self
, txt
, icon
, hide
=False):
344 context
= self
.context
346 if prefs
.bold_headers(context
):
351 item
= QtWidgets
.QTreeWidgetItem(self
)
352 item
.setFont(0, font
)
354 item
.setIcon(0, icon
)
355 if prefs
.bold_headers(context
):
356 item
.setBackground(0, self
.palette().midlight())
360 def _restore_selection(self
):
361 """Apply the old selection to the newly updated items"""
362 # This function is called after a new set of items have been added to
363 # the per-category file lists. Its purpose is to either restore the
364 # existing selection or to create a new intuitive selection based on
365 # a combination of the old items, the old selection and the new items.
366 if not self
.old_selection
or not self
.old_contents
:
368 # The old set of categorized files.
369 old_c
= self
.old_contents
371 old_s
= self
.old_selection
372 # The current/new set of categorized files.
373 new_c
= self
.contents()
375 select_staged
= partial(_select_item
, self
, new_c
.staged
, self
._staged
_item
)
376 select_unmerged
= partial(
377 _select_item
, self
, new_c
.unmerged
, self
._unmerged
_item
379 select_modified
= partial(
380 _select_item
, self
, new_c
.modified
, self
._modified
_item
382 select_untracked
= partial(
383 _select_item
, self
, new_c
.untracked
, self
._untracked
_item
387 (set(new_c
.staged
), old_c
.staged
, set(old_s
.staged
), select_staged
),
388 (set(new_c
.unmerged
), old_c
.unmerged
, set(old_s
.unmerged
), select_unmerged
),
389 (set(new_c
.modified
), old_c
.modified
, set(old_s
.modified
), select_modified
),
391 set(new_c
.untracked
),
393 set(old_s
.untracked
),
398 # Restore the current item
399 if self
.old_current_item
:
400 category
, idx
= self
.old_current_item
401 if _apply_toplevel_selection(self
, category
, idx
):
403 # Reselect the current item
404 selection_info
= saved_selection
[category
]
405 new
= selection_info
[NEW_PATHS_IDX
]
406 old
= selection_info
[OLD_PATHS_IDX
]
407 reselect
= selection_info
[SELECT_FN_IDX
]
412 if item
and item
in new
:
413 reselect(item
, current
=True)
415 # Restore previously selected items.
416 # When reselecting in this section we only care that the items are
417 # selected; we do not need to rerun the callbacks which were triggered
418 # above for the current item. Block signals to skip the callbacks.
420 # Reselect items that were previously selected and still exist in the
421 # current path lists. This handles a common case such as a Ctrl-R
422 # refresh which results in the same exact path state.
425 with qtutils
.BlockSignals(self
):
426 for (new
, old
, sel
, reselect
) in saved_selection
:
429 reselect(item
, current
=False)
432 # The status widget is used to interactively work your way down the
433 # list of Staged, Unmerged, Modified and Untracked items and perform
434 # an operation on them.
436 # For Staged items we intend to work our way down the list of Staged
437 # items while we unstage each item. For every other category we work
438 # our way down the list of {Unmerged,Modified,Untracked} items while
439 # we stage each item.
441 # The following block of code implements the behavior of selecting
442 # the next item based on the previous selection.
443 for (new
, old
, sel
, reselect
) in saved_selection
:
444 # When modified is staged, select the next modified item
445 # When unmerged is staged, select the next unmerged item
446 # When unstaging, select the next staged item
447 # When staging untracked files, select the next untracked item
448 if len(new
) >= len(old
):
449 # The list did not shrink so it is not one of these cases.
452 # The item still exists so ignore it
453 if item
in new
or item
not in old
:
455 # The item no longer exists in this list so search for
456 # its nearest neighbors and select them instead.
457 idx
= old
.index(item
)
458 for j
in itertools
.chain(old
[idx
+ 1 :], reversed(old
[:idx
])):
460 reselect(j
, current
=True)
463 # If we already reselected stuff then there's nothing more to do.
466 # If we got this far then nothing was reselected and made current.
467 # Try a few more heuristics that we can use to keep something selected.
468 if self
.old_current_item
:
469 category
, idx
= self
.old_current_item
470 _transplant_selection_across_sections(
471 category
, idx
, self
.previous_contents
, saved_selection
474 def _restore_scrollbars(self
):
475 """Restore scrollbars to the stored values"""
476 qtutils
.set_scrollbar_values(self
, self
.old_hscroll
, self
.old_vscroll
)
477 self
.old_hscroll
= None
478 self
.old_vscroll
= None
480 def _stage_selection(self
):
481 """Stage or unstage files according to the selection"""
482 context
= self
.context
483 selected_indexes
= self
.selected_indexes()
484 is_header
= any(category
== HEADER_IDX
for (category
, idx
) in selected_indexes
)
487 idx
== STAGED_IDX
and category
== HEADER_IDX
488 for (category
, idx
) in selected_indexes
491 idx
== MODIFIED_IDX
and category
== HEADER_IDX
492 for (category
, idx
) in selected_indexes
495 idx
== UNTRACKED_IDX
and category
== HEADER_IDX
496 for (category
, idx
) in selected_indexes
498 # A header item: 'Staged', 'Modified' or 'Untracked'.
500 # If we have the staged header selected then the only sensible
501 # thing to do is to unstage everything and nothing else, even
502 # if the modified or untracked headers are selected.
503 cmds
.do(cmds
.UnstageAll
, context
)
504 return # Everything was unstaged. There's nothing more to be done.
505 if is_modified
and is_untracked
:
506 # If both modified and untracked headers are selected then
508 cmds
.do(cmds
.StageModifiedAndUntracked
, context
)
509 return # Nothing more to do.
510 # At this point we may stage all modified and untracked, and then
511 # possibly a subset of the other category (eg. all modified and
512 # some untracked). We don't return here so that StageOrUnstage
513 # gets a chance to run below.
515 cmds
.do(cmds
.StageModified
, context
)
517 cmds
.do(cmds
.StageUntracked
, context
)
519 # Do nothing for unmerged items, by design
521 # Now handle individual files
522 cmds
.do(cmds
.StageOrUnstage
, context
)
524 def _staged_item(self
, itemidx
):
525 return self
._subtree
_item
(STAGED_IDX
, itemidx
)
527 def _modified_item(self
, itemidx
):
528 return self
._subtree
_item
(MODIFIED_IDX
, itemidx
)
530 def _unmerged_item(self
, itemidx
):
531 return self
._subtree
_item
(UNMERGED_IDX
, itemidx
)
533 def _untracked_item(self
, itemidx
):
534 return self
._subtree
_item
(UNTRACKED_IDX
, itemidx
)
536 def _unstaged_item(self
, itemidx
):
538 item
= self
.topLevelItem(MODIFIED_IDX
)
539 count
= item
.childCount()
541 return item
.child(itemidx
)
543 item
= self
.topLevelItem(UNMERGED_IDX
)
544 count
+= item
.childCount()
546 return item
.child(itemidx
)
548 item
= self
.topLevelItem(UNTRACKED_IDX
)
549 count
+= item
.childCount()
551 return item
.child(itemidx
)
555 def _subtree_item(self
, idx
, itemidx
):
556 parent
= self
.topLevelItem(idx
)
557 return parent
.child(itemidx
)
559 def _set_previous_contents(self
, staged
, unmerged
, modified
, untracked
):
560 """Callback triggered right before the model changes its contents"""
561 self
.previous_contents
= selection
.State(staged
, unmerged
, modified
, untracked
)
563 def _about_to_update(self
):
564 self
._save
_scrollbars
()
565 self
._save
_selection
()
567 def _save_scrollbars(self
):
568 """Store the scrollbar values for later application"""
569 hscroll
, vscroll
= qtutils
.get_scrollbar_values(self
)
570 if hscroll
is not None:
571 self
.old_hscroll
= hscroll
572 if vscroll
is not None:
573 self
.old_vscroll
= vscroll
575 def current_item(self
):
576 s
= self
.selected_indexes()
579 current
= self
.currentItem()
582 idx
= self
.indexFromItem(current
)
583 if idx
.parent().isValid():
584 parent_idx
= idx
.parent()
585 entry
= (parent_idx
.row(), idx
.row())
587 entry
= (HEADER_IDX
, idx
.row())
590 def _save_selection(self
):
591 self
.old_contents
= self
.contents()
592 self
.old_selection
= self
.selection()
593 self
.old_current_item
= self
.current_item()
596 self
._set
_staged
(self
._model
.staged
)
597 self
._set
_modified
(self
._model
.modified
)
598 self
._set
_unmerged
(self
._model
.unmerged
)
599 self
._set
_untracked
(self
._model
.untracked
)
600 self
._update
_column
_widths
()
601 self
._update
_actions
()
602 self
._restore
_selection
()
603 self
._restore
_scrollbars
()
605 def _update_actions(self
, selected
=None):
607 selected
= self
.selection_model
.selection()
608 can_revert_edits
= bool(selected
.staged
or selected
.modified
)
609 self
.revert_unstaged_edits_action
.setEnabled(can_revert_edits
)
611 enabled
= self
.selection_model
.filename() is not None
612 self
.default_app_action
.setEnabled(enabled
)
613 self
.parent_dir_action
.setEnabled(enabled
)
614 self
.copy_path_action
.setEnabled(enabled
)
615 self
.copy_relpath_action
.setEnabled(enabled
)
616 self
.copy_basename_action
.setEnabled(enabled
)
618 def _set_staged(self
, items
):
619 """Adds items to the 'Staged' subtree."""
620 with qtutils
.BlockSignals(self
):
626 deleted_set
=self
._model
.staged_deleted
,
629 def _set_modified(self
, items
):
630 """Adds items to the 'Modified' subtree."""
631 with qtutils
.BlockSignals(self
):
636 deleted_set
=self
._model
.unstaged_deleted
,
639 def _set_unmerged(self
, items
):
640 """Adds items to the 'Unmerged' subtree."""
641 deleted_set
= {path
for path
in items
if not core
.exists(path
)}
642 with qtutils
.BlockSignals(self
):
644 items
, UNMERGED_IDX
, N_('Unmerged'), deleted_set
=deleted_set
647 def _set_untracked(self
, items
):
648 """Adds items to the 'Untracked' subtree."""
649 with qtutils
.BlockSignals(self
):
650 self
._set
_subtree
(items
, UNTRACKED_IDX
, N_('Untracked'), untracked
=True)
653 self
, items
, idx
, parent_title
, staged
=False, untracked
=False, deleted_set
=None
655 """Add a list of items to a treewidget item."""
656 parent
= self
.topLevelItem(idx
)
657 hide
= not bool(items
)
658 parent
.setHidden(hide
)
660 # sip v4.14.7 and below leak memory in parent.takeChildren()
661 # so we use this backwards-compatible construct instead
662 while parent
.takeChild(0) is not None:
666 deleted
= deleted_set
is not None and item
in deleted_set
667 treeitem
= qtutils
.create_treeitem(
668 item
, staged
=staged
, deleted
=deleted
, untracked
=untracked
670 parent
.addChild(treeitem
)
671 self
._expand
_items
(idx
, items
)
673 if prefs
.status_show_totals(self
.context
):
674 parent
.setText(0, '%s (%s)' % (parent_title
, len(items
)))
676 def _update_column_widths(self
):
677 self
.resizeColumnToContents(0)
679 def _expand_items(self
, idx
, items
):
680 """Expand the top-level category "folder" once and only once."""
681 # Don't do this if items is empty; this makes it so that we
682 # don't add the top-level index into the expanded_items set
683 # until an item appears in a particular category.
686 # Only run this once; we don't want to re-expand items that
687 # we've clicked on to re-collapse on updated().
688 if idx
in self
.expanded_items
:
690 self
.expanded_items
.add(idx
)
691 item
= self
.topLevelItem(idx
)
693 self
.expandItem(item
)
695 def contextMenuEvent(self
, event
):
696 """Create context menus for the repo status tree."""
697 menu
= self
._create
_context
_menu
()
698 menu
.exec_(self
.mapToGlobal(event
.pos()))
700 def _create_context_menu(self
):
701 """Set up the status menu for the repo status tree."""
702 sel
= self
.selection()
703 menu
= qtutils
.create_menu('Status', self
)
704 selected_indexes
= self
.selected_indexes()
706 category
, idx
= selected_indexes
[0]
707 # A header item e.g. 'Staged', 'Modified', etc.
708 if category
== HEADER_IDX
:
709 return self
._create
_header
_context
_menu
(menu
, idx
)
712 self
._create
_staged
_context
_menu
(menu
, sel
)
714 self
._create
_unmerged
_context
_menu
(menu
, sel
)
716 self
._create
_unstaged
_context
_menu
(menu
, sel
)
718 if not menu
.isEmpty():
721 if not self
.selection_model
.is_empty():
722 menu
.addAction(self
.default_app_action
)
723 menu
.addAction(self
.parent_dir_action
)
725 if self
.terminal_action
is not None:
726 menu
.addAction(self
.terminal_action
)
728 menu
.addAction(self
.worktree_dir_action
)
730 self
._add
_copy
_actions
(menu
)
734 def _add_copy_actions(self
, menu
):
735 """Add the "Copy" sub-menu"""
736 enabled
= self
.selection_model
.filename() is not None
737 self
.copy_path_action
.setEnabled(enabled
)
738 self
.copy_relpath_action
.setEnabled(enabled
)
739 self
.copy_basename_action
.setEnabled(enabled
)
741 copy_menu
= QtWidgets
.QMenu(N_('Copy...'), menu
)
742 copy_icon
= icons
.copy()
743 copy_menu
.setIcon(copy_icon
)
745 copy_leading_path_action
= QtWidgets
.QWidgetAction(copy_menu
)
746 copy_leading_path_action
.setEnabled(enabled
)
748 widget
= CopyLeadingPathWidget(
749 N_('Copy Leading Path to Clipboard'), self
.context
, copy_menu
752 # Store the value of the leading paths spinbox so that the value does not reset
753 # everytime the menu is shown and recreated.
754 widget
.set_value(self
.copy_leading_paths_value
)
755 widget
.spinbox
.valueChanged
.connect(
756 partial(setattr, self
, 'copy_leading_paths_value')
758 copy_leading_path_action
.setDefaultWidget(widget
)
760 # Copy the leading path when the action is activated.
761 qtutils
.connect_action(
762 copy_leading_path_action
,
763 lambda widget
=widget
: copy_leading_path(context
, widget
.value()),
767 menu
.addMenu(copy_menu
)
768 copy_menu
.addAction(self
.copy_path_action
)
769 copy_menu
.addAction(self
.copy_relpath_action
)
770 copy_menu
.addAction(copy_leading_path_action
)
771 copy_menu
.addAction(self
.copy_basename_action
)
773 settings
= Settings
.read()
774 copy_formats
= settings
.copy_formats
776 copy_menu
.addSeparator()
778 context
= self
.context
779 for entry
in copy_formats
:
780 name
= entry
.get('name', '')
781 fmt
= entry
.get('format', '')
783 action
= copy_menu
.addAction(name
, partial(copy_format
, context
, fmt
))
784 action
.setIcon(copy_icon
)
785 action
.setEnabled(enabled
)
787 copy_menu
.addSeparator()
788 copy_menu
.addAction(self
.copy_customize_action
)
790 def _create_header_context_menu(self
, menu
, idx
):
791 context
= self
.context
792 if idx
== STAGED_IDX
:
794 icons
.remove(), N_('Unstage All'), cmds
.run(cmds
.UnstageAll
, context
)
796 elif idx
== UNMERGED_IDX
:
797 action
= menu
.addAction(
799 cmds
.StageUnmerged
.name(),
800 cmds
.run(cmds
.StageUnmerged
, context
),
802 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
803 elif idx
== MODIFIED_IDX
:
804 action
= menu
.addAction(
806 cmds
.StageModified
.name(),
807 cmds
.run(cmds
.StageModified
, context
),
809 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
810 elif idx
== UNTRACKED_IDX
:
811 action
= menu
.addAction(
813 cmds
.StageUntracked
.name(),
814 cmds
.run(cmds
.StageUntracked
, context
),
816 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
819 def _create_staged_context_menu(self
, menu
, s
):
820 if s
.staged
[0] in self
._model
.submodules
:
821 return self
._create
_staged
_submodule
_context
_menu
(menu
, s
)
823 context
= self
.context
824 if self
._model
.is_unstageable():
825 action
= menu
.addAction(
827 N_('Unstage Selected'),
828 cmds
.run(cmds
.Unstage
, context
, self
.staged()),
830 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
832 menu
.addAction(self
.launch_editor_action
)
834 # Do all of the selected items exist?
836 i
not in self
._model
.staged_deleted
and core
.exists(i
)
837 for i
in self
.staged()
841 menu
.addAction(self
.launch_difftool_action
)
843 if self
._model
.is_undoable():
844 menu
.addAction(self
.revert_unstaged_edits_action
)
846 menu
.addAction(self
.view_history_action
)
847 menu
.addAction(self
.view_blame_action
)
850 def _create_staged_submodule_context_menu(self
, menu
, s
):
851 context
= self
.context
852 path
= core
.abspath(s
.staged
[0])
853 if len(self
.staged()) == 1:
856 N_('Launch git-cola'),
857 cmds
.run(cmds
.OpenRepo
, context
, path
),
860 action
= menu
.addAction(
862 N_('Unstage Selected'),
863 cmds
.run(cmds
.Unstage
, context
, self
.staged()),
865 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
867 menu
.addAction(self
.view_history_action
)
870 def _create_unmerged_context_menu(self
, menu
, _s
):
871 context
= self
.context
872 menu
.addAction(self
.launch_difftool_action
)
874 action
= menu
.addAction(
876 N_('Stage Selected'),
877 cmds
.run(cmds
.Stage
, context
, self
.unstaged()),
879 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
881 menu
.addAction(self
.launch_editor_action
)
882 menu
.addAction(self
.view_history_action
)
883 menu
.addAction(self
.view_blame_action
)
885 menu
.addAction(self
.checkout_ours_action
)
886 menu
.addAction(self
.checkout_theirs_action
)
889 def _create_unstaged_context_menu(self
, menu
, s
):
890 context
= self
.context
891 modified_submodule
= s
.modified
and s
.modified
[0] in self
._model
.submodules
892 if modified_submodule
:
893 return self
._create
_modified
_submodule
_context
_menu
(menu
, s
)
895 if self
._model
.is_stageable():
896 action
= menu
.addAction(
898 N_('Stage Selected'),
899 cmds
.run(cmds
.Stage
, context
, self
.unstaged()),
901 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
903 if not self
.selection_model
.is_empty():
904 menu
.addAction(self
.launch_editor_action
)
906 # Do all of the selected items exist?
908 i
not in self
._model
.unstaged_deleted
and core
.exists(i
)
909 for i
in self
.staged()
912 if all_exist
and s
.modified
and self
._model
.is_stageable():
913 menu
.addAction(self
.launch_difftool_action
)
915 if s
.modified
and self
._model
.is_stageable() and self
._model
.is_undoable():
917 menu
.addAction(self
.revert_unstaged_edits_action
)
919 if all_exist
and s
.untracked
:
920 # Git Annex / Git LFS
921 annex
= self
._model
.annex
922 lfs
= core
.find_executable('git-lfs')
926 menu
.addAction(self
.annex_add_action
)
928 menu
.addAction(self
.lfs_track_action
)
931 if self
.move_to_trash_action
is not None:
932 menu
.addAction(self
.move_to_trash_action
)
933 menu
.addAction(self
.delete_untracked_files_action
)
938 partial(gitignore
.gitignore_view
, self
.context
),
941 if not self
.selection_model
.is_empty():
942 menu
.addAction(self
.view_history_action
)
943 menu
.addAction(self
.view_blame_action
)
946 def _create_modified_submodule_context_menu(self
, menu
, s
):
947 context
= self
.context
948 path
= core
.abspath(s
.modified
[0])
949 if len(self
.unstaged()) == 1:
952 N_('Launch git-cola'),
953 cmds
.run(cmds
.OpenRepo
, context
, path
),
957 N_('Update this submodule'),
958 cmds
.run(cmds
.SubmoduleUpdate
, context
, path
),
962 if self
._model
.is_stageable():
964 action
= menu
.addAction(
966 N_('Stage Selected'),
967 cmds
.run(cmds
.Stage
, context
, self
.unstaged()),
969 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
971 menu
.addAction(self
.view_history_action
)
974 def _delete_untracked_files(self
):
975 cmds
.do(cmds
.Delete
, self
.context
, self
.untracked())
977 def _trash_untracked_files(self
):
978 cmds
.do(cmds
.MoveToTrash
, self
.context
, self
.untracked())
980 def selected_path(self
):
981 s
= self
.single_selection()
982 return s
.staged
or s
.unmerged
or s
.modified
or s
.untracked
or None
984 def single_selection(self
):
985 """Scan across staged, modified, etc. and return a single item."""
995 unmerged
= s
.unmerged
[0]
997 modified
= s
.modified
[0]
999 untracked
= s
.untracked
[0]
1001 return selection
.State(staged
, unmerged
, modified
, untracked
)
1003 def selected_indexes(self
):
1004 """Returns a list of (category, row) representing the tree selection."""
1005 selected
= self
.selectedIndexes()
1007 for idx
in selected
:
1008 if idx
.parent().isValid():
1009 parent_idx
= idx
.parent()
1010 entry
= (parent_idx
.row(), idx
.row())
1012 entry
= (HEADER_IDX
, idx
.row())
1013 result
.append(entry
)
1016 def selection(self
):
1017 """Return the current selection in the repo status tree."""
1018 return selection
.State(
1019 self
.staged(), self
.unmerged(), self
.modified(), self
.untracked()
1023 """Return all of the current files in a selection.State container"""
1024 return selection
.State(
1026 self
._model
.unmerged
,
1027 self
._model
.modified
,
1028 self
._model
.untracked
,
1031 def all_files(self
):
1032 """Return all of the current active files as a flast list"""
1034 return c
.staged
+ c
.unmerged
+ c
.modified
+ c
.untracked
1036 def selected_group(self
):
1037 """A list of selected files in various states of being"""
1038 return selection
.pick(self
.selection())
1040 def selected_idx(self
):
1042 s
= self
.single_selection()
1044 for content
, sel
in zip(c
, s
):
1048 return offset
+ content
.index(sel
)
1049 offset
+= len(content
)
1052 def select_by_index(self
, idx
):
1055 (c
.staged
, STAGED_IDX
),
1056 (c
.unmerged
, UNMERGED_IDX
),
1057 (c
.modified
, MODIFIED_IDX
),
1058 (c
.untracked
, UNTRACKED_IDX
),
1060 for content
, toplevel_idx
in to_try
:
1063 if idx
< len(content
):
1064 parent
= self
.topLevelItem(toplevel_idx
)
1065 item
= parent
.child(idx
)
1066 if item
is not None:
1067 qtutils
.select_item(self
, item
)
1072 return qtutils
.get_selected_values(self
, STAGED_IDX
, self
._model
.staged
)
1075 return self
.unmerged() + self
.modified() + self
.untracked()
1078 return qtutils
.get_selected_values(self
, MODIFIED_IDX
, self
._model
.modified
)
1081 return qtutils
.get_selected_values(self
, UNMERGED_IDX
, self
._model
.unmerged
)
1083 def untracked(self
):
1084 return qtutils
.get_selected_values(self
, UNTRACKED_IDX
, self
._model
.untracked
)
1086 def staged_items(self
):
1087 return qtutils
.get_selected_items(self
, STAGED_IDX
)
1089 def unstaged_items(self
):
1090 return self
.unmerged_items() + self
.modified_items() + self
.untracked_items()
1092 def modified_items(self
):
1093 return qtutils
.get_selected_items(self
, MODIFIED_IDX
)
1095 def unmerged_items(self
):
1096 return qtutils
.get_selected_items(self
, UNMERGED_IDX
)
1098 def untracked_items(self
):
1099 return qtutils
.get_selected_items(self
, UNTRACKED_IDX
)
1101 def show_selection(self
):
1102 """Show the selected item."""
1103 context
= self
.context
1104 qtutils
.scroll_to_item(self
, self
.currentItem())
1105 # Sync the selection model
1106 selected
= self
.selection()
1107 selection_model
= self
.selection_model
1108 selection_model
.set_selection(selected
)
1109 self
._update
_actions
(selected
=selected
)
1111 selected_indexes
= self
.selected_indexes()
1112 if not selected_indexes
:
1113 if self
._model
.is_amend_mode() or self
._model
.is_diff_mode():
1114 cmds
.do(cmds
.SetDiffText
, context
, '')
1116 cmds
.do(cmds
.ResetMode
, context
)
1119 # A header item e.g. 'Staged', 'Modified', etc.
1120 category
, idx
= selected_indexes
[0]
1121 header
= category
== HEADER_IDX
1124 STAGED_IDX
: cmds
.DiffStagedSummary
,
1125 MODIFIED_IDX
: cmds
.Diffstat
,
1126 UNMERGED_IDX
: cmds
.UnmergedSummary
,
1127 UNTRACKED_IDX
: cmds
.UntrackedSummary
,
1128 }.get(idx
, cmds
.Diffstat
)
1129 cmds
.do(cls
, context
)
1132 staged
= category
== STAGED_IDX
1133 modified
= category
== MODIFIED_IDX
1134 unmerged
= category
== UNMERGED_IDX
1135 untracked
= category
== UNTRACKED_IDX
1138 item
= self
.staged_items()[0]
1140 item
= self
.unmerged_items()[0]
1142 item
= self
.modified_items()[0]
1144 item
= self
.unstaged_items()[0]
1146 item
= None # this shouldn't happen
1147 assert item
is not None
1150 deleted
= item
.deleted
1151 image
= self
.image_formats
.ok(path
)
1153 # Update the diff text
1155 cmds
.do(cmds
.DiffStaged
, context
, path
, deleted
=deleted
)
1157 cmds
.do(cmds
.Diff
, context
, path
, deleted
=deleted
)
1159 cmds
.do(cmds
.Diff
, context
, path
)
1161 cmds
.do(cmds
.ShowUntracked
, context
, path
)
1163 # Images are diffed differently.
1164 # DiffImage transitions the diff mode to image.
1165 # DiffText transitions the diff mode to text.
1178 cmds
.do(cmds
.DiffText
, context
)
1180 def select_header(self
):
1181 """Select an active header, which triggers a diffstat"""
1188 item
= self
.topLevelItem(idx
)
1189 if item
.childCount() > 0:
1190 self
.clearSelection()
1191 self
.setCurrentItem(item
)
1195 """Select the item above the currently selected item"""
1196 idx
= self
.selected_idx()
1197 all_files
= self
.all_files()
1199 selected_indexes
= self
.selected_indexes()
1200 if selected_indexes
:
1201 category
, toplevel_idx
= selected_indexes
[0]
1202 if category
== HEADER_IDX
:
1203 item
= self
.itemAbove(self
.topLevelItem(toplevel_idx
))
1204 if item
is not None:
1205 qtutils
.select_item(self
, item
)
1208 self
.select_by_index(len(all_files
) - 1)
1211 self
.select_by_index(idx
- 1)
1213 self
.select_by_index(len(all_files
) - 1)
1215 def move_down(self
):
1216 """Select the item below the currently selected item"""
1217 idx
= self
.selected_idx()
1218 all_files
= self
.all_files()
1220 selected_indexes
= self
.selected_indexes()
1221 if selected_indexes
:
1222 category
, toplevel_idx
= selected_indexes
[0]
1223 if category
== HEADER_IDX
:
1224 item
= self
.itemBelow(self
.topLevelItem(toplevel_idx
))
1225 if item
is not None:
1226 qtutils
.select_item(self
, item
)
1229 self
.select_by_index(0)
1231 if idx
+ 1 < len(all_files
):
1232 self
.select_by_index(idx
+ 1)
1234 self
.select_by_index(0)
1236 def mousePressEvent(self
, event
):
1237 """Keep track of whether to drag URLs or just text"""
1238 self
._alt
_drag
= event
.modifiers() & Qt
.AltModifier
1239 return super(StatusTreeWidget
, self
).mousePressEvent(event
)
1241 def mouseMoveEvent(self
, event
):
1242 """Keep track of whether to drag URLs or just text"""
1243 self
._alt
_drag
= event
.modifiers() & Qt
.AltModifier
1244 return super(StatusTreeWidget
, self
).mouseMoveEvent(event
)
1246 def mimeData(self
, items
):
1247 """Return a list of absolute-path URLs"""
1248 context
= self
.context
1249 paths
= qtutils
.paths_from_items(items
, item_filter
=_item_filter
)
1250 include_urls
= not self
._alt
_drag
1251 return qtutils
.mimedata_from_paths(context
, paths
, include_urls
=include_urls
)
1253 def mimeTypes(self
):
1254 """Return the mimetypes that this widget generates"""
1255 return qtutils
.path_mimetypes(include_urls
=not self
._alt
_drag
)
1258 def _item_filter(item
):
1259 """Filter items down to just those that exist on disk"""
1260 return not item
.deleted
and core
.exists(item
.path
)
1263 def view_blame(context
):
1264 """Signal that we should view blame for paths."""
1265 cmds
.do(cmds
.BlamePaths
, context
)
1268 def view_history(context
):
1269 """Signal that we should view history for paths."""
1270 cmds
.do(cmds
.VisualizePaths
, context
, context
.selection
.union())
1273 def copy_path(context
, absolute
=True):
1274 """Copy a selected path to the clipboard"""
1275 filename
= context
.selection
.filename()
1276 qtutils
.copy_path(filename
, absolute
=absolute
)
1279 def copy_relpath(context
):
1280 """Copy a selected relative path to the clipboard"""
1281 copy_path(context
, absolute
=False)
1284 def copy_basename(context
):
1285 filename
= os
.path
.basename(context
.selection
.filename())
1286 basename
, _
= os
.path
.splitext(filename
)
1287 qtutils
.copy_path(basename
, absolute
=False)
1290 def copy_leading_path(context
, strip_components
):
1291 """Peal off trailing path components and copy the current path to the clipboard"""
1292 filename
= context
.selection
.filename()
1294 for _
in range(strip_components
):
1295 value
= os
.path
.dirname(value
)
1296 qtutils
.copy_path(value
, absolute
=False)
1299 def copy_format(context
, fmt
):
1300 """Add variables usable in the custom Copy format strings"""
1302 values
['path'] = path
= context
.selection
.filename()
1303 values
['abspath'] = abspath
= os
.path
.abspath(path
)
1304 values
['absdirname'] = os
.path
.dirname(abspath
)
1305 values
['dirname'] = os
.path
.dirname(path
)
1306 values
['filename'] = os
.path
.basename(path
)
1307 values
['basename'], values
['ext'] = os
.path
.splitext(os
.path
.basename(path
))
1308 qtutils
.set_clipboard(fmt
% values
)
1311 def show_help(context
):
1312 """Display the help for the custom Copy format strings"""
1315 Format String Variables
1316 -----------------------
1317 %(path)s = relative file path
1318 %(abspath)s = absolute file path
1319 %(dirname)s = relative directory path
1320 %(absdirname)s = absolute directory path
1321 %(filename)s = file basename
1322 %(basename)s = file basename without extension
1323 %(ext)s = file extension
1326 title
= N_('Help - Custom Copy Actions')
1327 return text
.text_dialog(context
, help_text
, title
)
1330 class StatusFilterWidget(QtWidgets
.QWidget
):
1331 """Filter paths displayed by the Status tool"""
1332 def __init__(self
, context
, parent
=None):
1333 QtWidgets
.QWidget
.__init
__(self
, parent
)
1334 self
.context
= context
1336 hint
= N_('Filter paths...')
1337 self
.text
= completion
.GitStatusFilterLineEdit(context
, hint
=hint
, parent
=self
)
1338 self
.text
.setToolTip(hint
)
1339 self
.setFocusProxy(self
.text
)
1342 self
.main_layout
= qtutils
.hbox(defs
.no_margin
, defs
.spacing
, self
.text
)
1343 self
.setLayout(self
.main_layout
)
1346 # pylint: disable=no-member
1347 widget
.changed
.connect(self
.apply_filter
)
1348 widget
.cleared
.connect(self
.apply_filter
)
1349 widget
.enter
.connect(self
.apply_filter
)
1350 widget
.editingFinished
.connect(self
.apply_filter
)
1352 def apply_filter(self
):
1353 """Apply the text filter to the model"""
1354 value
= get(self
.text
)
1355 if value
== self
._filter
:
1357 self
._filter
= value
1358 paths
= utils
.shell_split(value
)
1359 self
.context
.model
.update_path_filter(paths
)
1362 def customize_copy_actions(context
, parent
):
1363 """Customize copy actions"""
1364 dialog
= CustomizeCopyActions(context
, parent
)
1369 class CustomizeCopyActions(standard
.Dialog
):
1370 """A dialog for defining custom Copy actions and format strings"""
1372 def __init__(self
, context
, parent
):
1373 standard
.Dialog
.__init
__(self
, parent
=parent
)
1374 self
.setWindowTitle(N_('Custom Copy Actions'))
1376 self
.context
= context
1377 self
.table
= QtWidgets
.QTableWidget(self
)
1378 self
.table
.setColumnCount(2)
1379 self
.table
.setHorizontalHeaderLabels([N_('Action Name'), N_('Format String')])
1380 self
.table
.setSortingEnabled(False)
1381 self
.table
.verticalHeader().hide()
1382 self
.table
.horizontalHeader().setStretchLastSection(True)
1384 self
.add_button
= qtutils
.create_button(N_('Add'))
1385 self
.remove_button
= qtutils
.create_button(N_('Remove'))
1386 self
.remove_button
.setEnabled(False)
1387 self
.show_help_button
= qtutils
.create_button(N_('Show Help'))
1388 self
.show_help_button
.setShortcut(hotkeys
.QUESTION
)
1390 self
.close_button
= qtutils
.close_button()
1391 self
.save_button
= qtutils
.ok_button(N_('Save'))
1393 self
.buttons
= qtutils
.hbox(
1395 defs
.button_spacing
,
1398 self
.show_help_button
,
1404 layout
= qtutils
.vbox(defs
.margin
, defs
.spacing
, self
.table
, self
.buttons
)
1405 self
.setLayout(layout
)
1407 qtutils
.connect_button(self
.add_button
, self
.add
)
1408 qtutils
.connect_button(self
.remove_button
, self
.remove
)
1409 qtutils
.connect_button(self
.show_help_button
, partial(show_help
, context
))
1410 qtutils
.connect_button(self
.close_button
, self
.reject
)
1411 qtutils
.connect_button(self
.save_button
, self
.save
)
1412 qtutils
.add_close_action(self
)
1413 # pylint: disable=no-member
1414 self
.table
.itemSelectionChanged
.connect(self
.table_selection_changed
)
1416 self
.init_size(parent
=parent
)
1418 QtCore
.QTimer
.singleShot(0, self
.reload_settings
)
1420 def reload_settings(self
):
1421 """Update the view to match the current settings"""
1422 # Called once after the GUI is initialized
1423 settings
= self
.context
.settings
1426 for entry
in settings
.copy_formats
:
1427 name_string
= entry
.get('name', '')
1428 format_string
= entry
.get('format', '')
1429 if name_string
and format_string
:
1430 name
= QtWidgets
.QTableWidgetItem(name_string
)
1431 fmt
= QtWidgets
.QTableWidgetItem(format_string
)
1432 rows
= table
.rowCount()
1433 table
.setRowCount(rows
+ 1)
1434 table
.setItem(rows
, 0, name
)
1435 table
.setItem(rows
, 1, fmt
)
1437 def export_state(self
):
1438 """Export the current state into the saved settings"""
1439 state
= super(CustomizeCopyActions
, self
).export_state()
1440 standard
.export_header_columns(self
.table
, state
)
1443 def apply_state(self
, state
):
1444 """Restore state from the saved settings"""
1445 result
= super(CustomizeCopyActions
, self
).apply_state(state
)
1446 standard
.apply_header_columns(self
.table
, state
)
1450 """Add a custom Copy action and format string"""
1451 self
.table
.setFocus()
1452 rows
= self
.table
.rowCount()
1453 self
.table
.setRowCount(rows
+ 1)
1455 name
= QtWidgets
.QTableWidgetItem(N_('Name'))
1456 fmt
= QtWidgets
.QTableWidgetItem(r
'%(path)s')
1457 self
.table
.setItem(rows
, 0, name
)
1458 self
.table
.setItem(rows
, 1, fmt
)
1460 self
.table
.setCurrentCell(rows
, 0)
1461 self
.table
.editItem(name
)
1464 """Remove selected items"""
1465 # Gather a unique set of rows and remove them in reverse order
1467 items
= self
.table
.selectedItems()
1469 rows
.add(self
.table
.row(item
))
1471 for row
in reversed(sorted(rows
)):
1472 self
.table
.removeRow(row
)
1475 """Save custom copy actions to the settings"""
1477 for row
in range(self
.table
.rowCount()):
1478 name
= self
.table
.item(row
, 0)
1479 fmt
= self
.table
.item(row
, 1)
1482 'name': name
.text(),
1483 'format': fmt
.text(),
1485 copy_formats
.append(entry
)
1487 settings
= self
.context
.settings
1488 while settings
.copy_formats
:
1489 settings
.copy_formats
.pop()
1491 settings
.copy_formats
.extend(copy_formats
)
1496 def table_selection_changed(self
):
1497 """Update the enabled state of action buttons based on the current selection"""
1498 items
= self
.table
.selectedItems()
1499 self
.remove_button
.setEnabled(bool(items
))
1502 def _select_item(widget
, path_list
, widget_getter
, item
, current
=False):
1503 """Select the widget item based on the list index"""
1504 # The path lists and widget indexes have a 1:1 correspondence.
1505 # Lookup the item filename in the list and use that index to
1506 # retrieve the widget item and select it.
1507 idx
= path_list
.index(item
)
1508 item
= widget_getter(idx
)
1510 widget
.setCurrentItem(item
)
1511 item
.setSelected(True)
1514 def _apply_toplevel_selection(widget
, category
, idx
):
1515 """Select a top-level "header" item (ex: the Staged parent item)
1517 Return True when a top-level item is selected.
1519 is_top_level_item
= category
== HEADER_IDX
1520 if is_top_level_item
:
1521 root_item
= widget
.invisibleRootItem()
1522 item
= root_item
.child(idx
)
1524 if item
is not None and item
.childCount() == 0:
1525 # The item now has no children. Select a different top-level item
1526 # corresponding to the previously selected item.
1527 if idx
== STAGED_IDX
:
1528 # If "Staged" was previously selected try "Modified" and "Untracked".
1529 item
= _get_first_item_with_children(
1530 root_item
.child(MODIFIED_IDX
), root_item
.child(UNTRACKED_IDX
)
1532 elif idx
== UNMERGED_IDX
:
1533 # If "Unmerged" was previously selected try "Staged".
1534 item
= _get_first_item_with_children(root_item
.child(STAGED_IDX
))
1535 elif idx
== MODIFIED_IDX
:
1536 # If "Modified" was previously selected try "Staged" or "Untracked".
1537 item
= _get_first_item_with_children(
1538 root_item
.child(STAGED_IDX
), root_item
.child(UNTRACKED_IDX
)
1540 elif idx
== UNTRACKED_IDX
:
1541 # If "Untracked" was previously selected try "Staged".
1542 item
= _get_first_item_with_children(root_item
.child(STAGED_IDX
))
1544 if item
is not None:
1545 with qtutils
.BlockSignals(widget
):
1546 widget
.setCurrentItem(item
)
1547 item
.setSelected(True)
1548 widget
.show_selection()
1549 return is_top_level_item
1552 def _get_first_item_with_children(*items
):
1553 """Return the first item that contains child items"""
1555 if item
.childCount() > 0:
1560 def _transplant_selection_across_sections(
1561 category
, idx
, previous_contents
, saved_selection
1563 """Transplant the selection to a different category"""
1564 # This function is used when the selection would otherwise become empty.
1565 # Apply heuristics to select the items based on the previous state.
1566 if not previous_contents
:
1568 staged
, unmerged
, modified
, untracked
= saved_selection
1569 prev_staged
, prev_unmerged
, prev_modified
, prev_untracked
= previous_contents
1571 # The current set of paths.
1572 staged_paths
= staged
[NEW_PATHS_IDX
]
1573 unmerged_paths
= unmerged
[NEW_PATHS_IDX
]
1574 modified_paths
= modified
[NEW_PATHS_IDX
]
1575 untracked_paths
= untracked
[NEW_PATHS_IDX
]
1577 # These callbacks select a path in the corresponding widget subtree lists.
1578 select_staged
= staged
[SELECT_FN_IDX
]
1579 select_unmerged
= unmerged
[SELECT_FN_IDX
]
1580 select_modified
= modified
[SELECT_FN_IDX
]
1581 select_untracked
= untracked
[SELECT_FN_IDX
]
1583 if category
== STAGED_IDX
:
1584 # Staged files can become Unmerged, Modified or Untracked.
1585 # If we previously had a staged file selected then try to select
1586 # it in either the Unmerged, Modified or Untracked sections.
1588 old_path
= prev_staged
[idx
]
1591 if old_path
in unmerged_paths
:
1592 select_unmerged(old_path
, current
=True)
1593 elif old_path
in modified_paths
:
1594 select_modified(old_path
, current
=True)
1595 elif old_path
in untracked_paths
:
1596 select_untracked(old_path
, current
=True)
1598 elif category
== UNMERGED_IDX
:
1599 # Unmerged files can become Staged, Modified or Untracked.
1600 # If we previously had an unmerged file selected then try to select it in
1601 # the Staged, Modified or Untracked sections.
1603 old_path
= prev_unmerged
[idx
]
1606 if old_path
in staged_paths
:
1607 select_staged(old_path
, current
=True)
1608 elif old_path
in modified_paths
:
1609 select_modified(old_path
, current
=True)
1610 elif old_path
in untracked_paths
:
1611 select_untracked(old_path
, current
=True)
1613 elif category
== MODIFIED_IDX
:
1614 # If we previously had a modified file selected then try to select
1615 # it in either the Staged or Untracked sections.
1617 old_path
= prev_modified
[idx
]
1620 if old_path
in staged_paths
:
1621 select_staged(old_path
, current
=True)
1622 elif old_path
in untracked_paths
:
1623 select_untracked(old_path
, current
=True)
1625 elif category
== UNTRACKED_IDX
:
1626 # If we previously had an untracked file selected then try to select
1627 # it in the Modified or Staged section. Modified is less common, but
1628 # it's possible for a file to be untracked and then the user adds and
1629 # modifies the file before we've refreshed our state.
1631 old_path
= prev_untracked
[idx
]
1634 if old_path
in modified_paths
:
1635 select_modified(old_path
, current
=True)
1636 elif old_path
in staged_paths
:
1637 select_staged(old_path
, current
=True)
1640 class CopyLeadingPathWidget(QtWidgets
.QWidget
):
1641 """A widget that holds a label and a spinbox for the number of paths to strip"""
1643 def __init__(self
, title
, context
, parent
):
1644 QtWidgets
.QWidget
.__init
__(self
, parent
)
1645 self
.context
= context
1646 self
.icon
= QtWidgets
.QLabel(self
)
1647 self
.label
= QtWidgets
.QLabel(self
)
1648 self
.spinbox
= standard
.SpinBox(value
=1, mini
=1, maxi
=99, parent
=self
)
1649 self
.spinbox
.setToolTip(N_('The number of leading paths to strip'))
1652 pixmap
= icon
.pixmap(defs
.default_icon
, defs
.default_icon
)
1653 self
.icon
.setPixmap(pixmap
)
1654 self
.label
.setText(title
)
1656 layout
= qtutils
.hbox(
1658 defs
.titlebar_spacing
,
1664 self
.setLayout(layout
)
1666 theme
= context
.app
.theme
1667 highlight_rgb
= theme
.highlight_color_rgb()
1668 text_rgb
, highlight_text_rgb
= theme
.text_colors_rgb()
1669 disabled_text_rgb
= theme
.disabled_text_color_rgb()
1673 show-decoration-selected: 1
1676 color: %(text_rgb)s;
1677 show-decoration-selected: 1
1680 color: %(highlight_text_rgb)s;
1681 background-color: %(highlight_rgb)s;
1682 background-clip: padding;
1683 show-decoration-selected: 1
1686 color: %(disabled_text_rgb)s;
1689 disabled_text_rgb
=disabled_text_rgb
,
1691 highlight_text_rgb
=highlight_text_rgb
,
1692 highlight_rgb
=highlight_rgb
,
1695 self
.setStyleSheet(stylesheet
)
1698 """Return the current value of the spinbox"""
1699 return self
.spinbox
.value()
1701 def set_value(self
, value
):
1702 """Set the spinbox value"""
1703 self
.spinbox
.setValue(value
)