status: improve the selection behavior for header items
[git-cola.git] / cola / widgets / status.py
blob031980e12eb8e75dc5afb81d27d757cca32ffae8
1 from __future__ import division, absolute_import, unicode_literals
2 import itertools
3 import os
4 from functools import partial
6 from qtpy.QtCore import Qt
7 from qtpy.QtCore import Signal
8 from qtpy import QtCore
9 from qtpy import QtWidgets
11 from ..i18n import N_
12 from ..models import prefs
13 from ..models import selection
14 from ..widgets import gitignore
15 from ..widgets import standard
16 from ..qtutils import get
17 from ..settings import Settings
18 from .. import actions
19 from .. import cmds
20 from .. import core
21 from .. import hotkeys
22 from .. import icons
23 from .. import qtutils
24 from .. import utils
25 from . import common
26 from . import completion
27 from . import defs
28 from . import text
31 # Top-level status widget item indexes.
32 HEADER_IDX = -1
33 STAGED_IDX = 0
34 UNMERGED_IDX = 1
35 MODIFIED_IDX = 2
36 UNTRACKED_IDX = 3
37 END_IDX = 4
39 # Indexes into the saved_selection entries.
40 NEW_PATHS_IDX = 0
41 OLD_PATHS_IDX = 1
42 SELECTION_IDX = 2
43 SELECT_FN_IDX = 3
46 class StatusWidget(QtWidgets.QFrame):
47 """
48 Provides a git-status-like repository widget.
50 This widget observes the main model and broadcasts
51 Qt signals.
53 """
55 def __init__(self, context, titlebar, parent):
56 QtWidgets.QFrame.__init__(self, parent)
57 self.context = context
59 tooltip = N_('Toggle the paths filter')
60 icon = icons.ellipsis()
61 self.filter_button = qtutils.create_action_button(tooltip=tooltip, icon=icon)
62 self.filter_widget = StatusFilterWidget(context)
63 self.filter_widget.hide()
64 self.tree = StatusTreeWidget(context, parent=self)
65 self.setFocusProxy(self.tree)
67 self.main_layout = qtutils.vbox(
68 defs.no_margin, defs.no_spacing, self.filter_widget, self.tree
70 self.setLayout(self.main_layout)
72 self.toggle_action = qtutils.add_action(
73 self, tooltip, self.toggle_filter, hotkeys.FILTER
76 titlebar.add_corner_widget(self.filter_button)
77 qtutils.connect_button(self.filter_button, self.toggle_filter)
79 def toggle_filter(self):
80 shown = not self.filter_widget.isVisible()
81 self.filter_widget.setVisible(shown)
82 if shown:
83 self.filter_widget.setFocus()
84 else:
85 self.tree.setFocus()
87 def set_initial_size(self):
88 self.setMaximumWidth(222)
89 QtCore.QTimer.singleShot(1, lambda: self.setMaximumWidth(2 ** 13))
91 def refresh(self):
92 self.tree.show_selection()
94 def set_filter(self, txt):
95 self.filter_widget.setVisible(True)
96 self.filter_widget.text.set_value(txt)
97 self.filter_widget.apply_filter()
99 def move_up(self):
100 self.tree.move_up()
102 def move_down(self):
103 self.tree.move_down()
105 def select_header(self):
106 self.tree.select_header()
109 # pylint: disable=too-many-ancestors
110 class StatusTreeWidget(QtWidgets.QTreeWidget):
111 # Signals
112 about_to_update = Signal()
113 set_previous_contents = Signal(list, list, list, list)
114 updated = Signal()
115 diff_text_changed = Signal()
117 # Read-only access to the mode state
118 mode = property(lambda self: self.m.mode)
120 def __init__(self, context, parent=None):
121 QtWidgets.QTreeWidget.__init__(self, parent)
122 self.context = context
123 self.selection_model = context.selection
125 self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
126 self.headerItem().setHidden(True)
127 self.setAllColumnsShowFocus(True)
128 self.setSortingEnabled(False)
129 self.setUniformRowHeights(True)
130 self.setAnimated(True)
131 self.setRootIsDecorated(False)
132 self.setDragEnabled(True)
133 self.setAutoScroll(False)
135 if not prefs.status_indent(context):
136 self.setIndentation(0)
138 ok = icons.ok()
139 compare = icons.compare()
140 question = icons.question()
141 self._add_toplevel_item(N_('Staged'), ok, hide=True)
142 self._add_toplevel_item(N_('Unmerged'), compare, hide=True)
143 self._add_toplevel_item(N_('Modified'), compare, hide=True)
144 self._add_toplevel_item(N_('Untracked'), question, hide=True)
146 # Used to restore the selection
147 self.old_vscroll = None
148 self.old_hscroll = None
149 self.old_selection = None
150 self.old_contents = None
151 self.old_current_item = None
152 self.previous_contents = None
153 self.was_visible = True
154 self.expanded_items = set()
156 self.image_formats = qtutils.ImageFormats()
158 self.process_selection_action = qtutils.add_action(
159 self,
160 cmds.StageOrUnstage.name(),
161 self._stage_selection,
162 hotkeys.STAGE_SELECTION,
164 self.process_selection_action.setIcon(icons.add())
166 self.stage_or_unstage_all_action = qtutils.add_action(
167 self,
168 cmds.StageOrUnstageAll.name(),
169 cmds.run(cmds.StageOrUnstageAll, self.context),
170 hotkeys.STAGE_ALL,
172 self.stage_or_unstage_all_action.setIcon(icons.add())
174 self.revert_unstaged_edits_action = qtutils.add_action(
175 self,
176 cmds.RevertUnstagedEdits.name(),
177 cmds.run(cmds.RevertUnstagedEdits, context),
178 hotkeys.REVERT,
180 self.revert_unstaged_edits_action.setIcon(icons.undo())
182 self.launch_difftool_action = qtutils.add_action(
183 self,
184 cmds.LaunchDifftool.name(),
185 cmds.run(cmds.LaunchDifftool, context),
186 hotkeys.DIFF,
188 self.launch_difftool_action.setIcon(icons.diff())
190 self.launch_editor_action = actions.launch_editor_at_line(
191 context, self, *hotkeys.ACCEPT
194 if not utils.is_win32():
195 self.default_app_action = common.default_app_action(
196 context, self, self.selected_group
199 self.parent_dir_action = common.parent_dir_action(
200 context, self, self.selected_group
203 self.terminal_action = common.terminal_action(
204 context, self, self.selected_group
207 self.up_action = qtutils.add_action(
208 self,
209 N_('Move Up'),
210 self.move_up,
211 hotkeys.MOVE_UP,
212 hotkeys.MOVE_UP_SECONDARY,
215 self.down_action = qtutils.add_action(
216 self,
217 N_('Move Down'),
218 self.move_down,
219 hotkeys.MOVE_DOWN,
220 hotkeys.MOVE_DOWN_SECONDARY,
223 self.copy_path_action = qtutils.add_action(
224 self,
225 N_('Copy Path to Clipboard'),
226 partial(copy_path, context),
227 hotkeys.COPY,
229 self.copy_path_action.setIcon(icons.copy())
231 self.copy_relpath_action = qtutils.add_action(
232 self,
233 N_('Copy Relative Path to Clipboard'),
234 partial(copy_relpath, context),
235 hotkeys.CUT,
237 self.copy_relpath_action.setIcon(icons.copy())
239 self.copy_leading_path_action = qtutils.add_action(
240 self,
241 N_('Copy Leading Path to Clipboard'),
242 partial(copy_leading_path, context),
244 self.copy_leading_path_action.setIcon(icons.copy())
246 self.copy_basename_action = qtutils.add_action(
247 self, N_('Copy Basename to Clipboard'), partial(copy_basename, context)
249 self.copy_basename_action.setIcon(icons.copy())
251 self.copy_customize_action = qtutils.add_action(
252 self, N_('Customize...'), partial(customize_copy_actions, context, self)
254 self.copy_customize_action.setIcon(icons.configure())
256 self.view_history_action = qtutils.add_action(
257 self, N_('View History...'), partial(view_history, context), hotkeys.HISTORY
260 self.view_blame_action = qtutils.add_action(
261 self, N_('Blame...'), partial(view_blame, context), hotkeys.BLAME
264 self.annex_add_action = qtutils.add_action(
265 self, N_('Add to Git Annex'), cmds.run(cmds.AnnexAdd, context)
268 self.lfs_track_action = qtutils.add_action(
269 self, N_('Add to Git LFS'), cmds.run(cmds.LFSTrack, context)
272 # MoveToTrash and Delete use the same shortcut.
273 # We will only bind one of them, depending on whether or not the
274 # MoveToTrash command is available. When available, the hotkey
275 # is bound to MoveToTrash, otherwise it is bound to Delete.
276 if cmds.MoveToTrash.AVAILABLE:
277 self.move_to_trash_action = qtutils.add_action(
278 self,
279 N_('Move files to trash'),
280 self._trash_untracked_files,
281 hotkeys.TRASH,
283 self.move_to_trash_action.setIcon(icons.discard())
284 delete_shortcut = hotkeys.DELETE_FILE
285 else:
286 self.move_to_trash_action = None
287 delete_shortcut = hotkeys.DELETE_FILE_SECONDARY
289 self.delete_untracked_files_action = qtutils.add_action(
290 self, N_('Delete Files...'), self._delete_untracked_files, delete_shortcut
292 self.delete_untracked_files_action.setIcon(icons.discard())
294 self.about_to_update.connect(self._about_to_update, type=Qt.QueuedConnection)
295 self.set_previous_contents.connect(
296 self._set_previous_contents, type=Qt.QueuedConnection)
297 self.updated.connect(self.refresh, type=Qt.QueuedConnection)
298 self.diff_text_changed.connect(
299 self._make_current_item_visible, type=Qt.QueuedConnection
302 # The model is stored as self.m because self.model() is a
303 # QTreeWidgetItem method that returns a QAbstractItemModel.
304 self.m = context.model
305 # Forward the previous_contents notification through self.set_previous_contents.
306 self.m.add_observer(
307 self.m.message_previous_contents, self.set_previous_contents.emit
309 # Forward the about_to_update notification through self.about_to_udpate.
310 self.m.add_observer(self.m.message_about_to_update, self.about_to_update.emit)
311 # Foward the updated notification through self.updated.
312 self.m.add_observer(self.m.message_updated, self.updated.emit)
313 self.m.add_observer(
314 self.m.message_diff_text_changed, self.diff_text_changed.emit
316 # pylint: disable=no-member
317 self.itemSelectionChanged.connect(self.show_selection)
318 self.itemDoubleClicked.connect(cmds.run(cmds.StageOrUnstage, self.context))
319 self.itemCollapsed.connect(lambda x: self._update_column_widths())
320 self.itemExpanded.connect(lambda x: self._update_column_widths())
322 def _make_current_item_visible(self):
323 item = self.currentItem()
324 if item:
325 qtutils.scroll_to_item(self, item)
327 def _add_toplevel_item(self, txt, icon, hide=False):
328 context = self.context
329 font = self.font()
330 if prefs.bold_headers(context):
331 font.setBold(True)
332 else:
333 font.setItalic(True)
335 item = QtWidgets.QTreeWidgetItem(self)
336 item.setFont(0, font)
337 item.setText(0, txt)
338 item.setIcon(0, icon)
339 if prefs.bold_headers(context):
340 item.setBackground(0, self.palette().midlight())
341 if hide:
342 item.setHidden(True)
344 def _restore_selection(self):
345 """Apply the old selection to the newly updated items"""
346 # This function is called after a new set of items have been added to
347 # the per-category file lists. Its purpose is to either restore the
348 # existing selection or to create a new intuitive selection based on
349 # a combination of the old items, the old selection and the new items.
350 if not self.old_selection or not self.old_contents:
351 return
352 # The old set of categorized files.
353 old_c = self.old_contents
354 # The old selection.
355 old_s = self.old_selection
356 # The current/new set of categorized files.
357 new_c = self.contents()
359 select_staged = partial(
360 _select_item, self, new_c.staged, self._staged_item
362 select_unmerged = partial(
363 _select_item, self, new_c.unmerged, self._unmerged_item
365 select_modified = partial(
366 _select_item, self, new_c.modified, self._modified_item
368 select_untracked = partial(
369 _select_item, self, new_c.untracked, self._untracked_item
372 saved_selection = [
373 (set(new_c.staged), old_c.staged, set(old_s.staged), select_staged),
374 (set(new_c.unmerged), old_c.unmerged, set(old_s.unmerged), select_unmerged),
375 (set(new_c.modified), old_c.modified, set(old_s.modified), select_modified),
377 set(new_c.untracked),
378 old_c.untracked,
379 set(old_s.untracked),
380 select_untracked,
384 # Restore the current item
385 if self.old_current_item:
386 category, idx = self.old_current_item
387 if _apply_toplevel_selection(self, category, idx):
388 return
389 # Reselect the current item
390 selection_info = saved_selection[category]
391 new = selection_info[NEW_PATHS_IDX]
392 old = selection_info[OLD_PATHS_IDX]
393 reselect = selection_info[SELECT_FN_IDX]
394 try:
395 item = old[idx]
396 except IndexError:
397 item = None
398 if item and item in new:
399 reselect(item, current=True)
401 # Restore previously selected items.
402 # When reselecting in this section we only care that the items are
403 # selected; we do not need to rerun the callbacks which were triggered
404 # above for the current item. Block signals to skip the callbacks.
406 # Reselect items that were previously selected and still exist in the
407 # current path lists. This handles a common case such as a Ctrl-R
408 # refresh which results in the same exact path state.
409 did_reselect = False
411 with qtutils.BlockSignals(self):
412 for (new, old, sel, reselect) in saved_selection:
413 for item in sel:
414 if item in new:
415 reselect(item, current=False)
416 did_reselect = True
418 # The status widget is used to interactively work your way down the
419 # list of Staged, Unmerged, Modified and Untracked items and perform
420 # an operation on them.
422 # For Staged items we intend to work our way down the list of Staged
423 # items while we unstage each item. For every other category we work
424 # our way down the list of {Unmerged,Modified,Untracked} items while
425 # we stage each item.
427 # The following block of code implements the behavior of selecting
428 # the next item based on the previous selection.
429 for (new, old, sel, reselect) in saved_selection:
430 # When modified is staged, select the next modified item
431 # When unmerged is staged, select the next unmerged item
432 # When unstaging, select the next staged item
433 # When staging untracked files, select the next untracked item
434 if len(new) >= len(old):
435 # The list did not shrink so it is not one of these cases.
436 continue
437 for item in sel:
438 # The item still exists so ignore it
439 if item in new or item not in old:
440 continue
441 # The item no longer exists in this list so search for
442 # its nearest neighbors and select them instead.
443 idx = old.index(item)
444 for j in itertools.chain(old[idx + 1 :], reversed(old[:idx])):
445 if j in new:
446 reselect(j, current=True)
447 return
449 # If we already reselected stuff then there's nothing more to do.
450 if did_reselect:
451 return
452 # If we got this far then nothing was reselected and made current.
453 # Try a few more heuristics that we can use to keep something selected.
454 if self.old_current_item:
455 category, idx = self.old_current_item
456 _transplant_selection_across_sections(
457 category, idx, self.previous_contents, saved_selection
460 def _restore_scrollbars(self):
461 """Restore scrollbars to the stored values"""
462 qtutils.set_scrollbar_values(self, self.old_hscroll, self.old_vscroll)
463 self.old_hscroll = None
464 self.old_vscroll = None
466 def _stage_selection(self):
467 """Stage or unstage files according to the selection"""
468 context = self.context
469 selected_indexes = self.selected_indexes()
470 is_header = any(
471 category == HEADER_IDX for (category, idx) in selected_indexes
473 if is_header:
474 is_staged = any(
475 idx == STAGED_IDX and category == HEADER_IDX
476 for (category, idx) in selected_indexes
478 is_modified = any(
479 idx == MODIFIED_IDX and category == HEADER_IDX
480 for (category, idx) in selected_indexes
482 is_untracked = any(
483 idx == UNTRACKED_IDX and category == HEADER_IDX
484 for (category, idx) in selected_indexes
486 # A header item: 'Staged', 'Modified' or 'Untracked'.
487 if is_staged:
488 # If we have the staged header selected then the only sensible
489 # thing to do is to unstage everything and nothing else, even
490 # if the modified or untracked headers are selected.
491 cmds.do(cmds.UnstageAll, context)
492 return # Everything was unstaged. There's nothing more to be done.
493 elif is_modified and is_untracked:
494 # If both modified and untracked headers are selected then
495 # stage everything.
496 cmds.do(cmds.StageModifiedAndUntracked, context)
497 return # Nothing more to do.
498 # At this point we may stage all modified and untracked, and then
499 # possibly a subset of the other category (eg. all modified and
500 # some untracked). We don't return here so that StageOrUnstage
501 # gets a chance to run below.
502 elif is_modified:
503 cmds.do(cmds.StageModified, context)
504 elif is_untracked:
505 cmds.do(cmds.StageUntracked, context)
506 else:
507 # Do nothing for unmerged items, by design
508 pass
509 # Now handle individual files
510 cmds.do(cmds.StageOrUnstage, context)
512 def _staged_item(self, itemidx):
513 return self._subtree_item(STAGED_IDX, itemidx)
515 def _modified_item(self, itemidx):
516 return self._subtree_item(MODIFIED_IDX, itemidx)
518 def _unmerged_item(self, itemidx):
519 return self._subtree_item(UNMERGED_IDX, itemidx)
521 def _untracked_item(self, itemidx):
522 return self._subtree_item(UNTRACKED_IDX, itemidx)
524 def _unstaged_item(self, itemidx):
525 # is it modified?
526 item = self.topLevelItem(MODIFIED_IDX)
527 count = item.childCount()
528 if itemidx < count:
529 return item.child(itemidx)
530 # is it unmerged?
531 item = self.topLevelItem(UNMERGED_IDX)
532 count += item.childCount()
533 if itemidx < count:
534 return item.child(itemidx)
535 # is it untracked?
536 item = self.topLevelItem(UNTRACKED_IDX)
537 count += item.childCount()
538 if itemidx < count:
539 return item.child(itemidx)
540 # Nope..
541 return None
543 def _subtree_item(self, idx, itemidx):
544 parent = self.topLevelItem(idx)
545 return parent.child(itemidx)
547 def _set_previous_contents(self, staged, unmerged, modified, untracked):
548 """Callback triggered right before the model changes its contents"""
549 self.previous_contents = selection.State(staged, unmerged, modified, untracked)
551 def _about_to_update(self):
552 self._save_scrollbars()
553 self._save_selection()
555 def _save_scrollbars(self):
556 """Store the scrollbar values for later application"""
557 hscroll, vscroll = qtutils.get_scrollbar_values(self)
558 if hscroll is not None:
559 self.old_hscroll = hscroll
560 if vscroll is not None:
561 self.old_vscroll = vscroll
563 def current_item(self):
564 s = self.selected_indexes()
565 if not s:
566 return None
567 current = self.currentItem()
568 if not current:
569 return None
570 idx = self.indexFromItem(current)
571 if idx.parent().isValid():
572 parent_idx = idx.parent()
573 entry = (parent_idx.row(), idx.row())
574 else:
575 entry = (HEADER_IDX, idx.row())
576 return entry
578 def _save_selection(self):
579 self.old_contents = self.contents()
580 self.old_selection = self.selection()
581 self.old_current_item = self.current_item()
583 def refresh(self):
584 self._set_staged(self.m.staged)
585 self._set_modified(self.m.modified)
586 self._set_unmerged(self.m.unmerged)
587 self._set_untracked(self.m.untracked)
588 self._update_column_widths()
589 self._update_actions()
590 self._restore_selection()
591 self._restore_scrollbars()
593 def _update_actions(self, selected=None):
594 if selected is None:
595 selected = self.selection_model.selection()
596 can_revert_edits = bool(selected.staged or selected.modified)
597 self.revert_unstaged_edits_action.setEnabled(can_revert_edits)
599 def _set_staged(self, items):
600 """Adds items to the 'Staged' subtree."""
601 with qtutils.BlockSignals(self):
602 self._set_subtree(
603 items,
604 STAGED_IDX,
605 N_('Staged'),
606 staged=True,
607 deleted_set=self.m.staged_deleted,
610 def _set_modified(self, items):
611 """Adds items to the 'Modified' subtree."""
612 with qtutils.BlockSignals(self):
613 self._set_subtree(
614 items,
615 MODIFIED_IDX,
616 N_('Modified'),
617 deleted_set=self.m.unstaged_deleted,
620 def _set_unmerged(self, items):
621 """Adds items to the 'Unmerged' subtree."""
622 deleted_set = set([path for path in items if not core.exists(path)])
623 with qtutils.BlockSignals(self):
624 self._set_subtree(
625 items, UNMERGED_IDX, N_('Unmerged'), deleted_set=deleted_set
628 def _set_untracked(self, items):
629 """Adds items to the 'Untracked' subtree."""
630 with qtutils.BlockSignals(self):
631 self._set_subtree(
632 items, UNTRACKED_IDX, N_('Untracked'), untracked=True
635 def _set_subtree(
636 self, items, idx, parent_title, staged=False, untracked=False, deleted_set=None
638 """Add a list of items to a treewidget item."""
639 parent = self.topLevelItem(idx)
640 hide = not bool(items)
641 parent.setHidden(hide)
643 # sip v4.14.7 and below leak memory in parent.takeChildren()
644 # so we use this backwards-compatible construct instead
645 while parent.takeChild(0) is not None:
646 pass
648 for item in items:
649 deleted = deleted_set is not None and item in deleted_set
650 treeitem = qtutils.create_treeitem(
651 item, staged=staged, deleted=deleted, untracked=untracked
653 parent.addChild(treeitem)
654 self._expand_items(idx, items)
656 if prefs.status_show_totals(self.context):
657 parent.setText(0, '%s (%s)' % (parent_title, len(items)))
659 def _update_column_widths(self):
660 self.resizeColumnToContents(0)
662 def _expand_items(self, idx, items):
663 """Expand the top-level category "folder" once and only once."""
664 # Don't do this if items is empty; this makes it so that we
665 # don't add the top-level index into the expanded_items set
666 # until an item appears in a particular category.
667 if not items:
668 return
669 # Only run this once; we don't want to re-expand items that
670 # we've clicked on to re-collapse on updated().
671 if idx in self.expanded_items:
672 return
673 self.expanded_items.add(idx)
674 item = self.topLevelItem(idx)
675 if item:
676 self.expandItem(item)
678 def contextMenuEvent(self, event):
679 """Create context menus for the repo status tree."""
680 menu = self._create_context_menu()
681 menu.exec_(self.mapToGlobal(event.pos()))
683 def _create_context_menu(self):
684 """Set up the status menu for the repo status tree."""
685 s = self.selection()
686 menu = qtutils.create_menu('Status', self)
687 selected_indexes = self.selected_indexes()
688 if selected_indexes:
689 category, idx = selected_indexes[0]
690 # A header item e.g. 'Staged', 'Modified', etc.
691 if category == HEADER_IDX:
692 return self._create_header_context_menu(menu, idx)
694 if s.staged:
695 self._create_staged_context_menu(menu, s)
696 elif s.unmerged:
697 self._create_unmerged_context_menu(menu, s)
698 else:
699 self._create_unstaged_context_menu(menu, s)
701 if not utils.is_win32():
702 if not menu.isEmpty():
703 menu.addSeparator()
704 if not self.selection_model.is_empty():
705 menu.addAction(self.default_app_action)
706 menu.addAction(self.parent_dir_action)
708 if self.terminal_action is not None:
709 menu.addAction(self.terminal_action)
711 self._add_copy_actions(menu)
713 return menu
715 def _add_copy_actions(self, menu):
716 """Add the "Copy" sub-menu"""
717 enabled = self.selection_model.filename() is not None
718 self.copy_path_action.setEnabled(enabled)
719 self.copy_relpath_action.setEnabled(enabled)
720 self.copy_leading_path_action.setEnabled(enabled)
721 self.copy_basename_action.setEnabled(enabled)
722 copy_icon = icons.copy()
724 menu.addSeparator()
725 copy_menu = QtWidgets.QMenu(N_('Copy...'), menu)
726 menu.addMenu(copy_menu)
728 copy_menu.setIcon(copy_icon)
729 copy_menu.addAction(self.copy_path_action)
730 copy_menu.addAction(self.copy_relpath_action)
731 copy_menu.addAction(self.copy_leading_path_action)
732 copy_menu.addAction(self.copy_basename_action)
734 settings = Settings.read()
735 copy_formats = settings.copy_formats
736 if copy_formats:
737 copy_menu.addSeparator()
739 context = self.context
740 for entry in copy_formats:
741 name = entry.get('name', '')
742 fmt = entry.get('format', '')
743 if name and fmt:
744 action = copy_menu.addAction(name, partial(copy_format, context, fmt))
745 action.setIcon(copy_icon)
746 action.setEnabled(enabled)
748 copy_menu.addSeparator()
749 copy_menu.addAction(self.copy_customize_action)
751 def _create_header_context_menu(self, menu, idx):
752 context = self.context
753 if idx == STAGED_IDX:
754 menu.addAction(
755 icons.remove(), N_('Unstage All'), cmds.run(cmds.UnstageAll, context)
757 elif idx == UNMERGED_IDX:
758 action = menu.addAction(
759 icons.add(),
760 cmds.StageUnmerged.name(),
761 cmds.run(cmds.StageUnmerged, context),
763 action.setShortcut(hotkeys.STAGE_SELECTION)
764 elif idx == MODIFIED_IDX:
765 action = menu.addAction(
766 icons.add(),
767 cmds.StageModified.name(),
768 cmds.run(cmds.StageModified, context),
770 action.setShortcut(hotkeys.STAGE_SELECTION)
771 elif idx == UNTRACKED_IDX:
772 action = menu.addAction(
773 icons.add(),
774 cmds.StageUntracked.name(),
775 cmds.run(cmds.StageUntracked, context),
777 action.setShortcut(hotkeys.STAGE_SELECTION)
778 return menu
780 def _create_staged_context_menu(self, menu, s):
781 if s.staged[0] in self.m.submodules:
782 return self._create_staged_submodule_context_menu(menu, s)
784 context = self.context
785 if self.m.unstageable():
786 action = menu.addAction(
787 icons.remove(),
788 N_('Unstage Selected'),
789 cmds.run(cmds.Unstage, context, self.staged()),
791 action.setShortcut(hotkeys.STAGE_SELECTION)
793 menu.addAction(self.launch_editor_action)
795 # Do all of the selected items exist?
796 all_exist = all(
797 i not in self.m.staged_deleted and core.exists(i) for i in self.staged()
800 if all_exist:
801 menu.addAction(self.launch_difftool_action)
803 if self.m.undoable():
804 menu.addAction(self.revert_unstaged_edits_action)
806 menu.addAction(self.view_history_action)
807 menu.addAction(self.view_blame_action)
808 return menu
810 def _create_staged_submodule_context_menu(self, menu, s):
811 context = self.context
812 path = core.abspath(s.staged[0])
813 if len(self.staged()) == 1:
814 menu.addAction(
815 icons.cola(),
816 N_('Launch git-cola'),
817 cmds.run(cmds.OpenRepo, context, path),
819 menu.addSeparator()
820 action = menu.addAction(
821 icons.remove(),
822 N_('Unstage Selected'),
823 cmds.run(cmds.Unstage, context, self.staged()),
825 action.setShortcut(hotkeys.STAGE_SELECTION)
827 menu.addAction(self.view_history_action)
828 return menu
830 def _create_unmerged_context_menu(self, menu, _s):
831 context = self.context
832 menu.addAction(self.launch_difftool_action)
834 action = menu.addAction(
835 icons.add(),
836 N_('Stage Selected'),
837 cmds.run(cmds.Stage, context, self.unstaged()),
839 action.setShortcut(hotkeys.STAGE_SELECTION)
841 menu.addAction(self.launch_editor_action)
842 menu.addAction(self.view_history_action)
843 menu.addAction(self.view_blame_action)
844 return menu
846 def _create_unstaged_context_menu(self, menu, s):
847 context = self.context
848 modified_submodule = s.modified and s.modified[0] in self.m.submodules
849 if modified_submodule:
850 return self._create_modified_submodule_context_menu(menu, s)
852 if self.m.stageable():
853 action = menu.addAction(
854 icons.add(),
855 N_('Stage Selected'),
856 cmds.run(cmds.Stage, context, self.unstaged()),
858 action.setShortcut(hotkeys.STAGE_SELECTION)
860 if not self.selection_model.is_empty():
861 menu.addAction(self.launch_editor_action)
863 # Do all of the selected items exist?
864 all_exist = all(
865 i not in self.m.unstaged_deleted and core.exists(i) for i in self.staged()
868 if all_exist and s.modified and self.m.stageable():
869 menu.addAction(self.launch_difftool_action)
871 if s.modified and self.m.stageable():
872 if self.m.undoable():
873 menu.addSeparator()
874 menu.addAction(self.revert_unstaged_edits_action)
876 if all_exist and s.untracked:
877 # Git Annex / Git LFS
878 annex = self.m.annex
879 lfs = core.find_executable('git-lfs')
880 if annex or lfs:
881 menu.addSeparator()
882 if annex:
883 menu.addAction(self.annex_add_action)
884 if lfs:
885 menu.addAction(self.lfs_track_action)
887 menu.addSeparator()
888 if self.move_to_trash_action is not None:
889 menu.addAction(self.move_to_trash_action)
890 menu.addAction(self.delete_untracked_files_action)
891 menu.addSeparator()
892 menu.addAction(
893 icons.edit(),
894 N_('Ignore...'),
895 partial(gitignore.gitignore_view, self.context),
898 if not self.selection_model.is_empty():
899 menu.addAction(self.view_history_action)
900 menu.addAction(self.view_blame_action)
901 return menu
903 def _create_modified_submodule_context_menu(self, menu, s):
904 context = self.context
905 path = core.abspath(s.modified[0])
906 if len(self.unstaged()) == 1:
907 menu.addAction(
908 icons.cola(),
909 N_('Launch git-cola'),
910 cmds.run(cmds.OpenRepo, context, path),
912 menu.addAction(
913 icons.pull(),
914 N_('Update this submodule'),
915 cmds.run(cmds.SubmoduleUpdate, context, path),
917 menu.addSeparator()
919 if self.m.stageable():
920 menu.addSeparator()
921 action = menu.addAction(
922 icons.add(),
923 N_('Stage Selected'),
924 cmds.run(cmds.Stage, context, self.unstaged()),
926 action.setShortcut(hotkeys.STAGE_SELECTION)
928 menu.addAction(self.view_history_action)
929 return menu
931 def _delete_untracked_files(self):
932 cmds.do(cmds.Delete, self.context, self.untracked())
934 def _trash_untracked_files(self):
935 cmds.do(cmds.MoveToTrash, self.context, self.untracked())
937 def selected_path(self):
938 s = self.single_selection()
939 return s.staged or s.unmerged or s.modified or s.untracked or None
941 def single_selection(self):
942 """Scan across staged, modified, etc. and return a single item."""
943 staged = None
944 unmerged = None
945 modified = None
946 untracked = None
948 s = self.selection()
949 if s.staged:
950 staged = s.staged[0]
951 elif s.unmerged:
952 unmerged = s.unmerged[0]
953 elif s.modified:
954 modified = s.modified[0]
955 elif s.untracked:
956 untracked = s.untracked[0]
958 return selection.State(staged, unmerged, modified, untracked)
960 def selected_indexes(self):
961 """Returns a list of (category, row) representing the tree selection."""
962 selected = self.selectedIndexes()
963 result = []
964 for idx in selected:
965 if idx.parent().isValid():
966 parent_idx = idx.parent()
967 entry = (parent_idx.row(), idx.row())
968 else:
969 entry = (HEADER_IDX, idx.row())
970 result.append(entry)
971 return result
973 def selection(self):
974 """Return the current selection in the repo status tree."""
975 return selection.State(
976 self.staged(), self.unmerged(), self.modified(), self.untracked()
979 def contents(self):
980 """Return all of the current files in a selection.State container"""
981 return selection.State(
982 self.m.staged, self.m.unmerged, self.m.modified, self.m.untracked
985 def all_files(self):
986 """Return all of the current active files as a flast list"""
987 c = self.contents()
988 return c.staged + c.unmerged + c.modified + c.untracked
990 def selected_group(self):
991 """A list of selected files in various states of being"""
992 return selection.pick(self.selection())
994 def selected_idx(self):
995 c = self.contents()
996 s = self.single_selection()
997 offset = 0
998 for content, sel in zip(c, s):
999 if not content:
1000 continue
1001 if sel is not None:
1002 return offset + content.index(sel)
1003 offset += len(content)
1004 return None
1006 def select_by_index(self, idx):
1007 c = self.contents()
1008 to_try = [
1009 (c.staged, STAGED_IDX),
1010 (c.unmerged, UNMERGED_IDX),
1011 (c.modified, MODIFIED_IDX),
1012 (c.untracked, UNTRACKED_IDX),
1014 for content, toplevel_idx in to_try:
1015 if not content:
1016 continue
1017 if idx < len(content):
1018 parent = self.topLevelItem(toplevel_idx)
1019 item = parent.child(idx)
1020 if item is not None:
1021 qtutils.select_item(self, item)
1022 return
1023 idx -= len(content)
1025 def staged(self):
1026 return qtutils.get_selected_values(self, STAGED_IDX, self.m.staged)
1028 def unstaged(self):
1029 return self.unmerged() + self.modified() + self.untracked()
1031 def modified(self):
1032 return qtutils.get_selected_values(self, MODIFIED_IDX, self.m.modified)
1034 def unmerged(self):
1035 return qtutils.get_selected_values(self, UNMERGED_IDX, self.m.unmerged)
1037 def untracked(self):
1038 return qtutils.get_selected_values(self, UNTRACKED_IDX, self.m.untracked)
1040 def staged_items(self):
1041 return qtutils.get_selected_items(self, STAGED_IDX)
1043 def unstaged_items(self):
1044 return self.unmerged_items() + self.modified_items() + self.untracked_items()
1046 def modified_items(self):
1047 return qtutils.get_selected_items(self, MODIFIED_IDX)
1049 def unmerged_items(self):
1050 return qtutils.get_selected_items(self, UNMERGED_IDX)
1052 def untracked_items(self):
1053 return qtutils.get_selected_items(self, UNTRACKED_IDX)
1055 def show_selection(self):
1056 """Show the selected item."""
1057 context = self.context
1058 qtutils.scroll_to_item(self, self.currentItem())
1059 # Sync the selection model
1060 selected = self.selection()
1061 selection_model = self.selection_model
1062 selection_model.set_selection(selected)
1063 self._update_actions(selected=selected)
1065 selected_indexes = self.selected_indexes()
1066 if not selected_indexes:
1067 if self.m.amending():
1068 cmds.do(cmds.SetDiffText, context, '')
1069 else:
1070 cmds.do(cmds.ResetMode, context)
1071 return
1073 # A header item e.g. 'Staged', 'Modified', etc.
1074 category, idx = selected_indexes[0]
1075 header = category == HEADER_IDX
1076 if header:
1077 cls = {
1078 STAGED_IDX: cmds.DiffStagedSummary,
1079 MODIFIED_IDX: cmds.Diffstat,
1080 # TODO implement UnmergedSummary
1081 # UNMERGED_IDX: cmds.UnmergedSummary,
1082 UNTRACKED_IDX: cmds.UntrackedSummary,
1083 }.get(idx, cmds.Diffstat)
1084 cmds.do(cls, context)
1085 return
1087 staged = category == STAGED_IDX
1088 modified = category == MODIFIED_IDX
1089 unmerged = category == UNMERGED_IDX
1090 untracked = category == UNTRACKED_IDX
1092 if staged:
1093 item = self.staged_items()[0]
1094 elif unmerged:
1095 item = self.unmerged_items()[0]
1096 elif modified:
1097 item = self.modified_items()[0]
1098 elif untracked:
1099 item = self.unstaged_items()[0]
1100 else:
1101 item = None # this shouldn't happen
1102 assert item is not None
1104 path = item.path
1105 deleted = item.deleted
1106 image = self.image_formats.ok(path)
1108 # Update the diff text
1109 if staged:
1110 cmds.do(cmds.DiffStaged, context, path, deleted=deleted)
1111 elif modified:
1112 cmds.do(cmds.Diff, context, path, deleted=deleted)
1113 elif unmerged:
1114 cmds.do(cmds.Diff, context, path)
1115 elif untracked:
1116 cmds.do(cmds.ShowUntracked, context, path)
1118 # Images are diffed differently.
1119 # DiffImage transitions the diff mode to image.
1120 # DiffText transitions the diff mode to text.
1121 if image:
1122 cmds.do(
1123 cmds.DiffImage,
1124 context,
1125 path,
1126 deleted,
1127 staged,
1128 modified,
1129 unmerged,
1130 untracked,
1132 else:
1133 cmds.do(cmds.DiffText, context)
1135 def select_header(self):
1136 """Select an active header, which triggers a diffstat"""
1137 for idx in (
1138 STAGED_IDX,
1139 UNMERGED_IDX,
1140 MODIFIED_IDX,
1141 UNTRACKED_IDX,
1143 item = self.topLevelItem(idx)
1144 if item.childCount() > 0:
1145 self.clearSelection()
1146 self.setCurrentItem(item)
1147 return
1149 def move_up(self):
1150 idx = self.selected_idx()
1151 all_files = self.all_files()
1152 if idx is None:
1153 selected_indexes = self.selected_indexes()
1154 if selected_indexes:
1155 category, toplevel_idx = selected_indexes[0]
1156 if category == HEADER_IDX:
1157 item = self.itemAbove(self.topLevelItem(toplevel_idx))
1158 if item is not None:
1159 qtutils.select_item(self, item)
1160 return
1161 if all_files:
1162 self.select_by_index(len(all_files) - 1)
1163 return
1164 if idx - 1 >= 0:
1165 self.select_by_index(idx - 1)
1166 else:
1167 self.select_by_index(len(all_files) - 1)
1169 def move_down(self):
1170 idx = self.selected_idx()
1171 all_files = self.all_files()
1172 if idx is None:
1173 selected_indexes = self.selected_indexes()
1174 if selected_indexes:
1175 category, toplevel_idx = selected_indexes[0]
1176 if category == HEADER_IDX:
1177 item = self.itemBelow(self.topLevelItem(toplevel_idx))
1178 if item is not None:
1179 qtutils.select_item(self, item)
1180 return
1181 if all_files:
1182 self.select_by_index(0)
1183 return
1184 if idx + 1 < len(all_files):
1185 self.select_by_index(idx + 1)
1186 else:
1187 self.select_by_index(0)
1189 def mimeData(self, items):
1190 """Return a list of absolute-path URLs"""
1191 context = self.context
1192 paths = qtutils.paths_from_items(items, item_filter=_item_filter)
1193 return qtutils.mimedata_from_paths(context, paths)
1195 # pylint: disable=no-self-use
1196 def mimeTypes(self):
1197 return qtutils.path_mimetypes()
1200 def _item_filter(item):
1201 return not item.deleted and core.exists(item.path)
1204 def view_blame(context):
1205 """Signal that we should view blame for paths."""
1206 cmds.do(cmds.BlamePaths, context)
1209 def view_history(context):
1210 """Signal that we should view history for paths."""
1211 cmds.do(cmds.VisualizePaths, context, context.selection.union())
1214 def copy_path(context, absolute=True):
1215 """Copy a selected path to the clipboard"""
1216 filename = context.selection.filename()
1217 qtutils.copy_path(filename, absolute=absolute)
1220 def copy_relpath(context):
1221 """Copy a selected relative path to the clipboard"""
1222 copy_path(context, absolute=False)
1225 def copy_basename(context):
1226 filename = os.path.basename(context.selection.filename())
1227 basename, _ = os.path.splitext(filename)
1228 qtutils.copy_path(basename, absolute=False)
1231 def copy_leading_path(context):
1232 """Copy the selected leading path to the clipboard"""
1233 filename = context.selection.filename()
1234 dirname = os.path.dirname(filename)
1235 qtutils.copy_path(dirname, absolute=False)
1238 def copy_format(context, fmt):
1239 values = {}
1240 values['path'] = path = context.selection.filename()
1241 values['abspath'] = abspath = os.path.abspath(path)
1242 values['absdirname'] = os.path.dirname(abspath)
1243 values['dirname'] = os.path.dirname(path)
1244 values['filename'] = os.path.basename(path)
1245 values['basename'], values['ext'] = os.path.splitext(os.path.basename(path))
1246 qtutils.set_clipboard(fmt % values)
1249 def show_help(context):
1250 help_text = N_(
1251 r"""
1252 Format String Variables
1253 -----------------------
1254 %(path)s = relative file path
1255 %(abspath)s = absolute file path
1256 %(dirname)s = relative directory path
1257 %(absdirname)s = absolute directory path
1258 %(filename)s = file basename
1259 %(basename)s = file basename without extension
1260 %(ext)s = file extension
1263 title = N_('Help - Custom Copy Actions')
1264 return text.text_dialog(context, help_text, title)
1267 class StatusFilterWidget(QtWidgets.QWidget):
1268 def __init__(self, context, parent=None):
1269 QtWidgets.QWidget.__init__(self, parent)
1270 self.context = context
1272 hint = N_('Filter paths...')
1273 self.text = completion.GitStatusFilterLineEdit(context, hint=hint, parent=self)
1274 self.text.setToolTip(hint)
1275 self.setFocusProxy(self.text)
1276 self._filter = None
1278 self.main_layout = qtutils.hbox(defs.no_margin, defs.spacing, self.text)
1279 self.setLayout(self.main_layout)
1281 widget = self.text
1282 # pylint: disable=no-member
1283 widget.changed.connect(self.apply_filter)
1284 widget.cleared.connect(self.apply_filter)
1285 widget.enter.connect(self.apply_filter)
1286 widget.editingFinished.connect(self.apply_filter)
1288 def apply_filter(self):
1289 value = get(self.text)
1290 if value == self._filter:
1291 return
1292 self._filter = value
1293 paths = utils.shell_split(value)
1294 self.context.model.update_path_filter(paths)
1297 def customize_copy_actions(context, parent):
1298 """Customize copy actions"""
1299 dialog = CustomizeCopyActions(context, parent)
1300 dialog.show()
1301 dialog.exec_()
1304 class CustomizeCopyActions(standard.Dialog):
1305 def __init__(self, context, parent):
1306 standard.Dialog.__init__(self, parent=parent)
1307 self.setWindowTitle(N_('Custom Copy Actions'))
1309 self.context = context
1310 self.table = QtWidgets.QTableWidget(self)
1311 self.table.setColumnCount(2)
1312 self.table.setHorizontalHeaderLabels([N_('Action Name'), N_('Format String')])
1313 self.table.setSortingEnabled(False)
1314 self.table.verticalHeader().hide()
1315 self.table.horizontalHeader().setStretchLastSection(True)
1317 self.add_button = qtutils.create_button(N_('Add'))
1318 self.remove_button = qtutils.create_button(N_('Remove'))
1319 self.remove_button.setEnabled(False)
1320 self.show_help_button = qtutils.create_button(N_('Show Help'))
1321 self.show_help_button.setShortcut(hotkeys.QUESTION)
1323 self.close_button = qtutils.close_button()
1324 self.save_button = qtutils.ok_button(N_('Save'))
1326 self.buttons = qtutils.hbox(
1327 defs.no_margin,
1328 defs.button_spacing,
1329 self.add_button,
1330 self.remove_button,
1331 self.show_help_button,
1332 qtutils.STRETCH,
1333 self.close_button,
1334 self.save_button,
1337 layout = qtutils.vbox(defs.margin, defs.spacing, self.table, self.buttons)
1338 self.setLayout(layout)
1340 qtutils.connect_button(self.add_button, self.add)
1341 qtutils.connect_button(self.remove_button, self.remove)
1342 qtutils.connect_button(self.show_help_button, partial(show_help, context))
1343 qtutils.connect_button(self.close_button, self.reject)
1344 qtutils.connect_button(self.save_button, self.save)
1345 qtutils.add_close_action(self)
1346 # pylint: disable=no-member
1347 self.table.itemSelectionChanged.connect(self.table_selection_changed)
1349 self.init_size(parent=parent)
1351 QtCore.QTimer.singleShot(0, self.reload_settings)
1353 def reload_settings(self):
1354 # Called once after the GUI is initialized
1355 settings = self.context.settings
1356 settings.load()
1357 table = self.table
1358 for entry in settings.copy_formats:
1359 name_string = entry.get('name', '')
1360 format_string = entry.get('format', '')
1361 if name_string and format_string:
1362 name = QtWidgets.QTableWidgetItem(name_string)
1363 fmt = QtWidgets.QTableWidgetItem(format_string)
1364 rows = table.rowCount()
1365 table.setRowCount(rows + 1)
1366 table.setItem(rows, 0, name)
1367 table.setItem(rows, 1, fmt)
1369 def export_state(self):
1370 state = super(CustomizeCopyActions, self).export_state()
1371 standard.export_header_columns(self.table, state)
1372 return state
1374 def apply_state(self, state):
1375 result = super(CustomizeCopyActions, self).apply_state(state)
1376 standard.apply_header_columns(self.table, state)
1377 return result
1379 def add(self):
1380 self.table.setFocus()
1381 rows = self.table.rowCount()
1382 self.table.setRowCount(rows + 1)
1384 name = QtWidgets.QTableWidgetItem(N_('Name'))
1385 fmt = QtWidgets.QTableWidgetItem(r'%(path)s')
1386 self.table.setItem(rows, 0, name)
1387 self.table.setItem(rows, 1, fmt)
1389 self.table.setCurrentCell(rows, 0)
1390 self.table.editItem(name)
1392 def remove(self):
1393 """Remove selected items"""
1394 # Gather a unique set of rows and remove them in reverse order
1395 rows = set()
1396 items = self.table.selectedItems()
1397 for item in items:
1398 rows.add(self.table.row(item))
1400 for row in reversed(sorted(rows)):
1401 self.table.removeRow(row)
1403 def save(self):
1404 copy_formats = []
1405 for row in range(self.table.rowCount()):
1406 name = self.table.item(row, 0)
1407 fmt = self.table.item(row, 1)
1408 if name and fmt:
1409 entry = {
1410 'name': name.text(),
1411 'format': fmt.text(),
1413 copy_formats.append(entry)
1415 settings = self.context.settings
1416 while settings.copy_formats:
1417 settings.copy_formats.pop()
1419 settings.copy_formats.extend(copy_formats)
1420 settings.save()
1422 self.accept()
1424 def table_selection_changed(self):
1425 items = self.table.selectedItems()
1426 self.remove_button.setEnabled(bool(items))
1429 def _select_item(widget, path_list, widget_getter, item, current=False):
1430 """Select the widget item based on the list index"""
1431 # The path lists and widget indexes have a 1:1 correspondence.
1432 # Lookup the item filename in the list and use that index to
1433 # retrieve the widget item and select it.
1434 idx = path_list.index(item)
1435 item = widget_getter(idx)
1436 if current:
1437 widget.setCurrentItem(item)
1438 item.setSelected(True)
1441 def _apply_toplevel_selection(widget, category, idx):
1442 """Select a top-level "header" item (ex: the Staged parent item)
1444 Return True when a top-level item is selected.
1446 is_top_level_item = category == HEADER_IDX
1447 if is_top_level_item:
1448 root_item = widget.invisibleRootItem()
1449 item = root_item.child(idx)
1451 if item is not None and item.childCount() == 0:
1452 # The item now has no children. Select a different top-level item
1453 # corresponding to the previously selected item.
1454 if idx == STAGED_IDX:
1455 # If "Staged" was previously selected try "Modified" and "Untracked".
1456 item = _get_first_item_with_children(
1457 root_item.child(MODIFIED_IDX), root_item.child(UNTRACKED_IDX)
1459 elif idx == UNMERGED_IDX:
1460 # If "Unmerged" was previously selected try "Staged".
1461 item = _get_first_item_with_children(root_item.child(STAGED_IDX))
1462 elif idx == MODIFIED_IDX:
1463 # If "Modified" was previously selected try "Staged" or "Untracked".
1464 item = _get_first_item_with_children(
1465 root_item.child(STAGED_IDX), root_item.child(UNTRACKED_IDX)
1467 elif idx == UNTRACKED_IDX:
1468 # If "Untracked" was previously selected try "Staged".
1469 item = _get_first_item_with_children(root_item.child(STAGED_IDX))
1471 if item is not None:
1472 with qtutils.BlockSignals(widget):
1473 widget.setCurrentItem(item)
1474 item.setSelected(True)
1475 widget.show_selection()
1476 return is_top_level_item
1479 def _get_first_item_with_children(*items):
1480 """Return the first item that contains child items"""
1481 for item in items:
1482 if item.childCount() > 0:
1483 return item
1484 return None
1487 def _transplant_selection_across_sections(
1488 category, idx, previous_contents, saved_selection
1490 """Transplant the selection to a different category"""
1491 # This function is used when the selection would otherwise become empty.
1492 # Apply heuristics to select the items based on the previous state.
1493 if not previous_contents:
1494 return
1495 staged, unmerged, modified, untracked = saved_selection
1496 prev_staged, prev_unmerged, prev_modified, prev_untracked = previous_contents
1498 # The current set of paths.
1499 staged_paths = staged[NEW_PATHS_IDX]
1500 unmerged_paths = unmerged[NEW_PATHS_IDX]
1501 modified_paths = modified[NEW_PATHS_IDX]
1502 untracked_paths = untracked[NEW_PATHS_IDX]
1504 # These callbacks select a path in the corresponding widget subtree lists.
1505 select_staged = staged[SELECT_FN_IDX]
1506 select_unmerged = unmerged[SELECT_FN_IDX]
1507 select_modified = modified[SELECT_FN_IDX]
1508 select_untracked = untracked[SELECT_FN_IDX]
1510 if category == STAGED_IDX:
1511 # Staged files can become Unmerged, Modified or Untracked.
1512 # If we previously had a staged file selected then try to select
1513 # it in either the Unmerged, Modified or Untracked sections.
1514 try:
1515 old_path = prev_staged[idx]
1516 except IndexError:
1517 return
1518 if old_path in unmerged_paths:
1519 select_unmerged(old_path, current=True)
1520 elif old_path in modified_paths:
1521 select_modified(old_path, current=True)
1522 elif old_path in untracked_paths:
1523 select_untracked(old_path, current=True)
1525 elif category == UNMERGED_IDX:
1526 # Unmerged files can become Staged, Modified or Untracked.
1527 # If we previously had an unmerged file selected then try to select it in
1528 # the Staged, Modified or Untracked sections.
1529 try:
1530 old_path = prev_unmerged[idx]
1531 except IndexError:
1532 return
1533 if old_path in staged_paths:
1534 select_staged(old_path, current=True)
1535 elif old_path in modified_paths:
1536 select_modified(old_path, current=True)
1537 elif old_path in untracked_paths:
1538 select_untracked(old_path, current=True)
1540 elif category == MODIFIED_IDX:
1541 # If we previously had a modified file selected then try to select
1542 # it in either the Staged or Untracked sections.
1543 try:
1544 old_path = prev_modified[idx]
1545 except IndexError:
1546 return
1547 if old_path in staged_paths:
1548 select_staged(old_path, current=True)
1549 elif old_path in untracked_paths:
1550 select_untracked(old_path, current=True)
1552 elif category == UNTRACKED_IDX:
1553 # If we previously had an untracked file selected then try to select
1554 # it in the Modified or Staged section. Modified is less common, but
1555 # it's possible for a file to be untracked and then the user adds and
1556 # modifies the file before we've refreshed our state.
1557 try:
1558 old_path = prev_untracked[idx]
1559 except IndexError:
1560 return
1561 if old_path in modified_paths:
1562 select_modified(old_path, current=True)
1563 elif old_path in staged_paths:
1564 select_staged(old_path, current=True)