1 from __future__
import division
, absolute_import
, unicode_literals
4 from functools
import partial
6 from qtpy
.QtCore
import Qt
7 from qtpy
.QtCore
import Signal
8 from qtpy
import QtCore
9 from qtpy
import QtWidgets
12 from ..models
import prefs
13 from ..models
import selection
14 from ..widgets
import gitignore
15 from ..widgets
import standard
16 from ..qtutils
import get
17 from .. import actions
20 from .. import hotkeys
22 from .. import qtutils
23 from .. import settings
26 from . import completion
31 class StatusWidget(QtWidgets
.QFrame
):
33 Provides a git-status-like repository widget.
35 This widget observes the main model and broadcasts
40 def __init__(self
, context
, titlebar
, parent
):
41 QtWidgets
.QFrame
.__init
__(self
, parent
)
42 self
.context
= context
44 tooltip
= N_('Toggle the paths filter')
45 icon
= icons
.ellipsis()
46 self
.filter_button
= qtutils
.create_action_button(tooltip
=tooltip
,
48 self
.filter_widget
= StatusFilterWidget(context
)
49 self
.filter_widget
.hide()
50 self
.tree
= StatusTreeWidget(context
, parent
=self
)
51 self
.setFocusProxy(self
.tree
)
53 self
.main_layout
= qtutils
.vbox(defs
.no_margin
, defs
.no_spacing
,
54 self
.filter_widget
, self
.tree
)
55 self
.setLayout(self
.main_layout
)
57 self
.toggle_action
= qtutils
.add_action(
58 self
, tooltip
, self
.toggle_filter
, hotkeys
.FILTER
)
60 titlebar
.add_corner_widget(self
.filter_button
)
61 qtutils
.connect_button(self
.filter_button
, self
.toggle_filter
)
63 def toggle_filter(self
):
64 shown
= not self
.filter_widget
.isVisible()
65 self
.filter_widget
.setVisible(shown
)
67 self
.filter_widget
.setFocus()
71 def set_initial_size(self
):
72 self
.setMaximumWidth(222)
73 QtCore
.QTimer
.singleShot(
74 1, lambda: self
.setMaximumWidth(2 ** 13))
77 self
.tree
.show_selection()
79 def set_filter(self
, txt
):
80 self
.filter_widget
.setVisible(True)
81 self
.filter_widget
.text
.set_value(txt
)
82 self
.filter_widget
.apply_filter()
90 def select_header(self
):
91 self
.tree
.select_header()
94 # pylint: disable=too-many-ancestors
95 class StatusTreeWidget(QtWidgets
.QTreeWidget
):
97 about_to_update
= Signal()
99 diff_text_changed
= Signal()
109 # Read-only access to the mode state
110 mode
= property(lambda self
: self
.m
.mode
)
112 def __init__(self
, context
, parent
=None):
113 QtWidgets
.QTreeWidget
.__init
__(self
, parent
)
114 self
.context
= context
115 self
.selection_model
= context
.selection
117 self
.setSelectionMode(QtWidgets
.QAbstractItemView
.ExtendedSelection
)
118 self
.headerItem().setHidden(True)
119 self
.setAllColumnsShowFocus(True)
120 self
.setSortingEnabled(False)
121 self
.setUniformRowHeights(True)
122 self
.setAnimated(True)
123 self
.setRootIsDecorated(False)
124 self
.setDragEnabled(True)
125 self
.setAutoScroll(False)
127 if not prefs
.status_indent(context
):
128 self
.setIndentation(0)
131 compare
= icons
.compare()
132 question
= icons
.question()
133 self
._add
_toplevel
_item
(N_('Staged'), ok
, hide
=True)
134 self
._add
_toplevel
_item
(N_('Unmerged'), compare
, hide
=True)
135 self
._add
_toplevel
_item
(N_('Modified'), compare
, hide
=True)
136 self
._add
_toplevel
_item
(N_('Untracked'), question
, hide
=True)
138 # Used to restore the selection
139 self
.old_vscroll
= None
140 self
.old_hscroll
= None
141 self
.old_selection
= None
142 self
.old_contents
= None
143 self
.old_current_item
= None
144 self
.was_visible
= True
145 self
.expanded_items
= set()
147 self
.image_formats
= qtutils
.ImageFormats()
149 self
.process_selection_action
= qtutils
.add_action(
150 self
, cmds
.StageOrUnstage
.name(), self
._stage
_selection
,
151 hotkeys
.STAGE_SELECTION
)
153 self
.revert_unstaged_edits_action
= qtutils
.add_action(
154 self
, cmds
.RevertUnstagedEdits
.name(),
155 cmds
.run(cmds
.RevertUnstagedEdits
, context
), hotkeys
.REVERT
)
156 self
.revert_unstaged_edits_action
.setIcon(icons
.undo())
158 self
.launch_difftool_action
= qtutils
.add_action(
159 self
, cmds
.LaunchDifftool
.name(),
160 cmds
.run(cmds
.LaunchDifftool
, context
), hotkeys
.DIFF
)
161 self
.launch_difftool_action
.setIcon(icons
.diff())
163 self
.launch_editor_action
= actions
.launch_editor(
164 context
, self
, *hotkeys
.ACCEPT
)
166 if not utils
.is_win32():
167 self
.default_app_action
= common
.default_app_action(
168 context
, self
, self
.selected_group
)
170 self
.parent_dir_action
= common
.parent_dir_action(
171 context
, self
, self
.selected_group
)
173 self
.terminal_action
= common
.terminal_action(
174 context
, self
, self
.selected_group
)
176 self
.up_action
= qtutils
.add_action(
177 self
, N_('Move Up'), self
.move_up
,
178 hotkeys
.MOVE_UP
, hotkeys
.MOVE_UP_SECONDARY
)
180 self
.down_action
= qtutils
.add_action(
181 self
, N_('Move Down'), self
.move_down
,
182 hotkeys
.MOVE_DOWN
, hotkeys
.MOVE_DOWN_SECONDARY
)
184 self
.copy_path_action
= qtutils
.add_action(
185 self
, N_('Copy Path to Clipboard'),
186 partial(copy_path
, context
), hotkeys
.COPY
)
187 self
.copy_path_action
.setIcon(icons
.copy())
189 self
.copy_relpath_action
= qtutils
.add_action(
190 self
, N_('Copy Relative Path to Clipboard'),
191 partial(copy_relpath
, context
), hotkeys
.CUT
)
192 self
.copy_relpath_action
.setIcon(icons
.copy())
194 self
.copy_leading_path_action
= qtutils
.add_action(
195 self
, N_('Copy Leading Path to Clipboard'),
196 partial(copy_leading_path
, context
))
197 self
.copy_leading_path_action
.setIcon(icons
.copy())
199 self
.copy_basename_action
= qtutils
.add_action(
200 self
, N_('Copy Basename to Clipboard'),
201 partial(copy_basename
, context
))
202 self
.copy_basename_action
.setIcon(icons
.copy())
204 self
.copy_customize_action
= qtutils
.add_action(
205 self
, N_('Customize...'),
206 partial(customize_copy_actions
, context
, self
))
207 self
.copy_customize_action
.setIcon(icons
.configure())
209 self
.view_history_action
= qtutils
.add_action(
210 self
, N_('View History...'), partial(view_history
, context
),
213 self
.view_blame_action
= qtutils
.add_action(
214 self
, N_('Blame...'),
215 partial(view_blame
, context
), hotkeys
.BLAME
)
217 self
.annex_add_action
= qtutils
.add_action(
218 self
, N_('Add to Git Annex'), cmds
.run(cmds
.AnnexAdd
, context
))
220 self
.lfs_track_action
= qtutils
.add_action(
221 self
, N_('Add to Git LFS'), cmds
.run(cmds
.LFSTrack
, context
))
223 # MoveToTrash and Delete use the same shortcut.
224 # We will only bind one of them, depending on whether or not the
225 # MoveToTrash command is available. When available, the hotkey
226 # is bound to MoveToTrash, otherwise it is bound to Delete.
227 if cmds
.MoveToTrash
.AVAILABLE
:
228 self
.move_to_trash_action
= qtutils
.add_action(
229 self
, N_('Move files to trash'),
230 self
._trash
_untracked
_files
, hotkeys
.TRASH
)
231 self
.move_to_trash_action
.setIcon(icons
.discard())
232 delete_shortcut
= hotkeys
.DELETE_FILE
234 self
.move_to_trash_action
= None
235 delete_shortcut
= hotkeys
.DELETE_FILE_SECONDARY
237 self
.delete_untracked_files_action
= qtutils
.add_action(
238 self
, N_('Delete Files...'),
239 self
._delete
_untracked
_files
, delete_shortcut
)
240 self
.delete_untracked_files_action
.setIcon(icons
.discard())
242 about_to_update
= self
._about
_to
_update
243 self
.about_to_update
.connect(about_to_update
, type=Qt
.QueuedConnection
)
244 self
.updated
.connect(self
.refresh
, type=Qt
.QueuedConnection
)
245 self
.diff_text_changed
.connect(
246 self
._make
_current
_item
_visible
, type=Qt
.QueuedConnection
)
248 self
.m
= context
.model
249 self
.m
.add_observer(self
.m
.message_about_to_update
,
250 self
.about_to_update
.emit
)
251 self
.m
.add_observer(self
.m
.message_updated
, self
.updated
.emit
)
252 self
.m
.add_observer(self
.m
.message_diff_text_changed
,
253 self
.diff_text_changed
.emit
)
254 # pylint: disable=no-member
255 self
.itemSelectionChanged
.connect(self
.show_selection
)
256 self
.itemDoubleClicked
.connect(self
._double
_clicked
)
257 self
.itemCollapsed
.connect(lambda x
: self
._update
_column
_widths
())
258 self
.itemExpanded
.connect(lambda x
: self
._update
_column
_widths
())
260 def _make_current_item_visible(self
):
261 item
= self
.currentItem()
263 self
.scroll_to_item(item
)
265 def _add_toplevel_item(self
, txt
, icon
, hide
=False):
266 context
= self
.context
268 if prefs
.bold_headers(context
):
273 item
= QtWidgets
.QTreeWidgetItem(self
)
274 item
.setFont(0, font
)
276 item
.setIcon(0, icon
)
277 if prefs
.bold_headers(context
):
278 item
.setBackground(0, self
.palette().midlight())
282 def _restore_selection(self
):
283 if not self
.old_selection
or not self
.old_contents
:
285 old_c
= self
.old_contents
286 old_s
= self
.old_selection
287 new_c
= self
.contents()
289 def mkselect(lst
, widget_getter
):
290 def select(item
, current
=False):
291 idx
= lst
.index(item
)
292 item
= widget_getter(idx
)
294 self
.setCurrentItem(item
)
295 item
.setSelected(True)
298 select_staged
= mkselect(new_c
.staged
, self
._staged
_item
)
299 select_unmerged
= mkselect(new_c
.unmerged
, self
._unmerged
_item
)
300 select_modified
= mkselect(new_c
.modified
, self
._modified
_item
)
301 select_untracked
= mkselect(new_c
.untracked
, self
._untracked
_item
)
304 (set(new_c
.staged
), old_c
.staged
, set(old_s
.staged
),
307 (set(new_c
.unmerged
), old_c
.unmerged
, set(old_s
.unmerged
),
310 (set(new_c
.modified
), old_c
.modified
, set(old_s
.modified
),
313 (set(new_c
.untracked
), old_c
.untracked
, set(old_s
.untracked
),
317 # Restore the current item
318 if self
.old_current_item
:
319 category
, idx
= self
.old_current_item
320 if category
== self
.idx_header
:
321 item
= self
.invisibleRootItem().child(idx
)
323 self
.blockSignals(True)
324 self
.setCurrentItem(item
)
325 item
.setSelected(True)
326 self
.blockSignals(False)
327 self
.show_selection()
329 # Reselect the current item
330 selection_info
= saved_selection
[category
]
331 new
= selection_info
[0]
332 old
= selection_info
[1]
333 reselect
= selection_info
[3]
339 reselect(item
, current
=True)
342 # When reselecting we only care that the items are selected;
343 # we do not need to rerun the callbacks which were triggered
344 # above. Block signals to skip the callbacks.
345 self
.blockSignals(True)
346 for (new
, old
, sel
, reselect
) in saved_selection
:
349 reselect(item
, current
=False)
350 self
.blockSignals(False)
352 for (new
, old
, sel
, reselect
) in saved_selection
:
353 # When modified is staged, select the next modified item
354 # When unmerged is staged, select the next unmerged item
355 # When unstaging, select the next staged item
356 # When staging untracked files, select the next untracked item
357 if len(new
) >= len(old
):
358 # The list did not shrink so it is not one of these cases.
361 # The item still exists so ignore it
362 if item
in new
or item
not in old
:
364 # The item no longer exists in this list so search for
365 # its nearest neighbors and select them instead.
366 idx
= old
.index(item
)
367 for j
in itertools
.chain(old
[idx
+1:], reversed(old
[:idx
])):
369 reselect(j
, current
=True)
372 def _restore_scrollbars(self
):
373 vscroll
= self
.verticalScrollBar()
374 if vscroll
and self
.old_vscroll
is not None:
375 vscroll
.setValue(self
.old_vscroll
)
376 self
.old_vscroll
= None
378 hscroll
= self
.horizontalScrollBar()
379 if hscroll
and self
.old_hscroll
is not None:
380 hscroll
.setValue(self
.old_hscroll
)
381 self
.old_hscroll
= None
383 def _stage_selection(self
):
384 """Stage or unstage files according to the selection"""
385 context
= self
.context
386 selected_indexes
= self
.selected_indexes()
388 category
, idx
= selected_indexes
[0]
389 # A header item e.g. 'Staged', 'Modified', etc.
390 if category
== self
.idx_header
:
391 if idx
== self
.idx_staged
:
392 cmds
.do(cmds
.UnstageAll
, context
)
393 elif idx
== self
.idx_modified
:
394 cmds
.do(cmds
.StageModified
, context
)
395 elif idx
== self
.idx_untracked
:
396 cmds
.do(cmds
.StageUntracked
, context
)
398 pass # Do nothing for unmerged items, by design
400 cmds
.do(cmds
.StageOrUnstage
, context
)
402 def _staged_item(self
, itemidx
):
403 return self
._subtree
_item
(self
.idx_staged
, itemidx
)
405 def _modified_item(self
, itemidx
):
406 return self
._subtree
_item
(self
.idx_modified
, itemidx
)
408 def _unmerged_item(self
, itemidx
):
409 return self
._subtree
_item
(self
.idx_unmerged
, itemidx
)
411 def _untracked_item(self
, itemidx
):
412 return self
._subtree
_item
(self
.idx_untracked
, itemidx
)
414 def _unstaged_item(self
, itemidx
):
416 item
= self
.topLevelItem(self
.idx_modified
)
417 count
= item
.childCount()
419 return item
.child(itemidx
)
421 item
= self
.topLevelItem(self
.idx_unmerged
)
422 count
+= item
.childCount()
424 return item
.child(itemidx
)
426 item
= self
.topLevelItem(self
.idx_untracked
)
427 count
+= item
.childCount()
429 return item
.child(itemidx
)
433 def _subtree_item(self
, idx
, itemidx
):
434 parent
= self
.topLevelItem(idx
)
435 return parent
.child(itemidx
)
437 def _about_to_update(self
):
438 self
._save
_scrollbars
()
439 self
._save
_selection
()
441 def _save_scrollbars(self
):
442 vscroll
= self
.verticalScrollBar()
444 self
.old_vscroll
= get(vscroll
)
446 hscroll
= self
.horizontalScrollBar()
448 self
.old_hscroll
= get(hscroll
)
450 def current_item(self
):
451 s
= self
.selected_indexes()
454 current
= self
.currentItem()
457 idx
= self
.indexFromItem(current
, 0)
458 if idx
.parent().isValid():
459 parent_idx
= idx
.parent()
460 entry
= (parent_idx
.row(), idx
.row())
462 entry
= (self
.idx_header
, idx
.row())
465 def _save_selection(self
):
466 self
.old_contents
= self
.contents()
467 self
.old_selection
= self
.selection()
468 self
.old_current_item
= self
.current_item()
471 self
._set
_staged
(self
.m
.staged
)
472 self
._set
_modified
(self
.m
.modified
)
473 self
._set
_unmerged
(self
.m
.unmerged
)
474 self
._set
_untracked
(self
.m
.untracked
)
475 self
._update
_column
_widths
()
476 self
._update
_actions
()
477 self
._restore
_selection
()
478 self
._restore
_scrollbars
()
480 def _update_actions(self
, selected
=None):
482 selected
= self
.selection_model
.selection()
483 can_revert_edits
= bool(selected
.staged
or selected
.modified
)
484 self
.revert_unstaged_edits_action
.setEnabled(can_revert_edits
)
486 def _set_staged(self
, items
):
487 """Adds items to the 'Staged' subtree."""
488 self
._set
_subtree
(items
, self
.idx_staged
, N_('Staged'), staged
=True,
489 deleted_set
=self
.m
.staged_deleted
)
491 def _set_modified(self
, items
):
492 """Adds items to the 'Modified' subtree."""
493 self
._set
_subtree
(items
, self
.idx_modified
, N_('Modified'),
494 deleted_set
=self
.m
.unstaged_deleted
)
496 def _set_unmerged(self
, items
):
497 """Adds items to the 'Unmerged' subtree."""
498 deleted_set
= set([path
for path
in items
if not core
.exists(path
)])
499 self
._set
_subtree
(items
, self
.idx_unmerged
, N_('Unmerged'),
500 deleted_set
=deleted_set
)
502 def _set_untracked(self
, items
):
503 """Adds items to the 'Untracked' subtree."""
504 self
._set
_subtree
(items
, self
.idx_untracked
, N_('Untracked'),
507 def _set_subtree(self
, items
, idx
, parent_title
,
511 """Add a list of items to a treewidget item."""
512 self
.blockSignals(True)
513 parent
= self
.topLevelItem(idx
)
514 hide
= not bool(items
)
515 parent
.setHidden(hide
)
517 # sip v4.14.7 and below leak memory in parent.takeChildren()
518 # so we use this backwards-compatible construct instead
519 while parent
.takeChild(0) is not None:
523 deleted
= (deleted_set
is not None and item
in deleted_set
)
524 treeitem
= qtutils
.create_treeitem(item
,
528 parent
.addChild(treeitem
)
529 self
._expand
_items
(idx
, items
)
530 self
.blockSignals(False)
531 if prefs
.status_show_totals(self
.context
):
532 parent
.setText(0, '%s (%s)' % (parent_title
, len(items
)))
534 def _update_column_widths(self
):
535 self
.resizeColumnToContents(0)
537 def _expand_items(self
, idx
, items
):
538 """Expand the top-level category "folder" once and only once."""
539 # Don't do this if items is empty; this makes it so that we
540 # don't add the top-level index into the expanded_items set
541 # until an item appears in a particular category.
544 # Only run this once; we don't want to re-expand items that
545 # we've clicked on to re-collapse on updated().
546 if idx
in self
.expanded_items
:
548 self
.expanded_items
.add(idx
)
549 item
= self
.topLevelItem(idx
)
551 self
.expandItem(item
)
553 def contextMenuEvent(self
, event
):
554 """Create context menus for the repo status tree."""
555 menu
= self
._create
_context
_menu
()
556 menu
.exec_(self
.mapToGlobal(event
.pos()))
558 def _create_context_menu(self
):
559 """Set up the status menu for the repo status tree."""
561 menu
= qtutils
.create_menu('Status', self
)
562 selected_indexes
= self
.selected_indexes()
564 category
, idx
= selected_indexes
[0]
565 # A header item e.g. 'Staged', 'Modified', etc.
566 if category
== self
.idx_header
:
567 return self
._create
_header
_context
_menu
(menu
, idx
)
570 self
._create
_staged
_context
_menu
(menu
, s
)
572 self
._create
_unmerged
_context
_menu
(menu
, s
)
574 self
._create
_unstaged
_context
_menu
(menu
, s
)
576 if not utils
.is_win32():
577 if not menu
.isEmpty():
579 if not self
.selection_model
.is_empty():
580 menu
.addAction(self
.default_app_action
)
581 menu
.addAction(self
.parent_dir_action
)
583 if self
.terminal_action
is not None:
584 menu
.addAction(self
.terminal_action
)
586 self
._add
_copy
_actions
(menu
)
590 def _add_copy_actions(self
, menu
):
591 """Add the "Copy" sub-menu"""
592 enabled
= self
.selection_model
.filename() is not None
593 self
.copy_path_action
.setEnabled(enabled
)
594 self
.copy_relpath_action
.setEnabled(enabled
)
595 self
.copy_leading_path_action
.setEnabled(enabled
)
596 self
.copy_basename_action
.setEnabled(enabled
)
597 copy_icon
= icons
.copy()
600 copy_menu
= QtWidgets
.QMenu(N_('Copy...'), menu
)
601 menu
.addMenu(copy_menu
)
603 copy_menu
.setIcon(copy_icon
)
604 copy_menu
.addAction(self
.copy_path_action
)
605 copy_menu
.addAction(self
.copy_relpath_action
)
606 copy_menu
.addAction(self
.copy_leading_path_action
)
607 copy_menu
.addAction(self
.copy_basename_action
)
609 current_settings
= settings
.Settings()
610 current_settings
.load()
612 copy_formats
= current_settings
.copy_formats
614 copy_menu
.addSeparator()
616 context
= self
.context
617 for entry
in copy_formats
:
618 name
= entry
.get('name', '')
619 fmt
= entry
.get('format', '')
621 action
= copy_menu
.addAction(
622 name
, partial(copy_format
, context
, fmt
))
623 action
.setIcon(copy_icon
)
624 action
.setEnabled(enabled
)
626 copy_menu
.addSeparator()
627 copy_menu
.addAction(self
.copy_customize_action
)
629 def _create_header_context_menu(self
, menu
, idx
):
630 context
= self
.context
631 if idx
== self
.idx_staged
:
632 menu
.addAction(icons
.remove(), N_('Unstage All'),
633 cmds
.run(cmds
.UnstageAll
, context
))
634 elif idx
== self
.idx_unmerged
:
635 action
= menu
.addAction(icons
.add(), cmds
.StageUnmerged
.name(),
636 cmds
.run(cmds
.StageUnmerged
, context
))
637 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
638 elif idx
== self
.idx_modified
:
639 action
= menu
.addAction(icons
.add(), cmds
.StageModified
.name(),
640 cmds
.run(cmds
.StageModified
, context
))
641 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
642 elif idx
== self
.idx_untracked
:
643 action
= menu
.addAction(icons
.add(), cmds
.StageUntracked
.name(),
644 cmds
.run(cmds
.StageUntracked
, context
))
645 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
648 def _create_staged_context_menu(self
, menu
, s
):
649 if s
.staged
[0] in self
.m
.submodules
:
650 return self
._create
_staged
_submodule
_context
_menu
(menu
, s
)
652 context
= self
.context
653 if self
.m
.unstageable():
654 action
= menu
.addAction(
655 icons
.remove(), N_('Unstage Selected'),
656 cmds
.run(cmds
.Unstage
, context
, self
.staged()))
657 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
659 menu
.addAction(self
.launch_editor_action
)
661 # Do all of the selected items exist?
662 all_exist
= all(i
not in self
.m
.staged_deleted
and core
.exists(i
)
663 for i
in self
.staged())
666 menu
.addAction(self
.launch_difftool_action
)
668 if self
.m
.undoable():
669 menu
.addAction(self
.revert_unstaged_edits_action
)
671 menu
.addAction(self
.view_history_action
)
672 menu
.addAction(self
.view_blame_action
)
675 def _create_staged_submodule_context_menu(self
, menu
, s
):
676 context
= self
.context
677 path
= core
.abspath(s
.staged
[0])
678 if len(self
.staged()) == 1:
679 menu
.addAction(icons
.cola(), N_('Launch git-cola'),
680 cmds
.run(cmds
.OpenRepo
, context
, path
))
682 action
= menu
.addAction(
683 icons
.remove(), N_('Unstage Selected'),
684 cmds
.run(cmds
.Unstage
, context
, self
.staged()))
685 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
687 menu
.addAction(self
.view_history_action
)
690 def _create_unmerged_context_menu(self
, menu
, _s
):
691 context
= self
.context
692 menu
.addAction(self
.launch_difftool_action
)
694 action
= menu
.addAction(
695 icons
.add(), N_('Stage Selected'),
696 cmds
.run(cmds
.Stage
, context
, self
.unstaged()))
697 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
699 menu
.addAction(self
.launch_editor_action
)
700 menu
.addAction(self
.view_history_action
)
701 menu
.addAction(self
.view_blame_action
)
704 def _create_unstaged_context_menu(self
, menu
, s
):
705 context
= self
.context
706 modified_submodule
= (s
.modified
and
707 s
.modified
[0] in self
.m
.submodules
)
708 if modified_submodule
:
709 return self
._create
_modified
_submodule
_context
_menu
(menu
, s
)
711 if self
.m
.stageable():
712 action
= menu
.addAction(
713 icons
.add(), N_('Stage Selected'),
714 cmds
.run(cmds
.Stage
, context
, self
.unstaged()))
715 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
717 if not self
.selection_model
.is_empty():
718 menu
.addAction(self
.launch_editor_action
)
720 # Do all of the selected items exist?
721 all_exist
= all(i
not in self
.m
.unstaged_deleted
and core
.exists(i
)
722 for i
in self
.staged())
724 if all_exist
and s
.modified
and self
.m
.stageable():
725 menu
.addAction(self
.launch_difftool_action
)
727 if s
.modified
and self
.m
.stageable():
728 if self
.m
.undoable():
730 menu
.addAction(self
.revert_unstaged_edits_action
)
732 if all_exist
and s
.untracked
:
733 # Git Annex / Git LFS
735 lfs
= core
.find_executable('git-lfs')
739 menu
.addAction(self
.annex_add_action
)
741 menu
.addAction(self
.lfs_track_action
)
744 if self
.move_to_trash_action
is not None:
745 menu
.addAction(self
.move_to_trash_action
)
746 menu
.addAction(self
.delete_untracked_files_action
)
748 menu
.addAction(icons
.edit(), N_('Add to .gitignore'),
749 partial(gitignore
.gitignore_view
, self
.context
))
751 if not self
.selection_model
.is_empty():
752 menu
.addAction(self
.view_history_action
)
753 menu
.addAction(self
.view_blame_action
)
756 def _create_modified_submodule_context_menu(self
, menu
, s
):
757 context
= self
.context
758 path
= core
.abspath(s
.modified
[0])
759 if len(self
.unstaged()) == 1:
760 menu
.addAction(icons
.cola(), N_('Launch git-cola'),
761 cmds
.run(cmds
.OpenRepo
, context
, path
))
762 menu
.addAction(icons
.pull(), N_('Update this submodule'),
763 cmds
.run(cmds
.SubmoduleUpdate
, context
, path
))
766 if self
.m
.stageable():
768 action
= menu
.addAction(
769 icons
.add(), N_('Stage Selected'),
770 cmds
.run(cmds
.Stage
, context
, self
.unstaged()))
771 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
773 menu
.addAction(self
.view_history_action
)
776 def _delete_untracked_files(self
):
777 cmds
.do(cmds
.Delete
, self
.context
, self
.untracked())
779 def _trash_untracked_files(self
):
780 cmds
.do(cmds
.MoveToTrash
, self
.context
, self
.untracked())
782 def selected_path(self
):
783 s
= self
.single_selection()
784 return s
.staged
or s
.unmerged
or s
.modified
or s
.untracked
or None
786 def single_selection(self
):
787 """Scan across staged, modified, etc. and return a single item."""
797 unmerged
= s
.unmerged
[0]
799 modified
= s
.modified
[0]
801 untracked
= s
.untracked
[0]
803 return selection
.State(staged
, unmerged
, modified
, untracked
)
805 def selected_indexes(self
):
806 """Returns a list of (category, row) representing the tree selection."""
807 selected
= self
.selectedIndexes()
810 if idx
.parent().isValid():
811 parent_idx
= idx
.parent()
812 entry
= (parent_idx
.row(), idx
.row())
814 entry
= (self
.idx_header
, idx
.row())
819 """Return the current selection in the repo status tree."""
820 return selection
.State(self
.staged(), self
.unmerged(),
821 self
.modified(), self
.untracked())
824 return selection
.State(self
.m
.staged
, self
.m
.unmerged
,
825 self
.m
.modified
, self
.m
.untracked
)
829 return c
.staged
+ c
.unmerged
+ c
.modified
+ c
.untracked
831 def selected_group(self
):
832 """A list of selected files in various states of being"""
833 return selection
.pick(self
.selection())
835 def selected_idx(self
):
837 s
= self
.single_selection()
839 for content
, sel
in zip(c
, s
):
843 return offset
+ content
.index(sel
)
844 offset
+= len(content
)
847 def select_by_index(self
, idx
):
850 (c
.staged
, self
.idx_staged
),
851 (c
.unmerged
, self
.idx_unmerged
),
852 (c
.modified
, self
.idx_modified
),
853 (c
.untracked
, self
.idx_untracked
),
855 for content
, toplevel_idx
in to_try
:
858 if idx
< len(content
):
859 parent
= self
.topLevelItem(toplevel_idx
)
860 item
= parent
.child(idx
)
862 self
.select_item(item
)
866 def scroll_to_item(self
, item
):
867 # First, scroll to the item, but keep the original hscroll
869 hscrollbar
= self
.horizontalScrollBar()
871 hscroll
= get(hscrollbar
)
872 self
.scrollToItem(item
)
873 if hscroll
is not None:
874 hscrollbar
.setValue(hscroll
)
876 def select_item(self
, item
):
877 self
.scroll_to_item(item
)
878 self
.setCurrentItem(item
)
879 item
.setSelected(True)
882 return self
._subtree
_selection
(self
.idx_staged
, self
.m
.staged
)
885 return self
.unmerged() + self
.modified() + self
.untracked()
888 return self
._subtree
_selection
(self
.idx_modified
, self
.m
.modified
)
891 return self
._subtree
_selection
(self
.idx_unmerged
, self
.m
.unmerged
)
894 return self
._subtree
_selection
(self
.idx_untracked
, self
.m
.untracked
)
896 def staged_items(self
):
897 return self
._subtree
_selection
_items
(self
.idx_staged
)
899 def unstaged_items(self
):
900 return (self
.unmerged_items() + self
.modified_items() +
901 self
.untracked_items())
903 def modified_items(self
):
904 return self
._subtree
_selection
_items
(self
.idx_modified
)
906 def unmerged_items(self
):
907 return self
._subtree
_selection
_items
(self
.idx_unmerged
)
909 def untracked_items(self
):
910 return self
._subtree
_selection
_items
(self
.idx_untracked
)
912 def _subtree_selection(self
, idx
, items
):
913 item
= self
.topLevelItem(idx
)
914 return qtutils
.tree_selection(item
, items
)
916 def _subtree_selection_items(self
, idx
):
917 item
= self
.topLevelItem(idx
)
918 return qtutils
.tree_selection_items(item
)
920 def _double_clicked(self
, _item
, _idx
):
921 """Called when an item is double-clicked in the repo status tree."""
922 cmds
.do(cmds
.StageOrUnstage
, self
.context
)
924 def show_selection(self
):
925 """Show the selected item."""
926 context
= self
.context
927 self
.scroll_to_item(self
.currentItem())
928 # Sync the selection model
929 selected
= self
.selection()
930 selection_model
= self
.selection_model
931 selection_model
.set_selection(selected
)
932 self
._update
_actions
(selected
=selected
)
934 selected_indexes
= self
.selected_indexes()
935 if not selected_indexes
:
936 if self
.m
.amending():
937 cmds
.do(cmds
.SetDiffText
, context
, '')
939 cmds
.do(cmds
.ResetMode
, context
)
942 # A header item e.g. 'Staged', 'Modified', etc.
943 category
, idx
= selected_indexes
[0]
944 header
= category
== self
.idx_header
947 self
.idx_staged
: cmds
.DiffStagedSummary
,
948 self
.idx_modified
: cmds
.Diffstat
,
949 # TODO implement UnmergedSummary
950 # self.idx_unmerged: cmds.UnmergedSummary,
951 self
.idx_untracked
: cmds
.UntrackedSummary
,
952 }.get(idx
, cmds
.Diffstat
)
953 cmds
.do(cls
, context
)
956 staged
= category
== self
.idx_staged
957 modified
= category
== self
.idx_modified
958 unmerged
= category
== self
.idx_unmerged
959 untracked
= category
== self
.idx_untracked
962 item
= self
.staged_items()[0]
964 item
= self
.unmerged_items()[0]
966 item
= self
.modified_items()[0]
968 item
= self
.unstaged_items()[0]
970 item
= None # this shouldn't happen
971 assert item
is not None
974 deleted
= item
.deleted
975 image
= self
.image_formats
.ok(path
)
977 # Images are diffed differently
979 cmds
.do(cmds
.DiffImage
, context
, path
, deleted
,
980 staged
, modified
, unmerged
, untracked
)
982 cmds
.do(cmds
.DiffStaged
, context
, path
, deleted
=deleted
)
984 cmds
.do(cmds
.Diff
, context
, path
, deleted
=deleted
)
986 cmds
.do(cmds
.Diff
, context
, path
)
988 cmds
.do(cmds
.ShowUntracked
, context
, path
)
990 def select_header(self
):
991 """Select an active header, which triggers a diffstat"""
992 for idx
in (self
.idx_staged
, self
.idx_unmerged
,
993 self
.idx_modified
, self
.idx_untracked
):
994 item
= self
.topLevelItem(idx
)
995 if item
.childCount() > 0:
996 self
.clearSelection()
997 self
.setCurrentItem(item
)
1001 idx
= self
.selected_idx()
1002 all_files
= self
.all_files()
1004 selected_indexes
= self
.selected_indexes()
1005 if selected_indexes
:
1006 category
, toplevel_idx
= selected_indexes
[0]
1007 if category
== self
.idx_header
:
1008 item
= self
.itemAbove(self
.topLevelItem(toplevel_idx
))
1009 if item
is not None:
1010 self
.select_item(item
)
1013 self
.select_by_index(len(all_files
) - 1)
1016 self
.select_by_index(idx
- 1)
1018 self
.select_by_index(len(all_files
) - 1)
1020 def move_down(self
):
1021 idx
= self
.selected_idx()
1022 all_files
= self
.all_files()
1024 selected_indexes
= self
.selected_indexes()
1025 if selected_indexes
:
1026 category
, toplevel_idx
= selected_indexes
[0]
1027 if category
== self
.idx_header
:
1028 item
= self
.itemBelow(self
.topLevelItem(toplevel_idx
))
1029 if item
is not None:
1030 self
.select_item(item
)
1033 self
.select_by_index(0)
1035 if idx
+ 1 < len(all_files
):
1036 self
.select_by_index(idx
+ 1)
1038 self
.select_by_index(0)
1040 def mimeData(self
, items
):
1041 """Return a list of absolute-path URLs"""
1042 context
= self
.context
1043 paths
= qtutils
.paths_from_items(items
, item_filter
=_item_filter
)
1044 return qtutils
.mimedata_from_paths(context
, paths
)
1046 # pylint: disable=no-self-use
1047 def mimeTypes(self
):
1048 return qtutils
.path_mimetypes()
1051 def _item_filter(item
):
1052 return not item
.deleted
and core
.exists(item
.path
)
1055 def view_blame(context
):
1056 """Signal that we should view blame for paths."""
1057 cmds
.do(cmds
.BlamePaths
, context
)
1060 def view_history(context
):
1061 """Signal that we should view history for paths."""
1062 cmds
.do(cmds
.VisualizePaths
, context
, context
.selection
.union())
1065 def copy_path(context
, absolute
=True):
1066 """Copy a selected path to the clipboard"""
1067 filename
= context
.selection
.filename()
1068 qtutils
.copy_path(filename
, absolute
=absolute
)
1071 def copy_relpath(context
):
1072 """Copy a selected relative path to the clipboard"""
1073 copy_path(context
, absolute
=False)
1076 def copy_basename(context
):
1077 filename
= os
.path
.basename(context
.selection
.filename())
1078 basename
, _
= os
.path
.splitext(filename
)
1079 qtutils
.copy_path(basename
, absolute
=False)
1082 def copy_leading_path(context
):
1083 """Copy the selected leading path to the clipboard"""
1084 filename
= context
.selection
.filename()
1085 dirname
= os
.path
.dirname(filename
)
1086 qtutils
.copy_path(dirname
, absolute
=False)
1089 def copy_format(context
, fmt
):
1091 values
['path'] = path
= context
.selection
.filename()
1092 values
['abspath'] = abspath
= os
.path
.abspath(path
)
1093 values
['absdirname'] = os
.path
.dirname(abspath
)
1094 values
['dirname'] = os
.path
.dirname(path
)
1095 values
['filename'] = os
.path
.basename(path
)
1096 values
['basename'], values
['ext'] = (
1097 os
.path
.splitext(os
.path
.basename(path
)))
1098 qtutils
.set_clipboard(fmt
% values
)
1101 def show_help(context
):
1103 Format String Variables
1104 -----------------------
1105 %(path)s = relative file path
1106 %(abspath)s = absolute file path
1107 %(dirname)s = relative directory path
1108 %(absdirname)s = absolute directory path
1109 %(filename)s = file basename
1110 %(basename)s = file basename without extension
1111 %(ext)s = file extension
1113 title
= N_('Help - Custom Copy Actions')
1114 return text
.text_dialog(context
, help_text
, title
)
1117 class StatusFilterWidget(QtWidgets
.QWidget
):
1119 def __init__(self
, context
, parent
=None):
1120 QtWidgets
.QWidget
.__init
__(self
, parent
)
1121 self
.context
= context
1123 hint
= N_('Filter paths...')
1124 self
.text
= completion
.GitStatusFilterLineEdit(
1125 context
, hint
=hint
, parent
=self
)
1126 self
.text
.setToolTip(hint
)
1127 self
.setFocusProxy(self
.text
)
1130 self
.main_layout
= qtutils
.hbox(defs
.no_margin
, defs
.spacing
, self
.text
)
1131 self
.setLayout(self
.main_layout
)
1134 # pylint: disable=no-member
1135 widget
.changed
.connect(self
.apply_filter
)
1136 widget
.cleared
.connect(self
.apply_filter
)
1137 widget
.enter
.connect(self
.apply_filter
)
1138 widget
.editingFinished
.connect(self
.apply_filter
)
1140 def apply_filter(self
):
1141 value
= get(self
.text
)
1142 if value
== self
._filter
:
1144 self
._filter
= value
1145 paths
= utils
.shell_split(value
)
1146 self
.context
.model
.update_path_filter(paths
)
1149 def customize_copy_actions(context
, parent
):
1150 """Customize copy actions"""
1151 dialog
= CustomizeCopyActions(context
, parent
)
1156 class CustomizeCopyActions(standard
.Dialog
):
1158 def __init__(self
, context
, parent
):
1159 standard
.Dialog
.__init
__(self
, parent
=parent
)
1160 self
.setWindowTitle(N_('Custom Copy Actions'))
1162 self
.table
= QtWidgets
.QTableWidget(self
)
1163 self
.table
.setColumnCount(2)
1164 self
.table
.setHorizontalHeaderLabels([
1166 N_('Format String'),
1168 self
.table
.setSortingEnabled(False)
1169 self
.table
.verticalHeader().hide()
1170 self
.table
.horizontalHeader().setStretchLastSection(True)
1172 self
.add_button
= qtutils
.create_button(N_('Add'))
1173 self
.remove_button
= qtutils
.create_button(N_('Remove'))
1174 self
.remove_button
.setEnabled(False)
1175 self
.show_help_button
= qtutils
.create_button(N_('Show Help'))
1176 self
.show_help_button
.setShortcut(hotkeys
.QUESTION
)
1178 self
.close_button
= qtutils
.close_button()
1179 self
.save_button
= qtutils
.ok_button(N_('Save'))
1181 self
.buttons
= qtutils
.hbox(defs
.no_margin
, defs
.button_spacing
,
1184 self
.show_help_button
,
1189 layout
= qtutils
.vbox(defs
.margin
, defs
.spacing
,
1190 self
.table
, self
.buttons
)
1191 self
.setLayout(layout
)
1193 qtutils
.connect_button(self
.add_button
, self
.add
)
1194 qtutils
.connect_button(self
.remove_button
, self
.remove
)
1195 qtutils
.connect_button(
1196 self
.show_help_button
, partial(show_help
, context
))
1197 qtutils
.connect_button(self
.close_button
, self
.reject
)
1198 qtutils
.connect_button(self
.save_button
, self
.save
)
1199 qtutils
.add_close_action(self
)
1200 # pylint: disable=no-member
1201 self
.table
.itemSelectionChanged
.connect(self
.table_selection_changed
)
1203 self
.init_size(parent
=parent
)
1205 self
.settings
= settings
.Settings()
1206 QtCore
.QTimer
.singleShot(0, self
.reload_settings
)
1208 def reload_settings(self
):
1209 # Called once after the GUI is initialized
1210 self
.settings
.load()
1212 for entry
in self
.settings
.copy_formats
:
1213 name_string
= entry
.get('name', '')
1214 format_string
= entry
.get('format', '')
1215 if name_string
and format_string
:
1216 name
= QtWidgets
.QTableWidgetItem(name_string
)
1217 fmt
= QtWidgets
.QTableWidgetItem(format_string
)
1218 rows
= table
.rowCount()
1219 table
.setRowCount(rows
+ 1)
1220 table
.setItem(rows
, 0, name
)
1221 table
.setItem(rows
, 1, fmt
)
1223 def export_state(self
):
1224 state
= super(CustomizeCopyActions
, self
).export_state()
1225 standard
.export_header_columns(self
.table
, state
)
1228 def apply_state(self
, state
):
1229 result
= super(CustomizeCopyActions
, self
).apply_state(state
)
1230 standard
.apply_header_columns(self
.table
, state
)
1234 self
.table
.setFocus()
1235 rows
= self
.table
.rowCount()
1236 self
.table
.setRowCount(rows
+ 1)
1238 name
= QtWidgets
.QTableWidgetItem(N_('Name'))
1239 fmt
= QtWidgets
.QTableWidgetItem(r
'%(path)s')
1240 self
.table
.setItem(rows
, 0, name
)
1241 self
.table
.setItem(rows
, 1, fmt
)
1243 self
.table
.setCurrentCell(rows
, 0)
1244 self
.table
.editItem(name
)
1247 """Remove selected items"""
1248 # Gather a unique set of rows and remove them in reverse order
1250 items
= self
.table
.selectedItems()
1252 rows
.add(self
.table
.row(item
))
1254 for row
in reversed(sorted(rows
)):
1255 self
.table
.removeRow(row
)
1259 for row
in range(self
.table
.rowCount()):
1260 name
= self
.table
.item(row
, 0)
1261 fmt
= self
.table
.item(row
, 1)
1264 'name': name
.text(),
1265 'format': fmt
.text(),
1267 copy_formats
.append(entry
)
1269 while self
.settings
.copy_formats
:
1270 self
.settings
.copy_formats
.pop()
1272 self
.settings
.copy_formats
.extend(copy_formats
)
1273 self
.settings
.save()
1277 def table_selection_changed(self
):
1278 items
= self
.table
.selectedItems()
1279 self
.remove_button
.setEnabled(bool(items
))