status: implement the UnmergedSummary TODO item
[git-cola.git] / cola / widgets / status.py
blobbc95b028064726b9ab8ddc1bf00557531ac7607a
1 from __future__ import absolute_import, division, print_function, unicode_literals
2 import itertools
3 import os
4 from functools import partial
6 from qtpy.QtCore import Qt
7 from qtpy import QtCore
8 from qtpy import QtWidgets
10 from ..i18n import N_
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
18 from .. import cmds
19 from .. import core
20 from .. import hotkeys
21 from .. import icons
22 from .. import qtutils
23 from .. import utils
24 from . import common
25 from . import completion
26 from . import defs
27 from . import text
30 # Top-level status widget item indexes.
31 HEADER_IDX = -1
32 STAGED_IDX = 0
33 UNMERGED_IDX = 1
34 MODIFIED_IDX = 2
35 UNTRACKED_IDX = 3
36 END_IDX = 4
38 # Indexes into the saved_selection entries.
39 NEW_PATHS_IDX = 0
40 OLD_PATHS_IDX = 1
41 SELECTION_IDX = 2
42 SELECT_FN_IDX = 3
45 class StatusWidget(QtWidgets.QFrame):
46 """
47 Provides a git-status-like repository widget.
49 This widget observes the main model and broadcasts
50 Qt signals.
52 """
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)
93 if shown:
94 self.filter_widget.setFocus()
95 else:
96 self.tree.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))
103 def refresh(self):
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)
118 def move_up(self):
119 self.tree.move_up()
121 def move_down(self):
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)
153 ok_icon = icons.ok()
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(
174 self,
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(
182 self,
183 cmds.StageOrUnstageAll.name(),
184 cmds.run(cmds.StageOrUnstageAll, self.context),
185 hotkeys.STAGE_ALL,
187 self.stage_or_unstage_all_action.setIcon(icons.add())
189 self.revert_unstaged_edits_action = qtutils.add_action(
190 self,
191 cmds.RevertUnstagedEdits.name(),
192 cmds.run(cmds.RevertUnstagedEdits, context),
193 hotkeys.REVERT,
195 self.revert_unstaged_edits_action.setIcon(icons.undo())
197 self.launch_difftool_action = qtutils.add_action(
198 self,
199 cmds.LaunchDifftool.name(),
200 cmds.run(cmds.LaunchDifftool, context),
201 hotkeys.DIFF,
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(
224 self,
225 N_('Move Up'),
226 self.move_up,
227 hotkeys.MOVE_UP,
228 hotkeys.MOVE_UP_SECONDARY,
231 self.down_action = qtutils.add_action(
232 self,
233 N_('Move Down'),
234 self.move_down,
235 hotkeys.MOVE_DOWN,
236 hotkeys.MOVE_DOWN_SECONDARY,
239 # Checkout the selected paths using "git checkout --ours".
240 self.checkout_ours_action = qtutils.add_action(
241 self,
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(
248 self,
249 cmds.CheckoutTheirs.name(),
250 cmds.run(cmds.CheckoutTheirs, context)
253 self.copy_path_action = qtutils.add_action(
254 self,
255 N_('Copy Path to Clipboard'),
256 partial(copy_path, context),
257 hotkeys.COPY,
259 self.copy_path_action.setIcon(icons.copy())
261 self.copy_relpath_action = qtutils.add_action(
262 self,
263 N_('Copy Relative Path to Clipboard'),
264 partial(copy_relpath, context),
265 hotkeys.CUT,
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(
303 self,
304 N_('Move files to trash'),
305 self._trash_untracked_files,
306 hotkeys.TRASH,
308 self.move_to_trash_action.setIcon(icons.discard())
309 delete_shortcut = hotkeys.DELETE_FILE
310 else:
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()
340 if item:
341 qtutils.scroll_to_item(self, item)
343 def _add_toplevel_item(self, txt, icon, hide=False):
344 context = self.context
345 font = self.font()
346 if prefs.bold_headers(context):
347 font.setBold(True)
348 else:
349 font.setItalic(True)
351 item = QtWidgets.QTreeWidgetItem(self)
352 item.setFont(0, font)
353 item.setText(0, txt)
354 item.setIcon(0, icon)
355 if prefs.bold_headers(context):
356 item.setBackground(0, self.palette().midlight())
357 if hide:
358 item.setHidden(True)
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:
367 return
368 # The old set of categorized files.
369 old_c = self.old_contents
370 # The old selection.
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
386 saved_selection = [
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),
392 old_c.untracked,
393 set(old_s.untracked),
394 select_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):
402 return
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]
408 try:
409 item = old[idx]
410 except IndexError:
411 item = None
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.
423 did_reselect = False
425 with qtutils.BlockSignals(self):
426 for (new, old, sel, reselect) in saved_selection:
427 for item in sel:
428 if item in new:
429 reselect(item, current=False)
430 did_reselect = True
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.
450 continue
451 for item in sel:
452 # The item still exists so ignore it
453 if item in new or item not in old:
454 continue
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])):
459 if j in new:
460 reselect(j, current=True)
461 return
463 # If we already reselected stuff then there's nothing more to do.
464 if did_reselect:
465 return
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)
485 if is_header:
486 is_staged = any(
487 idx == STAGED_IDX and category == HEADER_IDX
488 for (category, idx) in selected_indexes
490 is_modified = any(
491 idx == MODIFIED_IDX and category == HEADER_IDX
492 for (category, idx) in selected_indexes
494 is_untracked = any(
495 idx == UNTRACKED_IDX and category == HEADER_IDX
496 for (category, idx) in selected_indexes
498 # A header item: 'Staged', 'Modified' or 'Untracked'.
499 if is_staged:
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
507 # stage everything.
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.
514 if is_modified:
515 cmds.do(cmds.StageModified, context)
516 elif is_untracked:
517 cmds.do(cmds.StageUntracked, context)
518 else:
519 # Do nothing for unmerged items, by design
520 pass
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):
537 # is it modified?
538 item = self.topLevelItem(MODIFIED_IDX)
539 count = item.childCount()
540 if itemidx < count:
541 return item.child(itemidx)
542 # is it unmerged?
543 item = self.topLevelItem(UNMERGED_IDX)
544 count += item.childCount()
545 if itemidx < count:
546 return item.child(itemidx)
547 # is it untracked?
548 item = self.topLevelItem(UNTRACKED_IDX)
549 count += item.childCount()
550 if itemidx < count:
551 return item.child(itemidx)
552 # Nope..
553 return None
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()
577 if not s:
578 return None
579 current = self.currentItem()
580 if not current:
581 return None
582 idx = self.indexFromItem(current)
583 if idx.parent().isValid():
584 parent_idx = idx.parent()
585 entry = (parent_idx.row(), idx.row())
586 else:
587 entry = (HEADER_IDX, idx.row())
588 return entry
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()
595 def refresh(self):
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):
606 if selected is 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):
621 self._set_subtree(
622 items,
623 STAGED_IDX,
624 N_('Staged'),
625 staged=True,
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):
632 self._set_subtree(
633 items,
634 MODIFIED_IDX,
635 N_('Modified'),
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):
643 self._set_subtree(
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)
652 def _set_subtree(
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:
663 pass
665 for item in items:
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.
684 if not items:
685 return
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:
689 return
690 self.expanded_items.add(idx)
691 item = self.topLevelItem(idx)
692 if item:
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()
705 if 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)
711 if sel.staged:
712 self._create_staged_context_menu(menu, sel)
713 elif sel.unmerged:
714 self._create_unmerged_context_menu(menu, sel)
715 else:
716 self._create_unstaged_context_menu(menu, sel)
718 if not menu.isEmpty():
719 menu.addSeparator()
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)
732 return 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()),
766 menu.addSeparator()
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
775 if 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', '')
782 if name and fmt:
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:
793 menu.addAction(
794 icons.remove(), N_('Unstage All'), cmds.run(cmds.UnstageAll, context)
796 elif idx == UNMERGED_IDX:
797 action = menu.addAction(
798 icons.add(),
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(
805 icons.add(),
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(
812 icons.add(),
813 cmds.StageUntracked.name(),
814 cmds.run(cmds.StageUntracked, context),
816 action.setShortcut(hotkeys.STAGE_SELECTION)
817 return menu
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(
826 icons.remove(),
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?
835 all_exist = all(
836 i not in self._model.staged_deleted and core.exists(i)
837 for i in self.staged()
840 if all_exist:
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)
848 return menu
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:
854 menu.addAction(
855 icons.cola(),
856 N_('Launch git-cola'),
857 cmds.run(cmds.OpenRepo, context, path),
859 menu.addSeparator()
860 action = menu.addAction(
861 icons.remove(),
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)
868 return menu
870 def _create_unmerged_context_menu(self, menu, _s):
871 context = self.context
872 menu.addAction(self.launch_difftool_action)
874 action = menu.addAction(
875 icons.add(),
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)
884 menu.addSeparator()
885 menu.addAction(self.checkout_ours_action)
886 menu.addAction(self.checkout_theirs_action)
887 return menu
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(
897 icons.add(),
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?
907 all_exist = all(
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():
916 menu.addSeparator()
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')
923 if annex or lfs:
924 menu.addSeparator()
925 if annex:
926 menu.addAction(self.annex_add_action)
927 if lfs:
928 menu.addAction(self.lfs_track_action)
930 menu.addSeparator()
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)
934 menu.addSeparator()
935 menu.addAction(
936 icons.edit(),
937 N_('Ignore...'),
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)
944 return menu
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:
950 menu.addAction(
951 icons.cola(),
952 N_('Launch git-cola'),
953 cmds.run(cmds.OpenRepo, context, path),
955 menu.addAction(
956 icons.pull(),
957 N_('Update this submodule'),
958 cmds.run(cmds.SubmoduleUpdate, context, path),
960 menu.addSeparator()
962 if self._model.is_stageable():
963 menu.addSeparator()
964 action = menu.addAction(
965 icons.add(),
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)
972 return menu
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."""
986 staged = None
987 unmerged = None
988 modified = None
989 untracked = None
991 s = self.selection()
992 if s.staged:
993 staged = s.staged[0]
994 elif s.unmerged:
995 unmerged = s.unmerged[0]
996 elif s.modified:
997 modified = s.modified[0]
998 elif s.untracked:
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()
1006 result = []
1007 for idx in selected:
1008 if idx.parent().isValid():
1009 parent_idx = idx.parent()
1010 entry = (parent_idx.row(), idx.row())
1011 else:
1012 entry = (HEADER_IDX, idx.row())
1013 result.append(entry)
1014 return result
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()
1022 def contents(self):
1023 """Return all of the current files in a selection.State container"""
1024 return selection.State(
1025 self._model.staged,
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"""
1033 c = self.contents()
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):
1041 c = self.contents()
1042 s = self.single_selection()
1043 offset = 0
1044 for content, sel in zip(c, s):
1045 if not content:
1046 continue
1047 if sel is not None:
1048 return offset + content.index(sel)
1049 offset += len(content)
1050 return None
1052 def select_by_index(self, idx):
1053 c = self.contents()
1054 to_try = [
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:
1061 if not content:
1062 continue
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)
1068 return
1069 idx -= len(content)
1071 def staged(self):
1072 return qtutils.get_selected_values(self, STAGED_IDX, self._model.staged)
1074 def unstaged(self):
1075 return self.unmerged() + self.modified() + self.untracked()
1077 def modified(self):
1078 return qtutils.get_selected_values(self, MODIFIED_IDX, self._model.modified)
1080 def unmerged(self):
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, '')
1115 else:
1116 cmds.do(cmds.ResetMode, context)
1117 return
1119 # A header item e.g. 'Staged', 'Modified', etc.
1120 category, idx = selected_indexes[0]
1121 header = category == HEADER_IDX
1122 if header:
1123 cls = {
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)
1130 return
1132 staged = category == STAGED_IDX
1133 modified = category == MODIFIED_IDX
1134 unmerged = category == UNMERGED_IDX
1135 untracked = category == UNTRACKED_IDX
1137 if staged:
1138 item = self.staged_items()[0]
1139 elif unmerged:
1140 item = self.unmerged_items()[0]
1141 elif modified:
1142 item = self.modified_items()[0]
1143 elif untracked:
1144 item = self.unstaged_items()[0]
1145 else:
1146 item = None # this shouldn't happen
1147 assert item is not None
1149 path = item.path
1150 deleted = item.deleted
1151 image = self.image_formats.ok(path)
1153 # Update the diff text
1154 if staged:
1155 cmds.do(cmds.DiffStaged, context, path, deleted=deleted)
1156 elif modified:
1157 cmds.do(cmds.Diff, context, path, deleted=deleted)
1158 elif unmerged:
1159 cmds.do(cmds.Diff, context, path)
1160 elif untracked:
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.
1166 if image:
1167 cmds.do(
1168 cmds.DiffImage,
1169 context,
1170 path,
1171 deleted,
1172 staged,
1173 modified,
1174 unmerged,
1175 untracked,
1177 else:
1178 cmds.do(cmds.DiffText, context)
1180 def select_header(self):
1181 """Select an active header, which triggers a diffstat"""
1182 for idx in (
1183 STAGED_IDX,
1184 UNMERGED_IDX,
1185 MODIFIED_IDX,
1186 UNTRACKED_IDX,
1188 item = self.topLevelItem(idx)
1189 if item.childCount() > 0:
1190 self.clearSelection()
1191 self.setCurrentItem(item)
1192 return
1194 def move_up(self):
1195 """Select the item above the currently selected item"""
1196 idx = self.selected_idx()
1197 all_files = self.all_files()
1198 if idx is None:
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)
1206 return
1207 if all_files:
1208 self.select_by_index(len(all_files) - 1)
1209 return
1210 if idx - 1 >= 0:
1211 self.select_by_index(idx - 1)
1212 else:
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()
1219 if idx is None:
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)
1227 return
1228 if all_files:
1229 self.select_by_index(0)
1230 return
1231 if idx + 1 < len(all_files):
1232 self.select_by_index(idx + 1)
1233 else:
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()
1293 value = 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"""
1301 values = {}
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"""
1313 help_text = N_(
1314 r"""
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)
1340 self._filter = None
1342 self.main_layout = qtutils.hbox(defs.no_margin, defs.spacing, self.text)
1343 self.setLayout(self.main_layout)
1345 widget = self.text
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:
1356 return
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)
1365 dialog.show()
1366 dialog.exec_()
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(
1394 defs.no_margin,
1395 defs.button_spacing,
1396 self.add_button,
1397 self.remove_button,
1398 self.show_help_button,
1399 qtutils.STRETCH,
1400 self.close_button,
1401 self.save_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
1424 settings.load()
1425 table = self.table
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)
1441 return 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)
1447 return result
1449 def add(self):
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)
1463 def remove(self):
1464 """Remove selected items"""
1465 # Gather a unique set of rows and remove them in reverse order
1466 rows = set()
1467 items = self.table.selectedItems()
1468 for item in items:
1469 rows.add(self.table.row(item))
1471 for row in reversed(sorted(rows)):
1472 self.table.removeRow(row)
1474 def save(self):
1475 """Save custom copy actions to the settings"""
1476 copy_formats = []
1477 for row in range(self.table.rowCount()):
1478 name = self.table.item(row, 0)
1479 fmt = self.table.item(row, 1)
1480 if name and fmt:
1481 entry = {
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)
1492 settings.save()
1494 self.accept()
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)
1509 if current:
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"""
1554 for item in items:
1555 if item.childCount() > 0:
1556 return item
1557 return None
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:
1567 return
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.
1587 try:
1588 old_path = prev_staged[idx]
1589 except IndexError:
1590 return
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.
1602 try:
1603 old_path = prev_unmerged[idx]
1604 except IndexError:
1605 return
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.
1616 try:
1617 old_path = prev_modified[idx]
1618 except IndexError:
1619 return
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.
1630 try:
1631 old_path = prev_untracked[idx]
1632 except IndexError:
1633 return
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'))
1651 icon = icons.copy()
1652 pixmap = icon.pixmap(defs.default_icon, defs.default_icon)
1653 self.icon.setPixmap(pixmap)
1654 self.label.setText(title)
1656 layout = qtutils.hbox(
1657 defs.small_margin,
1658 defs.titlebar_spacing,
1659 self.icon,
1660 self.label,
1661 qtutils.STRETCH,
1662 self.spinbox,
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()
1671 stylesheet = """
1673 show-decoration-selected: 1
1675 QLabel {
1676 color: %(text_rgb)s;
1677 show-decoration-selected: 1
1679 QLabel:hover {
1680 color: %(highlight_text_rgb)s;
1681 background-color: %(highlight_rgb)s;
1682 background-clip: padding;
1683 show-decoration-selected: 1
1685 QLabel:disabled {
1686 color: %(disabled_text_rgb)s;
1688 """ % dict(
1689 disabled_text_rgb=disabled_text_rgb,
1690 text_rgb=text_rgb,
1691 highlight_text_rgb=highlight_text_rgb,
1692 highlight_rgb=highlight_rgb,
1695 self.setStyleSheet(stylesheet)
1697 def value(self):
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)