clone: clone into the parent directory by default
[git-cola.git] / cola / widgets / status.py
blobf2be3432193007ac792c70c7750c008848c6c901
1 import itertools
2 import os
3 from functools import partial
5 from qtpy.QtCore import Qt
6 from qtpy import QtCore
7 from qtpy import QtWidgets
9 from ..i18n import N_
10 from ..models import prefs
11 from ..models import selection
12 from ..widgets import gitignore
13 from ..widgets import standard
14 from ..qtutils import get
15 from ..settings import Settings
16 from .. import actions
17 from .. import cmds
18 from .. import core
19 from .. import difftool
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 class StatusTreeWidget(QtWidgets.QTreeWidget):
129 # Read-only access to the mode state
130 mode = property(lambda self: self._model.mode)
132 def __init__(self, context, parent=None):
133 QtWidgets.QTreeWidget.__init__(self, parent)
134 self.context = context
135 self.selection_model = context.selection
137 self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
138 self.headerItem().setHidden(True)
139 self.setAllColumnsShowFocus(True)
140 self.setSortingEnabled(False)
141 self.setUniformRowHeights(True)
142 self.setAnimated(True)
143 self.setRootIsDecorated(False)
144 self.setAutoScroll(False)
145 self.setDragEnabled(True)
146 self.setDragDropMode(QtWidgets.QAbstractItemView.DragOnly)
147 self._alt_drag = False
149 if not prefs.status_indent(context):
150 self.setIndentation(0)
152 ok_icon = icons.ok()
153 compare = icons.compare()
154 question = icons.question()
155 self._add_toplevel_item(N_('Staged'), ok_icon, hide=True)
156 self._add_toplevel_item(N_('Unmerged'), compare, hide=True)
157 self._add_toplevel_item(N_('Modified'), compare, hide=True)
158 self._add_toplevel_item(N_('Untracked'), question, hide=True)
160 # Used to restore the selection
161 self.old_vscroll = None
162 self.old_hscroll = None
163 self.old_selection = None
164 self.old_contents = None
165 self.old_current_item = None
166 self.previous_contents = None
167 self.was_visible = True
168 self.expanded_items = set()
170 self.image_formats = qtutils.ImageFormats()
172 self.process_selection_action = qtutils.add_action(
173 self,
174 cmds.StageOrUnstage.name(),
175 self._stage_selection,
176 hotkeys.STAGE_SELECTION,
178 self.process_selection_action.setIcon(icons.add())
180 self.stage_or_unstage_all_action = qtutils.add_action(
181 self,
182 cmds.StageOrUnstageAll.name(),
183 cmds.run(cmds.StageOrUnstageAll, self.context),
184 hotkeys.STAGE_ALL,
186 self.stage_or_unstage_all_action.setIcon(icons.add())
188 self.revert_unstaged_edits_action = qtutils.add_action(
189 self,
190 cmds.RevertUnstagedEdits.name(),
191 cmds.run(cmds.RevertUnstagedEdits, context),
192 hotkeys.REVERT,
193 hotkeys.REVERT_ALT,
195 self.revert_unstaged_edits_action.setIcon(icons.undo())
197 self.launch_difftool_action = qtutils.add_action(
198 self,
199 difftool.LaunchDifftool.name(),
200 cmds.run(difftool.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, cmds.CheckoutOurs.name(), cmds.run(cmds.CheckoutOurs, context)
244 # Checkout the selected paths using "git checkout --theirs".
245 self.checkout_theirs_action = qtutils.add_action(
246 self, cmds.CheckoutTheirs.name(), cmds.run(cmds.CheckoutTheirs, context)
249 self.copy_path_action = qtutils.add_action(
250 self,
251 N_('Copy Path to Clipboard'),
252 partial(copy_path, context),
253 hotkeys.COPY,
255 self.copy_path_action.setIcon(icons.copy())
257 self.copy_relpath_action = qtutils.add_action(
258 self,
259 N_('Copy Relative Path to Clipboard'),
260 partial(copy_relpath, context),
261 hotkeys.CUT,
263 self.copy_relpath_action.setIcon(icons.copy())
265 self.copy_leading_paths_value = 1
267 self.copy_basename_action = qtutils.add_action(
268 self, N_('Copy Basename to Clipboard'), partial(copy_basename, context)
270 self.copy_basename_action.setIcon(icons.copy())
272 self.copy_customize_action = qtutils.add_action(
273 self, N_('Customize...'), partial(customize_copy_actions, context, self)
275 self.copy_customize_action.setIcon(icons.configure())
277 self.view_history_action = qtutils.add_action(
278 self, N_('View History...'), partial(view_history, context), hotkeys.HISTORY
281 self.view_blame_action = qtutils.add_action(
282 self, N_('Blame...'), partial(view_blame, context), hotkeys.BLAME
285 self.annex_add_action = qtutils.add_action(
286 self, N_('Add to Git Annex'), cmds.run(cmds.AnnexAdd, context)
289 self.lfs_track_action = qtutils.add_action(
290 self, N_('Add to Git LFS'), cmds.run(cmds.LFSTrack, context)
293 # MoveToTrash and Delete use the same shortcut.
294 # We will only bind one of them, depending on whether or not the
295 # MoveToTrash command is available. When available, the hotkey
296 # is bound to MoveToTrash, otherwise it is bound to Delete.
297 if cmds.MoveToTrash.AVAILABLE:
298 self.move_to_trash_action = qtutils.add_action(
299 self,
300 N_('Move files to trash'),
301 self._trash_untracked_files,
302 hotkeys.TRASH,
304 self.move_to_trash_action.setIcon(icons.discard())
305 delete_shortcut = hotkeys.DELETE_FILE
306 else:
307 self.move_to_trash_action = None
308 delete_shortcut = hotkeys.DELETE_FILE_SECONDARY
310 self.delete_untracked_files_action = qtutils.add_action(
311 self, N_('Delete Files...'), self._delete_untracked_files, delete_shortcut
313 self.delete_untracked_files_action.setIcon(icons.discard())
315 # The model is stored as self._model because self.model() is a
316 # QTreeWidgetItem method that returns a QAbstractItemModel.
317 self._model = context.model
318 self._model.previous_contents.connect(
319 self._set_previous_contents, type=Qt.QueuedConnection
321 self._model.about_to_update.connect(
322 self._about_to_update, type=Qt.QueuedConnection
324 self._model.updated.connect(self.refresh, type=Qt.QueuedConnection)
325 self._model.diff_text_changed.connect(
326 self._make_current_item_visible, type=Qt.QueuedConnection
328 self.itemSelectionChanged.connect(self.show_selection)
329 self.itemDoubleClicked.connect(cmds.run(cmds.StageOrUnstage, self.context))
330 self.itemCollapsed.connect(lambda x: self._update_column_widths())
331 self.itemExpanded.connect(lambda x: self._update_column_widths())
333 def _make_current_item_visible(self):
334 item = self.currentItem()
335 if item:
336 qtutils.scroll_to_item(self, item)
338 def _add_toplevel_item(self, txt, icon, hide=False):
339 context = self.context
340 font = self.font()
341 if prefs.bold_headers(context):
342 font.setBold(True)
343 else:
344 font.setItalic(True)
346 item = QtWidgets.QTreeWidgetItem(self)
347 item.setFont(0, font)
348 item.setText(0, txt)
349 item.setIcon(0, icon)
350 if prefs.bold_headers(context):
351 item.setBackground(0, self.palette().midlight())
352 if hide:
353 item.setHidden(True)
355 def _restore_selection(self):
356 """Apply the old selection to the newly updated items"""
357 # This function is called after a new set of items have been added to
358 # the per-category file lists. Its purpose is to either restore the
359 # existing selection or to create a new intuitive selection based on
360 # a combination of the old items, the old selection and the new items.
361 if not self.old_selection or not self.old_contents:
362 return
363 # The old set of categorized files.
364 old_c = self.old_contents
365 # The old selection.
366 old_s = self.old_selection
367 # The current/new set of categorized files.
368 new_c = self.contents()
370 select_staged = partial(_select_item, self, new_c.staged, self._staged_item)
371 select_unmerged = partial(
372 _select_item, self, new_c.unmerged, self._unmerged_item
374 select_modified = partial(
375 _select_item, self, new_c.modified, self._modified_item
377 select_untracked = partial(
378 _select_item, self, new_c.untracked, self._untracked_item
381 saved_selection = [
382 (set(new_c.staged), old_c.staged, set(old_s.staged), select_staged),
383 (set(new_c.unmerged), old_c.unmerged, set(old_s.unmerged), select_unmerged),
384 (set(new_c.modified), old_c.modified, set(old_s.modified), select_modified),
386 set(new_c.untracked),
387 old_c.untracked,
388 set(old_s.untracked),
389 select_untracked,
393 # Restore the current item
394 if self.old_current_item:
395 category, idx = self.old_current_item
396 if _apply_toplevel_selection(self, category, idx):
397 return
398 # Reselect the current item
399 selection_info = saved_selection[category]
400 new = selection_info[NEW_PATHS_IDX]
401 old = selection_info[OLD_PATHS_IDX]
402 reselect = selection_info[SELECT_FN_IDX]
403 try:
404 item = old[idx]
405 except IndexError:
406 item = None
407 if item and item in new:
408 reselect(item, current=True)
410 # Restore previously selected items.
411 # When reselecting in this section we only care that the items are
412 # selected; we do not need to rerun the callbacks which were triggered
413 # above for the current item. Block signals to skip the callbacks.
415 # Reselect items that were previously selected and still exist in the
416 # current path lists. This handles a common case such as a Ctrl-R
417 # refresh which results in the same exact path state.
418 did_reselect = False
420 with qtutils.BlockSignals(self):
421 for new, old, sel, reselect in saved_selection:
422 for item in sel:
423 if item in new:
424 reselect(item, current=False)
425 did_reselect = True
427 # The status widget is used to interactively work your way down the
428 # list of Staged, Unmerged, Modified and Untracked items and perform
429 # an operation on them.
431 # For Staged items we intend to work our way down the list of Staged
432 # items while we unstage each item. For every other category we work
433 # our way down the list of {Unmerged,Modified,Untracked} items while
434 # we stage each item.
436 # The following block of code implements the behavior of selecting
437 # the next item based on the previous selection.
438 for new, old, sel, reselect in saved_selection:
439 # When modified is staged, select the next modified item
440 # When unmerged is staged, select the next unmerged item
441 # When unstaging, select the next staged item
442 # When staging untracked files, select the next untracked item
443 if len(new) >= len(old):
444 # The list did not shrink so it is not one of these cases.
445 continue
446 for item in sel:
447 # The item still exists so ignore it
448 if item in new or item not in old:
449 continue
450 # The item no longer exists in this list so search for
451 # its nearest neighbors and select them instead.
452 idx = old.index(item)
453 for j in itertools.chain(old[idx + 1 :], reversed(old[:idx])):
454 if j in new:
455 reselect(j, current=True)
456 return
458 # If we already reselected stuff then there's nothing more to do.
459 if did_reselect:
460 return
461 # If we got this far then nothing was reselected and made current.
462 # Try a few more heuristics that we can use to keep something selected.
463 if self.old_current_item:
464 category, idx = self.old_current_item
465 _transplant_selection_across_sections(
466 category, idx, self.previous_contents, saved_selection
469 def _restore_scrollbars(self):
470 """Restore scrollbars to the stored values"""
471 qtutils.set_scrollbar_values(self, self.old_hscroll, self.old_vscroll)
472 self.old_hscroll = None
473 self.old_vscroll = None
475 def _stage_selection(self):
476 """Stage or unstage files according to the selection"""
477 context = self.context
478 selected_indexes = self.selected_indexes()
479 is_header = any(category == HEADER_IDX for (category, idx) in selected_indexes)
480 if is_header:
481 is_staged = any(
482 idx == STAGED_IDX and category == HEADER_IDX
483 for (category, idx) in selected_indexes
485 is_modified = any(
486 idx == MODIFIED_IDX and category == HEADER_IDX
487 for (category, idx) in selected_indexes
489 is_untracked = any(
490 idx == UNTRACKED_IDX and category == HEADER_IDX
491 for (category, idx) in selected_indexes
493 # A header item: 'Staged', 'Modified' or 'Untracked'.
494 if is_staged:
495 # If we have the staged header selected then the only sensible
496 # thing to do is to unstage everything and nothing else, even
497 # if the modified or untracked headers are selected.
498 cmds.do(cmds.UnstageAll, context)
499 return # Everything was unstaged. There's nothing more to be done.
500 if is_modified and is_untracked:
501 # If both modified and untracked headers are selected then
502 # stage everything.
503 cmds.do(cmds.StageModifiedAndUntracked, context)
504 return # Nothing more to do.
505 # At this point we may stage all modified and untracked, and then
506 # possibly a subset of the other category (e.g. all modified and
507 # some untracked). We don't return here so that StageOrUnstage
508 # gets a chance to run below.
509 if is_modified:
510 cmds.do(cmds.StageModified, context)
511 elif is_untracked:
512 cmds.do(cmds.StageUntracked, context)
513 else:
514 # Do nothing for unmerged items, by design
515 pass
516 # Now handle individual files
517 cmds.do(cmds.StageOrUnstage, context)
519 def _staged_item(self, itemidx):
520 return self._subtree_item(STAGED_IDX, itemidx)
522 def _modified_item(self, itemidx):
523 return self._subtree_item(MODIFIED_IDX, itemidx)
525 def _unmerged_item(self, itemidx):
526 return self._subtree_item(UNMERGED_IDX, itemidx)
528 def _untracked_item(self, itemidx):
529 return self._subtree_item(UNTRACKED_IDX, itemidx)
531 def _unstaged_item(self, itemidx):
532 # is it modified?
533 item = self.topLevelItem(MODIFIED_IDX)
534 count = item.childCount()
535 if itemidx < count:
536 return item.child(itemidx)
537 # is it unmerged?
538 item = self.topLevelItem(UNMERGED_IDX)
539 count += item.childCount()
540 if itemidx < count:
541 return item.child(itemidx)
542 # is it untracked?
543 item = self.topLevelItem(UNTRACKED_IDX)
544 count += item.childCount()
545 if itemidx < count:
546 return item.child(itemidx)
547 # Nope..
548 return None
550 def _subtree_item(self, idx, itemidx):
551 parent = self.topLevelItem(idx)
552 return parent.child(itemidx)
554 def _set_previous_contents(self, staged, unmerged, modified, untracked):
555 """Callback triggered right before the model changes its contents"""
556 self.previous_contents = selection.State(staged, unmerged, modified, untracked)
558 def _about_to_update(self):
559 self._save_scrollbars()
560 self._save_selection()
562 def _save_scrollbars(self):
563 """Store the scrollbar values for later application"""
564 hscroll, vscroll = qtutils.get_scrollbar_values(self)
565 if hscroll is not None:
566 self.old_hscroll = hscroll
567 if vscroll is not None:
568 self.old_vscroll = vscroll
570 def current_item(self):
571 s = self.selected_indexes()
572 if not s:
573 return None
574 current = self.currentItem()
575 if not current:
576 return None
577 idx = self.indexFromItem(current)
578 if idx.parent().isValid():
579 parent_idx = idx.parent()
580 entry = (parent_idx.row(), idx.row())
581 else:
582 entry = (HEADER_IDX, idx.row())
583 return entry
585 def _save_selection(self):
586 self.old_contents = self.contents()
587 self.old_selection = self.selection()
588 self.old_current_item = self.current_item()
590 def refresh(self):
591 self._set_staged(self._model.staged)
592 self._set_modified(self._model.modified)
593 self._set_unmerged(self._model.unmerged)
594 self._set_untracked(self._model.untracked)
595 self._update_column_widths()
596 self._update_actions()
597 self._restore_selection()
598 self._restore_scrollbars()
600 def _update_actions(self, selected=None):
601 if selected is None:
602 selected = self.selection_model.selection()
603 can_revert_edits = bool(selected.staged or selected.modified)
604 self.revert_unstaged_edits_action.setEnabled(can_revert_edits)
606 enabled = self.selection_model.filename() is not None
607 self.default_app_action.setEnabled(enabled)
608 self.parent_dir_action.setEnabled(enabled)
609 self.copy_path_action.setEnabled(enabled)
610 self.copy_relpath_action.setEnabled(enabled)
611 self.copy_basename_action.setEnabled(enabled)
613 def _set_staged(self, items):
614 """Adds items to the 'Staged' sub-tree."""
615 with qtutils.BlockSignals(self):
616 self._set_subtree(
617 items,
618 STAGED_IDX,
619 N_('Staged'),
620 staged=True,
621 deleted_set=self._model.staged_deleted,
624 def _set_modified(self, items):
625 """Adds items to the 'Modified' sub-tree."""
626 with qtutils.BlockSignals(self):
627 self._set_subtree(
628 items,
629 MODIFIED_IDX,
630 N_('Modified'),
631 deleted_set=self._model.unstaged_deleted,
634 def _set_unmerged(self, items):
635 """Adds items to the 'Unmerged' sub-tree."""
636 deleted_set = {path for path in items if not core.exists(path)}
637 with qtutils.BlockSignals(self):
638 self._set_subtree(
639 items, UNMERGED_IDX, N_('Unmerged'), deleted_set=deleted_set
642 def _set_untracked(self, items):
643 """Adds items to the 'Untracked' sub-tree."""
644 with qtutils.BlockSignals(self):
645 self._set_subtree(items, UNTRACKED_IDX, N_('Untracked'), untracked=True)
647 def _set_subtree(
648 self, items, idx, parent_title, staged=False, untracked=False, deleted_set=None
650 """Add a list of items to a treewidget item."""
651 parent = self.topLevelItem(idx)
652 hide = not bool(items)
653 parent.setHidden(hide)
655 # sip v4.14.7 and below leak memory in parent.takeChildren()
656 # so we use this backwards-compatible construct instead
657 while parent.takeChild(0) is not None:
658 pass
660 for item in items:
661 deleted = deleted_set is not None and item in deleted_set
662 treeitem = qtutils.create_treeitem(
663 item, staged=staged, deleted=deleted, untracked=untracked
665 parent.addChild(treeitem)
666 self._expand_items(idx, items)
668 if prefs.status_show_totals(self.context):
669 parent.setText(0, f'{parent_title} ({len(items)})')
671 def _update_column_widths(self):
672 self.resizeColumnToContents(0)
674 def _expand_items(self, idx, items):
675 """Expand the top-level category "folder" once and only once."""
676 # Don't do this if items is empty; this makes it so that we
677 # don't add the top-level index into the expanded_items set
678 # until an item appears in a particular category.
679 if not items:
680 return
681 # Only run this once; we don't want to re-expand items that
682 # we've clicked on to re-collapse on updated().
683 if idx in self.expanded_items:
684 return
685 self.expanded_items.add(idx)
686 item = self.topLevelItem(idx)
687 if item:
688 self.expandItem(item)
690 def contextMenuEvent(self, event):
691 """Create context menus for the repo status tree."""
692 menu = self._create_context_menu()
693 menu.exec_(self.mapToGlobal(event.pos()))
695 def _create_context_menu(self):
696 """Set up the status menu for the repo status tree."""
697 sel = self.selection()
698 menu = qtutils.create_menu('Status', self)
699 selected_indexes = self.selected_indexes()
700 if selected_indexes:
701 category, idx = selected_indexes[0]
702 # A header item e.g. 'Staged', 'Modified', etc.
703 if category == HEADER_IDX:
704 return self._create_header_context_menu(menu, idx)
706 if sel.staged:
707 self._create_staged_context_menu(menu, sel)
708 elif sel.unmerged:
709 self._create_unmerged_context_menu(menu, sel)
710 else:
711 self._create_unstaged_context_menu(menu, sel)
713 if not menu.isEmpty():
714 menu.addSeparator()
716 if not self.selection_model.is_empty():
717 menu.addAction(self.default_app_action)
718 menu.addAction(self.parent_dir_action)
720 if self.terminal_action is not None:
721 menu.addAction(self.terminal_action)
723 menu.addAction(self.worktree_dir_action)
725 self._add_copy_actions(menu)
727 return menu
729 def _add_copy_actions(self, menu):
730 """Add the "Copy" sub-menu"""
731 enabled = self.selection_model.filename() is not None
732 self.copy_path_action.setEnabled(enabled)
733 self.copy_relpath_action.setEnabled(enabled)
734 self.copy_basename_action.setEnabled(enabled)
736 copy_menu = QtWidgets.QMenu(N_('Copy...'), menu)
737 copy_icon = icons.copy()
738 copy_menu.setIcon(copy_icon)
740 copy_leading_path_action = QtWidgets.QWidgetAction(copy_menu)
741 copy_leading_path_action.setEnabled(enabled)
743 widget = CopyLeadingPathWidget(
744 N_('Copy Leading Path to Clipboard'), self.context, copy_menu
746 # Store the value of the leading paths spin-box so that the value does not reset
747 # every time the menu is shown and recreated.
748 widget.set_value(self.copy_leading_paths_value)
749 widget.spinbox.valueChanged.connect(
750 partial(setattr, self, 'copy_leading_paths_value')
752 copy_leading_path_action.setDefaultWidget(widget)
754 # Copy the leading path when the action is activated.
755 qtutils.connect_action(
756 copy_leading_path_action,
757 lambda widget=widget: copy_leading_path(context, widget.value()),
760 menu.addSeparator()
761 menu.addMenu(copy_menu)
762 copy_menu.addAction(self.copy_path_action)
763 copy_menu.addAction(self.copy_relpath_action)
764 copy_menu.addAction(copy_leading_path_action)
765 copy_menu.addAction(self.copy_basename_action)
767 settings = Settings.read()
768 copy_formats = settings.copy_formats
769 if copy_formats:
770 copy_menu.addSeparator()
772 context = self.context
773 for entry in copy_formats:
774 name = entry.get('name', '')
775 fmt = entry.get('format', '')
776 if name and fmt:
777 action = copy_menu.addAction(name, partial(copy_format, context, fmt))
778 action.setIcon(copy_icon)
779 action.setEnabled(enabled)
781 copy_menu.addSeparator()
782 copy_menu.addAction(self.copy_customize_action)
784 def _create_header_context_menu(self, menu, idx):
785 context = self.context
786 if idx == STAGED_IDX:
787 menu.addAction(
788 icons.remove(), N_('Unstage All'), cmds.run(cmds.UnstageAll, context)
790 elif idx == UNMERGED_IDX:
791 action = menu.addAction(
792 icons.add(),
793 cmds.StageUnmerged.name(),
794 cmds.run(cmds.StageUnmerged, context),
796 action.setShortcut(hotkeys.STAGE_SELECTION)
797 elif idx == MODIFIED_IDX:
798 action = menu.addAction(
799 icons.add(),
800 cmds.StageModified.name(),
801 cmds.run(cmds.StageModified, context),
803 action.setShortcut(hotkeys.STAGE_SELECTION)
804 elif idx == UNTRACKED_IDX:
805 action = menu.addAction(
806 icons.add(),
807 cmds.StageUntracked.name(),
808 cmds.run(cmds.StageUntracked, context),
810 action.setShortcut(hotkeys.STAGE_SELECTION)
811 return menu
813 def _create_staged_context_menu(self, menu, s):
814 if s.staged[0] in self._model.submodules:
815 return self._create_staged_submodule_context_menu(menu, s)
817 context = self.context
818 if self._model.is_unstageable():
819 action = menu.addAction(
820 icons.remove(),
821 N_('Unstage Selected'),
822 cmds.run(cmds.Unstage, context, self.staged()),
824 action.setShortcut(hotkeys.STAGE_SELECTION)
826 menu.addAction(self.launch_editor_action)
828 # Do all of the selected items exist?
829 all_exist = all(
830 i not in self._model.staged_deleted and core.exists(i)
831 for i in self.staged()
834 if all_exist:
835 menu.addAction(self.launch_difftool_action)
837 if self._model.is_undoable():
838 menu.addAction(self.revert_unstaged_edits_action)
840 menu.addAction(self.view_history_action)
841 menu.addAction(self.view_blame_action)
842 return menu
844 def _create_staged_submodule_context_menu(self, menu, s):
845 context = self.context
846 path = core.abspath(s.staged[0])
847 if len(self.staged()) == 1:
848 menu.addAction(
849 icons.cola(),
850 N_('Launch git-cola'),
851 cmds.run(cmds.OpenRepo, context, path),
853 menu.addSeparator()
854 action = menu.addAction(
855 icons.remove(),
856 N_('Unstage Selected'),
857 cmds.run(cmds.Unstage, context, self.staged()),
859 action.setShortcut(hotkeys.STAGE_SELECTION)
861 menu.addAction(self.view_history_action)
862 return menu
864 def _create_unmerged_context_menu(self, menu, _s):
865 context = self.context
866 menu.addAction(self.launch_difftool_action)
868 action = menu.addAction(
869 icons.add(),
870 N_('Stage Selected'),
871 cmds.run(cmds.Stage, context, self.unstaged()),
873 action.setShortcut(hotkeys.STAGE_SELECTION)
875 menu.addAction(
876 icons.remove(),
877 N_('Unstage Selected'),
878 cmds.run(cmds.Unstage, context, self.unstaged()),
880 menu.addAction(self.launch_editor_action)
881 menu.addAction(self.view_history_action)
882 menu.addAction(self.view_blame_action)
883 menu.addSeparator()
884 menu.addAction(self.checkout_ours_action)
885 menu.addAction(self.checkout_theirs_action)
886 return menu
888 def _create_unstaged_context_menu(self, menu, s):
889 context = self.context
890 modified_submodule = s.modified and s.modified[0] in self._model.submodules
891 if modified_submodule:
892 return self._create_modified_submodule_context_menu(menu, s)
894 if self._model.is_stageable():
895 action = menu.addAction(
896 icons.add(),
897 N_('Stage Selected'),
898 cmds.run(cmds.Stage, context, self.unstaged()),
900 action.setShortcut(hotkeys.STAGE_SELECTION)
902 if not self.selection_model.is_empty():
903 menu.addAction(self.launch_editor_action)
905 # Do all of the selected items exist?
906 all_exist = all(
907 i not in self._model.unstaged_deleted and core.exists(i)
908 for i in self.staged()
911 if all_exist and s.modified and self._model.is_stageable():
912 menu.addAction(self.launch_difftool_action)
914 if s.modified and self._model.is_stageable() and self._model.is_undoable():
915 menu.addSeparator()
916 menu.addAction(self.revert_unstaged_edits_action)
918 if all_exist and s.untracked:
919 # Git Annex / Git LFS
920 annex = self._model.annex
921 lfs = core.find_executable('git-lfs')
922 if annex or lfs:
923 menu.addSeparator()
924 if annex:
925 menu.addAction(self.annex_add_action)
926 if lfs:
927 menu.addAction(self.lfs_track_action)
929 menu.addSeparator()
930 if self.move_to_trash_action is not None:
931 menu.addAction(self.move_to_trash_action)
932 menu.addAction(self.delete_untracked_files_action)
933 menu.addSeparator()
934 menu.addAction(
935 icons.edit(),
936 N_('Ignore...'),
937 partial(gitignore.gitignore_view, self.context),
940 if not self.selection_model.is_empty():
941 menu.addAction(self.view_history_action)
942 menu.addAction(self.view_blame_action)
943 return menu
945 def _create_modified_submodule_context_menu(self, menu, s):
946 context = self.context
947 path = core.abspath(s.modified[0])
948 if len(self.unstaged()) == 1:
949 menu.addAction(
950 icons.cola(),
951 N_('Launch git-cola'),
952 cmds.run(cmds.OpenRepo, context, path),
954 menu.addAction(
955 icons.pull(),
956 N_('Update this submodule'),
957 cmds.run(cmds.SubmoduleUpdate, context, path),
959 menu.addSeparator()
961 if self._model.is_stageable():
962 menu.addSeparator()
963 action = menu.addAction(
964 icons.add(),
965 N_('Stage Selected'),
966 cmds.run(cmds.Stage, context, self.unstaged()),
968 action.setShortcut(hotkeys.STAGE_SELECTION)
970 menu.addAction(self.view_history_action)
971 return menu
973 def _delete_untracked_files(self):
974 cmds.do(cmds.Delete, self.context, self.untracked())
976 def _trash_untracked_files(self):
977 cmds.do(cmds.MoveToTrash, self.context, self.untracked())
979 def selected_path(self):
980 s = self.single_selection()
981 return s.staged or s.unmerged or s.modified or s.untracked or None
983 def single_selection(self):
984 """Scan across staged, modified, etc. and return a single item."""
985 staged = None
986 unmerged = None
987 modified = None
988 untracked = None
990 s = self.selection()
991 if s.staged:
992 staged = s.staged[0]
993 elif s.unmerged:
994 unmerged = s.unmerged[0]
995 elif s.modified:
996 modified = s.modified[0]
997 elif s.untracked:
998 untracked = s.untracked[0]
1000 return selection.State(staged, unmerged, modified, untracked)
1002 def selected_indexes(self):
1003 """Returns a list of (category, row) representing the tree selection."""
1004 selected = self.selectedIndexes()
1005 result = []
1006 for idx in selected:
1007 if idx.parent().isValid():
1008 parent_idx = idx.parent()
1009 entry = (parent_idx.row(), idx.row())
1010 else:
1011 entry = (HEADER_IDX, idx.row())
1012 result.append(entry)
1013 return result
1015 def selection(self):
1016 """Return the current selection in the repo status tree."""
1017 return selection.State(
1018 self.staged(), self.unmerged(), self.modified(), self.untracked()
1021 def contents(self):
1022 """Return all of the current files in a selection.State container"""
1023 return selection.State(
1024 self._model.staged,
1025 self._model.unmerged,
1026 self._model.modified,
1027 self._model.untracked,
1030 def all_files(self):
1031 """Return all of the current active files as a flat list"""
1032 c = self.contents()
1033 return c.staged + c.unmerged + c.modified + c.untracked
1035 def selected_group(self):
1036 """A list of selected files in various states of being"""
1037 return selection.pick(self.selection())
1039 def selected_idx(self):
1040 c = self.contents()
1041 s = self.single_selection()
1042 offset = 0
1043 for content, sel in zip(c, s):
1044 if not content:
1045 continue
1046 if sel is not None:
1047 return offset + content.index(sel)
1048 offset += len(content)
1049 return None
1051 def select_by_index(self, idx):
1052 c = self.contents()
1053 to_try = [
1054 (c.staged, STAGED_IDX),
1055 (c.unmerged, UNMERGED_IDX),
1056 (c.modified, MODIFIED_IDX),
1057 (c.untracked, UNTRACKED_IDX),
1059 for content, toplevel_idx in to_try:
1060 if not content:
1061 continue
1062 if idx < len(content):
1063 parent = self.topLevelItem(toplevel_idx)
1064 item = parent.child(idx)
1065 if item is not None:
1066 qtutils.select_item(self, item)
1067 return
1068 idx -= len(content)
1070 def staged(self):
1071 return qtutils.get_selected_values(self, STAGED_IDX, self._model.staged)
1073 def unstaged(self):
1074 return self.unmerged() + self.modified() + self.untracked()
1076 def modified(self):
1077 return qtutils.get_selected_values(self, MODIFIED_IDX, self._model.modified)
1079 def unmerged(self):
1080 return qtutils.get_selected_values(self, UNMERGED_IDX, self._model.unmerged)
1082 def untracked(self):
1083 return qtutils.get_selected_values(self, UNTRACKED_IDX, self._model.untracked)
1085 def staged_items(self):
1086 return qtutils.get_selected_items(self, STAGED_IDX)
1088 def unstaged_items(self):
1089 return self.unmerged_items() + self.modified_items() + self.untracked_items()
1091 def modified_items(self):
1092 return qtutils.get_selected_items(self, MODIFIED_IDX)
1094 def unmerged_items(self):
1095 return qtutils.get_selected_items(self, UNMERGED_IDX)
1097 def untracked_items(self):
1098 return qtutils.get_selected_items(self, UNTRACKED_IDX)
1100 def show_selection(self):
1101 """Show the selected item."""
1102 context = self.context
1103 qtutils.scroll_to_item(self, self.currentItem())
1104 # Sync the selection model
1105 selected = self.selection()
1106 selection_model = self.selection_model
1107 selection_model.set_selection(selected)
1108 self._update_actions(selected=selected)
1110 selected_indexes = self.selected_indexes()
1111 if not selected_indexes:
1112 if self._model.is_amend_mode() or self._model.is_diff_mode():
1113 cmds.do(cmds.SetDiffText, context, '')
1114 else:
1115 cmds.do(cmds.ResetMode, context)
1116 return
1118 # A header item e.g. 'Staged', 'Modified', etc.
1119 category, idx = selected_indexes[0]
1120 header = category == HEADER_IDX
1121 if header:
1122 cls = {
1123 STAGED_IDX: cmds.DiffStagedSummary,
1124 MODIFIED_IDX: cmds.Diffstat,
1125 UNMERGED_IDX: cmds.UnmergedSummary,
1126 UNTRACKED_IDX: cmds.UntrackedSummary,
1127 }.get(idx, cmds.Diffstat)
1128 cmds.do(cls, context)
1129 return
1131 staged = category == STAGED_IDX
1132 modified = category == MODIFIED_IDX
1133 unmerged = category == UNMERGED_IDX
1134 untracked = category == UNTRACKED_IDX
1136 if staged:
1137 item = self.staged_items()[0]
1138 elif unmerged:
1139 item = self.unmerged_items()[0]
1140 elif modified:
1141 item = self.modified_items()[0]
1142 elif untracked:
1143 item = self.unstaged_items()[0]
1144 else:
1145 item = None # this shouldn't happen
1146 assert item is not None
1148 path = item.path
1149 deleted = item.deleted
1150 image = self.image_formats.ok(path)
1152 # Update the diff text
1153 if staged:
1154 cmds.do(cmds.DiffStaged, context, path, deleted=deleted)
1155 elif modified:
1156 cmds.do(cmds.Diff, context, path, deleted=deleted)
1157 elif unmerged:
1158 cmds.do(cmds.Diff, context, path)
1159 elif untracked:
1160 cmds.do(cmds.ShowUntracked, context, path)
1162 # Images are diffed differently.
1163 # DiffImage transitions the diff mode to image.
1164 # DiffText transitions the diff mode to text.
1165 if image:
1166 cmds.do(
1167 cmds.DiffImage,
1168 context,
1169 path,
1170 deleted,
1171 staged,
1172 modified,
1173 unmerged,
1174 untracked,
1176 else:
1177 cmds.do(cmds.DiffText, context)
1179 def select_header(self):
1180 """Select an active header, which triggers a diffstat"""
1181 for idx in (
1182 STAGED_IDX,
1183 UNMERGED_IDX,
1184 MODIFIED_IDX,
1185 UNTRACKED_IDX,
1187 item = self.topLevelItem(idx)
1188 if item.childCount() > 0:
1189 self.clearSelection()
1190 self.setCurrentItem(item)
1191 return
1193 def move_up(self):
1194 """Select the item above the currently selected item"""
1195 idx = self.selected_idx()
1196 all_files = self.all_files()
1197 if idx is None:
1198 selected_indexes = self.selected_indexes()
1199 if selected_indexes:
1200 category, toplevel_idx = selected_indexes[0]
1201 if category == HEADER_IDX:
1202 item = self.itemAbove(self.topLevelItem(toplevel_idx))
1203 if item is not None:
1204 qtutils.select_item(self, item)
1205 return
1206 if all_files:
1207 self.select_by_index(len(all_files) - 1)
1208 return
1209 if idx - 1 >= 0:
1210 self.select_by_index(idx - 1)
1211 else:
1212 self.select_by_index(len(all_files) - 1)
1214 def move_down(self):
1215 """Select the item below the currently selected item"""
1216 idx = self.selected_idx()
1217 all_files = self.all_files()
1218 if idx is None:
1219 selected_indexes = self.selected_indexes()
1220 if selected_indexes:
1221 category, toplevel_idx = selected_indexes[0]
1222 if category == HEADER_IDX:
1223 item = self.itemBelow(self.topLevelItem(toplevel_idx))
1224 if item is not None:
1225 qtutils.select_item(self, item)
1226 return
1227 if all_files:
1228 self.select_by_index(0)
1229 return
1230 if idx + 1 < len(all_files):
1231 self.select_by_index(idx + 1)
1232 else:
1233 self.select_by_index(0)
1235 def mousePressEvent(self, event):
1236 """Keep track of whether to drag URLs or just text"""
1237 self._alt_drag = event.modifiers() & Qt.AltModifier
1238 return super().mousePressEvent(event)
1240 def mouseMoveEvent(self, event):
1241 """Keep track of whether to drag URLs or just text"""
1242 self._alt_drag = event.modifiers() & Qt.AltModifier
1243 return super().mouseMoveEvent(event)
1245 def mimeData(self, items):
1246 """Return a list of absolute-path URLs"""
1247 context = self.context
1248 paths = qtutils.paths_from_items(items, item_filter=_item_filter)
1249 include_urls = not self._alt_drag
1250 return qtutils.mimedata_from_paths(context, paths, include_urls=include_urls)
1252 def mimeTypes(self):
1253 """Return the mime types that this widget generates"""
1254 return qtutils.path_mimetypes(include_urls=not self._alt_drag)
1257 def _item_filter(item):
1258 """Filter items down to just those that exist on disk"""
1259 return not item.deleted and core.exists(item.path)
1262 def view_blame(context):
1263 """Signal that we should view blame for paths."""
1264 cmds.do(cmds.BlamePaths, context)
1267 def view_history(context):
1268 """Signal that we should view history for paths."""
1269 cmds.do(cmds.VisualizePaths, context, context.selection.union())
1272 def copy_path(context, absolute=True):
1273 """Copy a selected path to the clipboard"""
1274 filename = context.selection.filename()
1275 qtutils.copy_path(filename, absolute=absolute)
1278 def copy_relpath(context):
1279 """Copy a selected relative path to the clipboard"""
1280 copy_path(context, absolute=False)
1283 def copy_basename(context):
1284 filename = os.path.basename(context.selection.filename())
1285 basename, _ = os.path.splitext(filename)
1286 qtutils.copy_path(basename, absolute=False)
1289 def copy_leading_path(context, strip_components):
1290 """Peal off trailing path components and copy the current path to the clipboard"""
1291 filename = context.selection.filename()
1292 value = filename
1293 for _ in range(strip_components):
1294 value = os.path.dirname(value)
1295 qtutils.copy_path(value, absolute=False)
1298 def copy_format(context, fmt):
1299 """Add variables usable in the custom Copy format strings"""
1300 values = {}
1301 values['path'] = path = context.selection.filename()
1302 values['abspath'] = abspath = os.path.abspath(path)
1303 values['absdirname'] = os.path.dirname(abspath)
1304 values['dirname'] = os.path.dirname(path)
1305 values['filename'] = os.path.basename(path)
1306 values['basename'], values['ext'] = os.path.splitext(os.path.basename(path))
1307 qtutils.set_clipboard(fmt % values)
1310 def show_help(context):
1311 """Display the help for the custom Copy format strings"""
1312 help_text = N_(
1313 r"""
1314 Format String Variables
1315 -----------------------
1316 %(path)s = relative file path
1317 %(abspath)s = absolute file path
1318 %(dirname)s = relative directory path
1319 %(absdirname)s = absolute directory path
1320 %(filename)s = file basename
1321 %(basename)s = file basename without extension
1322 %(ext)s = file extension
1325 title = N_('Help - Custom Copy Actions')
1326 return text.text_dialog(context, help_text, title)
1329 class StatusFilterWidget(QtWidgets.QWidget):
1330 """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 widget.changed.connect(self.apply_filter)
1347 widget.cleared.connect(self.apply_filter)
1348 widget.enter.connect(self.apply_filter)
1349 widget.editingFinished.connect(self.apply_filter)
1351 def apply_filter(self):
1352 """Apply the text filter to the model"""
1353 value = get(self.text)
1354 if value == self._filter:
1355 return
1356 self._filter = value
1357 paths = utils.shell_split(value)
1358 self.context.model.update_path_filter(paths)
1361 def customize_copy_actions(context, parent):
1362 """Customize copy actions"""
1363 dialog = CustomizeCopyActions(context, parent)
1364 dialog.show()
1365 dialog.exec_()
1368 class CustomizeCopyActions(standard.Dialog):
1369 """A dialog for defining custom Copy actions and format strings"""
1371 def __init__(self, context, parent):
1372 standard.Dialog.__init__(self, parent=parent)
1373 self.setWindowTitle(N_('Custom Copy Actions'))
1375 self.context = context
1376 self.table = QtWidgets.QTableWidget(self)
1377 self.table.setColumnCount(2)
1378 self.table.setHorizontalHeaderLabels([N_('Action Name'), N_('Format String')])
1379 self.table.setSortingEnabled(False)
1380 self.table.verticalHeader().hide()
1381 self.table.horizontalHeader().setStretchLastSection(True)
1383 self.add_button = qtutils.create_button(N_('Add'))
1384 self.remove_button = qtutils.create_button(N_('Remove'))
1385 self.remove_button.setEnabled(False)
1386 self.show_help_button = qtutils.create_button(N_('Show Help'))
1387 self.show_help_button.setShortcut(hotkeys.QUESTION)
1389 self.close_button = qtutils.close_button()
1390 self.save_button = qtutils.ok_button(N_('Save'))
1392 self.buttons = qtutils.hbox(
1393 defs.no_margin,
1394 defs.button_spacing,
1395 self.add_button,
1396 self.remove_button,
1397 self.show_help_button,
1398 qtutils.STRETCH,
1399 self.close_button,
1400 self.save_button,
1403 layout = qtutils.vbox(defs.margin, defs.spacing, self.table, self.buttons)
1404 self.setLayout(layout)
1406 qtutils.connect_button(self.add_button, self.add)
1407 qtutils.connect_button(self.remove_button, self.remove)
1408 qtutils.connect_button(self.show_help_button, partial(show_help, context))
1409 qtutils.connect_button(self.close_button, self.reject)
1410 qtutils.connect_button(self.save_button, self.save)
1411 qtutils.add_close_action(self)
1412 self.table.itemSelectionChanged.connect(self.table_selection_changed)
1414 self.init_size(parent=parent)
1416 QtCore.QTimer.singleShot(0, self.reload_settings)
1418 def reload_settings(self):
1419 """Update the view to match the current settings"""
1420 # Called once after the GUI is initialized
1421 settings = self.context.settings
1422 settings.load()
1423 table = self.table
1424 for entry in settings.copy_formats:
1425 name_string = entry.get('name', '')
1426 format_string = entry.get('format', '')
1427 if name_string and format_string:
1428 name = QtWidgets.QTableWidgetItem(name_string)
1429 fmt = QtWidgets.QTableWidgetItem(format_string)
1430 rows = table.rowCount()
1431 table.setRowCount(rows + 1)
1432 table.setItem(rows, 0, name)
1433 table.setItem(rows, 1, fmt)
1435 def export_state(self):
1436 """Export the current state into the saved settings"""
1437 state = super().export_state()
1438 standard.export_header_columns(self.table, state)
1439 return state
1441 def apply_state(self, state):
1442 """Restore state from the saved settings"""
1443 result = super().apply_state(state)
1444 standard.apply_header_columns(self.table, state)
1445 return result
1447 def add(self):
1448 """Add a custom Copy action and format string"""
1449 self.table.setFocus()
1450 rows = self.table.rowCount()
1451 self.table.setRowCount(rows + 1)
1453 name = QtWidgets.QTableWidgetItem(N_('Name'))
1454 fmt = QtWidgets.QTableWidgetItem(r'%(path)s')
1455 self.table.setItem(rows, 0, name)
1456 self.table.setItem(rows, 1, fmt)
1458 self.table.setCurrentCell(rows, 0)
1459 self.table.editItem(name)
1461 def remove(self):
1462 """Remove selected items"""
1463 # Gather a unique set of rows and remove them in reverse order
1464 rows = set()
1465 items = self.table.selectedItems()
1466 for item in items:
1467 rows.add(self.table.row(item))
1469 for row in reversed(sorted(rows)):
1470 self.table.removeRow(row)
1472 def save(self):
1473 """Save custom copy actions to the settings"""
1474 copy_formats = []
1475 for row in range(self.table.rowCount()):
1476 name = self.table.item(row, 0)
1477 fmt = self.table.item(row, 1)
1478 if name and fmt:
1479 entry = {
1480 'name': name.text(),
1481 'format': fmt.text(),
1483 copy_formats.append(entry)
1485 settings = self.context.settings
1486 while settings.copy_formats:
1487 settings.copy_formats.pop()
1489 settings.copy_formats.extend(copy_formats)
1490 settings.save()
1492 self.accept()
1494 def table_selection_changed(self):
1495 """Update the enabled state of action buttons based on the current selection"""
1496 items = self.table.selectedItems()
1497 self.remove_button.setEnabled(bool(items))
1500 def _select_item(widget, path_list, widget_getter, item, current=False):
1501 """Select the widget item based on the list index"""
1502 # The path lists and widget indexes have a 1:1 correspondence.
1503 # Lookup the item filename in the list and use that index to
1504 # retrieve the widget item and select it.
1505 idx = path_list.index(item)
1506 item = widget_getter(idx)
1507 if current:
1508 widget.setCurrentItem(item)
1509 item.setSelected(True)
1512 def _apply_toplevel_selection(widget, category, idx):
1513 """Select a top-level "header" item (ex: the Staged parent item)
1515 Return True when a top-level item is selected.
1517 is_top_level_item = category == HEADER_IDX
1518 if is_top_level_item:
1519 root_item = widget.invisibleRootItem()
1520 item = root_item.child(idx)
1522 if item is not None and item.childCount() == 0:
1523 # The item now has no children. Select a different top-level item
1524 # corresponding to the previously selected item.
1525 if idx == STAGED_IDX:
1526 # If "Staged" was previously selected try "Modified" and "Untracked".
1527 item = _get_first_item_with_children(
1528 root_item.child(MODIFIED_IDX), root_item.child(UNTRACKED_IDX)
1530 elif idx == UNMERGED_IDX:
1531 # If "Unmerged" was previously selected try "Staged".
1532 item = _get_first_item_with_children(root_item.child(STAGED_IDX))
1533 elif idx == MODIFIED_IDX:
1534 # If "Modified" was previously selected try "Staged" or "Untracked".
1535 item = _get_first_item_with_children(
1536 root_item.child(STAGED_IDX), root_item.child(UNTRACKED_IDX)
1538 elif idx == UNTRACKED_IDX:
1539 # If "Untracked" was previously selected try "Staged".
1540 item = _get_first_item_with_children(root_item.child(STAGED_IDX))
1542 if item is not None:
1543 with qtutils.BlockSignals(widget):
1544 widget.setCurrentItem(item)
1545 item.setSelected(True)
1546 widget.show_selection()
1547 return is_top_level_item
1550 def _get_first_item_with_children(*items):
1551 """Return the first item that contains child items"""
1552 for item in items:
1553 if item.childCount() > 0:
1554 return item
1555 return None
1558 def _transplant_selection_across_sections(
1559 category, idx, previous_contents, saved_selection
1561 """Transplant the selection to a different category"""
1562 # This function is used when the selection would otherwise become empty.
1563 # Apply heuristics to select the items based on the previous state.
1564 if not previous_contents:
1565 return
1566 staged, unmerged, modified, untracked = saved_selection
1567 prev_staged, prev_unmerged, prev_modified, prev_untracked = previous_contents
1569 # The current set of paths.
1570 staged_paths = staged[NEW_PATHS_IDX]
1571 unmerged_paths = unmerged[NEW_PATHS_IDX]
1572 modified_paths = modified[NEW_PATHS_IDX]
1573 untracked_paths = untracked[NEW_PATHS_IDX]
1575 # These callbacks select a path in the corresponding widget sub-tree lists.
1576 select_staged = staged[SELECT_FN_IDX]
1577 select_unmerged = unmerged[SELECT_FN_IDX]
1578 select_modified = modified[SELECT_FN_IDX]
1579 select_untracked = untracked[SELECT_FN_IDX]
1581 if category == STAGED_IDX:
1582 # Staged files can become Unmerged, Modified or Untracked.
1583 # If we previously had a staged file selected then try to select
1584 # it in either the Unmerged, Modified or Untracked sections.
1585 try:
1586 old_path = prev_staged[idx]
1587 except IndexError:
1588 return
1589 if old_path in unmerged_paths:
1590 select_unmerged(old_path, current=True)
1591 elif old_path in modified_paths:
1592 select_modified(old_path, current=True)
1593 elif old_path in untracked_paths:
1594 select_untracked(old_path, current=True)
1596 elif category == UNMERGED_IDX:
1597 # Unmerged files can become Staged, Modified or Untracked.
1598 # If we previously had an unmerged file selected then try to select it in
1599 # the Staged, Modified or Untracked sections.
1600 try:
1601 old_path = prev_unmerged[idx]
1602 except IndexError:
1603 return
1604 if old_path in staged_paths:
1605 select_staged(old_path, current=True)
1606 elif old_path in modified_paths:
1607 select_modified(old_path, current=True)
1608 elif old_path in untracked_paths:
1609 select_untracked(old_path, current=True)
1611 elif category == MODIFIED_IDX:
1612 # If we previously had a modified file selected then try to select
1613 # it in either the Staged or Untracked sections.
1614 try:
1615 old_path = prev_modified[idx]
1616 except IndexError:
1617 return
1618 if old_path in staged_paths:
1619 select_staged(old_path, current=True)
1620 elif old_path in untracked_paths:
1621 select_untracked(old_path, current=True)
1623 elif category == UNTRACKED_IDX:
1624 # If we previously had an untracked file selected then try to select
1625 # it in the Modified or Staged section. Modified is less common, but
1626 # it's possible for a file to be untracked and then the user adds and
1627 # modifies the file before we've refreshed our state.
1628 try:
1629 old_path = prev_untracked[idx]
1630 except IndexError:
1631 return
1632 if old_path in modified_paths:
1633 select_modified(old_path, current=True)
1634 elif old_path in staged_paths:
1635 select_staged(old_path, current=True)
1638 class CopyLeadingPathWidget(QtWidgets.QWidget):
1639 """A widget that holds a label and a spin-box for the number of paths to strip"""
1641 def __init__(self, title, context, parent):
1642 QtWidgets.QWidget.__init__(self, parent)
1643 self.context = context
1644 self.icon = QtWidgets.QLabel(self)
1645 self.label = QtWidgets.QLabel(self)
1646 self.spinbox = standard.SpinBox(value=1, mini=1, maxi=99, parent=self)
1647 self.spinbox.setToolTip(N_('The number of leading paths to strip'))
1649 icon = icons.copy()
1650 pixmap = icon.pixmap(defs.default_icon, defs.default_icon)
1651 self.icon.setPixmap(pixmap)
1652 self.label.setText(title)
1654 layout = qtutils.hbox(
1655 defs.small_margin,
1656 defs.titlebar_spacing,
1657 self.icon,
1658 self.label,
1659 qtutils.STRETCH,
1660 self.spinbox,
1662 self.setLayout(layout)
1664 theme = context.app.theme
1665 highlight_rgb = theme.highlight_color_rgb()
1666 text_rgb, highlight_text_rgb = theme.text_colors_rgb()
1667 disabled_text_rgb = theme.disabled_text_color_rgb()
1669 stylesheet = """
1670 * {{
1671 show-decoration-selected: 1
1673 QLabel {{
1674 color: {text_rgb};
1675 show-decoration-selected: 1
1677 QLabel:hover {{
1678 color: {highlight_text_rgb};
1679 background-color: {highlight_rgb};
1680 background-clip: padding;
1681 show-decoration-selected: 1
1683 QLabel:disabled {{
1684 color: {disabled_text_rgb};
1686 """.format(
1687 disabled_text_rgb=disabled_text_rgb,
1688 text_rgb=text_rgb,
1689 highlight_text_rgb=highlight_text_rgb,
1690 highlight_rgb=highlight_rgb,
1693 self.setStyleSheet(stylesheet)
1695 def value(self):
1696 """Return the current value of the spin-box"""
1697 return self.spinbox.value()
1699 def set_value(self, value):
1700 """Set the spin-box value"""
1701 self.spinbox.setValue(value)