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
.QWidget
):
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
.QWidget
.__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(True)
69 self
.tree
.setFocus(True)
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 class StatusTreeWidget(QtWidgets
.QTreeWidget
):
96 about_to_update
= Signal()
98 diff_text_changed
= Signal()
108 # Read-only access to the mode state
109 mode
= property(lambda self
: self
.m
.mode
)
111 def __init__(self
, context
, parent
=None):
112 QtWidgets
.QTreeWidget
.__init
__(self
, parent
)
113 self
.context
= context
114 self
.selection_model
= context
.selection
116 self
.setSelectionMode(QtWidgets
.QAbstractItemView
.ExtendedSelection
)
117 self
.headerItem().setHidden(True)
118 self
.setAllColumnsShowFocus(True)
119 self
.setSortingEnabled(False)
120 self
.setUniformRowHeights(True)
121 self
.setAnimated(True)
122 self
.setRootIsDecorated(False)
123 self
.setIndentation(0)
124 self
.setDragEnabled(True)
125 self
.setAutoScroll(False)
128 compare
= icons
.compare()
129 question
= icons
.question()
130 self
._add
_toplevel
_item
(N_('Staged'), ok
, hide
=True)
131 self
._add
_toplevel
_item
(N_('Unmerged'), compare
, hide
=True)
132 self
._add
_toplevel
_item
(N_('Modified'), compare
, hide
=True)
133 self
._add
_toplevel
_item
(N_('Untracked'), question
, hide
=True)
135 # Used to restore the selection
136 self
.old_vscroll
= None
137 self
.old_hscroll
= None
138 self
.old_selection
= None
139 self
.old_contents
= None
140 self
.old_current_item
= None
141 self
.was_visible
= True
142 self
.expanded_items
= set()
144 self
.image_formats
= qtutils
.ImageFormats()
146 self
.process_selection_action
= qtutils
.add_action(
147 self
, cmds
.StageOrUnstage
.name(), self
._stage
_selection
,
148 hotkeys
.STAGE_SELECTION
)
150 self
.revert_unstaged_edits_action
= qtutils
.add_action(
151 self
, cmds
.RevertUnstagedEdits
.name(),
152 cmds
.run(cmds
.RevertUnstagedEdits
, context
), hotkeys
.REVERT
)
153 self
.revert_unstaged_edits_action
.setIcon(icons
.undo())
155 self
.launch_difftool_action
= qtutils
.add_action(
156 self
, cmds
.LaunchDifftool
.name(),
157 cmds
.run(cmds
.LaunchDifftool
, context
), hotkeys
.DIFF
)
158 self
.launch_difftool_action
.setIcon(icons
.diff())
160 self
.launch_editor_action
= actions
.launch_editor(
161 context
, self
, *hotkeys
.ACCEPT
)
163 if not utils
.is_win32():
164 self
.default_app_action
= common
.default_app_action(
165 context
, self
, self
.selected_group
)
167 self
.parent_dir_action
= common
.parent_dir_action(
168 context
, self
, self
.selected_group
)
170 self
.terminal_action
= common
.terminal_action(
171 context
, self
, self
.selected_group
)
173 self
.up_action
= qtutils
.add_action(
174 self
, N_('Move Up'), self
.move_up
,
175 hotkeys
.MOVE_UP
, hotkeys
.MOVE_UP_SECONDARY
)
177 self
.down_action
= qtutils
.add_action(
178 self
, N_('Move Down'), self
.move_down
,
179 hotkeys
.MOVE_DOWN
, hotkeys
.MOVE_DOWN_SECONDARY
)
181 self
.copy_path_action
= qtutils
.add_action(
182 self
, N_('Copy Path to Clipboard'),
183 partial(copy_path
, context
), hotkeys
.COPY
)
184 self
.copy_path_action
.setIcon(icons
.copy())
186 self
.copy_relpath_action
= qtutils
.add_action(
187 self
, N_('Copy Relative Path to Clipboard'),
188 partial(copy_relpath
, context
), hotkeys
.CUT
)
189 self
.copy_relpath_action
.setIcon(icons
.copy())
191 self
.copy_leading_path_action
= qtutils
.add_action(
192 self
, N_('Copy Leading Path to Clipboard'),
193 partial(copy_leading_path
, context
))
194 self
.copy_leading_path_action
.setIcon(icons
.copy())
196 self
.copy_basename_action
= qtutils
.add_action(
197 self
, N_('Copy Basename to Clipboard'),
198 partial(copy_basename
, context
))
199 self
.copy_basename_action
.setIcon(icons
.copy())
201 self
.copy_customize_action
= qtutils
.add_action(
202 self
, N_('Customize...'),
203 partial(customize_copy_actions
, context
, self
))
204 self
.copy_customize_action
.setIcon(icons
.configure())
206 self
.view_history_action
= qtutils
.add_action(
207 self
, N_('View History...'), partial(view_history
, context
),
210 self
.view_blame_action
= qtutils
.add_action(
211 self
, N_('Blame...'),
212 partial(view_blame
, context
), hotkeys
.BLAME
)
214 self
.annex_add_action
= qtutils
.add_action(
215 self
, N_('Add to Git Annex'), cmds
.run(cmds
.AnnexAdd
, context
))
217 self
.lfs_track_action
= qtutils
.add_action(
218 self
, N_('Add to Git LFS'), cmds
.run(cmds
.LFSTrack
, context
))
220 # MoveToTrash and Delete use the same shortcut.
221 # We will only bind one of them, depending on whether or not the
222 # MoveToTrash command is available. When available, the hotkey
223 # is bound to MoveToTrash, otherwise it is bound to Delete.
224 if cmds
.MoveToTrash
.AVAILABLE
:
225 self
.move_to_trash_action
= qtutils
.add_action(
226 self
, N_('Move files to trash'),
227 self
._trash
_untracked
_files
, hotkeys
.TRASH
)
228 self
.move_to_trash_action
.setIcon(icons
.discard())
229 delete_shortcut
= hotkeys
.DELETE_FILE
231 self
.move_to_trash_action
= None
232 delete_shortcut
= hotkeys
.DELETE_FILE_SECONDARY
234 self
.delete_untracked_files_action
= qtutils
.add_action(
235 self
, N_('Delete Files...'),
236 self
._delete
_untracked
_files
, delete_shortcut
)
237 self
.delete_untracked_files_action
.setIcon(icons
.discard())
239 about_to_update
= self
._about
_to
_update
240 self
.about_to_update
.connect(about_to_update
, type=Qt
.QueuedConnection
)
241 self
.updated
.connect(self
.refresh
, type=Qt
.QueuedConnection
)
242 self
.diff_text_changed
.connect(
243 self
._make
_current
_item
_visible
, type=Qt
.QueuedConnection
)
245 self
.m
= context
.model
246 self
.m
.add_observer(self
.m
.message_about_to_update
,
247 self
.about_to_update
.emit
)
248 self
.m
.add_observer(self
.m
.message_updated
, self
.updated
.emit
)
249 self
.m
.add_observer(self
.m
.message_diff_text_changed
,
250 self
.diff_text_changed
.emit
)
252 self
.itemSelectionChanged
.connect(self
.show_selection
)
253 self
.itemDoubleClicked
.connect(self
._double
_clicked
)
254 self
.itemCollapsed
.connect(lambda x
: self
._update
_column
_widths
())
255 self
.itemExpanded
.connect(lambda x
: self
._update
_column
_widths
())
257 def _make_current_item_visible(self
):
258 item
= self
.currentItem()
260 self
.scroll_to_item(item
)
262 def _add_toplevel_item(self
, txt
, icon
, hide
=False):
263 context
= self
.context
265 if prefs
.bold_headers(context
):
270 item
= QtWidgets
.QTreeWidgetItem(self
)
271 item
.setFont(0, font
)
273 item
.setIcon(0, icon
)
274 if prefs
.bold_headers(context
):
275 item
.setBackground(0, self
.palette().midlight())
279 def _restore_selection(self
):
280 if not self
.old_selection
or not self
.old_contents
:
282 old_c
= self
.old_contents
283 old_s
= self
.old_selection
284 new_c
= self
.contents()
286 def mkselect(lst
, widget_getter
):
287 def select(item
, current
=False):
288 idx
= lst
.index(item
)
289 item
= widget_getter(idx
)
291 self
.setCurrentItem(item
)
292 item
.setSelected(True)
295 select_staged
= mkselect(new_c
.staged
, self
._staged
_item
)
296 select_unmerged
= mkselect(new_c
.unmerged
, self
._unmerged
_item
)
297 select_modified
= mkselect(new_c
.modified
, self
._modified
_item
)
298 select_untracked
= mkselect(new_c
.untracked
, self
._untracked
_item
)
301 (set(new_c
.staged
), old_c
.staged
, set(old_s
.staged
),
304 (set(new_c
.unmerged
), old_c
.unmerged
, set(old_s
.unmerged
),
307 (set(new_c
.modified
), old_c
.modified
, set(old_s
.modified
),
310 (set(new_c
.untracked
), old_c
.untracked
, set(old_s
.untracked
),
314 # Restore the current item
315 if self
.old_current_item
:
316 category
, idx
= self
.old_current_item
317 if category
== self
.idx_header
:
318 item
= self
.invisibleRootItem().child(idx
)
320 self
.blockSignals(True)
321 self
.setCurrentItem(item
)
322 item
.setSelected(True)
323 self
.blockSignals(False)
324 self
.show_selection()
326 # Reselect the current item
327 selection_info
= saved_selection
[category
]
328 new
= selection_info
[0]
329 old
= selection_info
[1]
330 reselect
= selection_info
[3]
336 reselect(item
, current
=True)
339 # When reselecting we only care that the items are selected;
340 # we do not need to rerun the callbacks which were triggered
341 # above. Block signals to skip the callbacks.
342 self
.blockSignals(True)
343 for (new
, old
, sel
, reselect
) in saved_selection
:
346 reselect(item
, current
=False)
347 self
.blockSignals(False)
349 for (new
, old
, sel
, reselect
) in saved_selection
:
350 # When modified is staged, select the next modified item
351 # When unmerged is staged, select the next unmerged item
352 # When unstaging, select the next staged item
353 # When staging untracked files, select the next untracked item
354 if len(new
) >= len(old
):
355 # The list did not shrink so it is not one of these cases.
358 # The item still exists so ignore it
359 if item
in new
or item
not in old
:
361 # The item no longer exists in this list so search for
362 # its nearest neighbors and select them instead.
363 idx
= old
.index(item
)
364 for j
in itertools
.chain(old
[idx
+1:], reversed(old
[:idx
])):
366 reselect(j
, current
=True)
369 def _restore_scrollbars(self
):
370 vscroll
= self
.verticalScrollBar()
371 if vscroll
and self
.old_vscroll
is not None:
372 vscroll
.setValue(self
.old_vscroll
)
373 self
.old_vscroll
= None
375 hscroll
= self
.horizontalScrollBar()
376 if hscroll
and self
.old_hscroll
is not None:
377 hscroll
.setValue(self
.old_hscroll
)
378 self
.old_hscroll
= None
380 def _stage_selection(self
):
381 """Stage or unstage files according to the selection"""
382 context
= self
.context
383 selected_indexes
= self
.selected_indexes()
385 category
, idx
= selected_indexes
[0]
386 # A header item e.g. 'Staged', 'Modified', etc.
387 if category
== self
.idx_header
:
388 if idx
== self
.idx_staged
:
389 cmds
.do(cmds
.UnstageAll
, context
)
390 elif idx
== self
.idx_modified
:
391 cmds
.do(cmds
.StageModified
, context
)
392 elif idx
== self
.idx_untracked
:
393 cmds
.do(cmds
.StageUntracked
, context
)
395 pass # Do nothing for unmerged items, by design
397 cmds
.do(cmds
.StageOrUnstage
, context
)
399 def _staged_item(self
, itemidx
):
400 return self
._subtree
_item
(self
.idx_staged
, itemidx
)
402 def _modified_item(self
, itemidx
):
403 return self
._subtree
_item
(self
.idx_modified
, itemidx
)
405 def _unmerged_item(self
, itemidx
):
406 return self
._subtree
_item
(self
.idx_unmerged
, itemidx
)
408 def _untracked_item(self
, itemidx
):
409 return self
._subtree
_item
(self
.idx_untracked
, itemidx
)
411 def _unstaged_item(self
, itemidx
):
413 item
= self
.topLevelItem(self
.idx_modified
)
414 count
= item
.childCount()
416 return item
.child(itemidx
)
418 item
= self
.topLevelItem(self
.idx_unmerged
)
419 count
+= item
.childCount()
421 return item
.child(itemidx
)
423 item
= self
.topLevelItem(self
.idx_untracked
)
424 count
+= item
.childCount()
426 return item
.child(itemidx
)
430 def _subtree_item(self
, idx
, itemidx
):
431 parent
= self
.topLevelItem(idx
)
432 return parent
.child(itemidx
)
434 def _about_to_update(self
):
435 self
._save
_scrollbars
()
436 self
._save
_selection
()
438 def _save_scrollbars(self
):
439 vscroll
= self
.verticalScrollBar()
441 self
.old_vscroll
= get(vscroll
)
443 hscroll
= self
.horizontalScrollBar()
445 self
.old_hscroll
= get(hscroll
)
447 def current_item(self
):
448 s
= self
.selected_indexes()
451 current
= self
.currentItem()
454 idx
= self
.indexFromItem(current
, 0)
455 if idx
.parent().isValid():
456 parent_idx
= idx
.parent()
457 entry
= (parent_idx
.row(), idx
.row())
459 entry
= (self
.idx_header
, idx
.row())
462 def _save_selection(self
):
463 self
.old_contents
= self
.contents()
464 self
.old_selection
= self
.selection()
465 self
.old_current_item
= self
.current_item()
468 self
._set
_staged
(self
.m
.staged
)
469 self
._set
_modified
(self
.m
.modified
)
470 self
._set
_unmerged
(self
.m
.unmerged
)
471 self
._set
_untracked
(self
.m
.untracked
)
472 self
._update
_column
_widths
()
473 self
._update
_actions
()
474 self
._restore
_selection
()
475 self
._restore
_scrollbars
()
477 def _update_actions(self
, selected
=None):
479 selected
= self
.selection_model
.selection()
480 can_revert_edits
= bool(selected
.staged
or selected
.modified
)
481 self
.revert_unstaged_edits_action
.setEnabled(can_revert_edits
)
483 def _set_staged(self
, items
):
484 """Adds items to the 'Staged' subtree."""
485 self
._set
_subtree
(items
, self
.idx_staged
, staged
=True,
486 deleted_set
=self
.m
.staged_deleted
)
488 def _set_modified(self
, items
):
489 """Adds items to the 'Modified' subtree."""
490 self
._set
_subtree
(items
, self
.idx_modified
,
491 deleted_set
=self
.m
.unstaged_deleted
)
493 def _set_unmerged(self
, items
):
494 """Adds items to the 'Unmerged' subtree."""
495 deleted_set
= set([path
for path
in items
if not core
.exists(path
)])
496 self
._set
_subtree
(items
, self
.idx_unmerged
,
497 deleted_set
=deleted_set
)
499 def _set_untracked(self
, items
):
500 """Adds items to the 'Untracked' subtree."""
501 self
._set
_subtree
(items
, self
.idx_untracked
, untracked
=True)
503 def _set_subtree(self
, items
, idx
,
507 """Add a list of items to a treewidget item."""
508 self
.blockSignals(True)
509 parent
= self
.topLevelItem(idx
)
510 hide
= not bool(items
)
511 parent
.setHidden(hide
)
513 # sip v4.14.7 and below leak memory in parent.takeChildren()
514 # so we use this backwards-compatible construct instead
515 while parent
.takeChild(0) is not None:
519 deleted
= (deleted_set
is not None and item
in deleted_set
)
520 treeitem
= qtutils
.create_treeitem(item
,
524 parent
.addChild(treeitem
)
525 self
._expand
_items
(idx
, items
)
526 self
.blockSignals(False)
528 def _update_column_widths(self
):
529 self
.resizeColumnToContents(0)
531 def _expand_items(self
, idx
, items
):
532 """Expand the top-level category "folder" once and only once."""
533 # Don't do this if items is empty; this makes it so that we
534 # don't add the top-level index into the expanded_items set
535 # until an item appears in a particular category.
538 # Only run this once; we don't want to re-expand items that
539 # we've clicked on to re-collapse on updated().
540 if idx
in self
.expanded_items
:
542 self
.expanded_items
.add(idx
)
543 item
= self
.topLevelItem(idx
)
545 self
.expandItem(item
)
547 def contextMenuEvent(self
, event
):
548 """Create context menus for the repo status tree."""
549 menu
= self
._create
_context
_menu
()
550 menu
.exec_(self
.mapToGlobal(event
.pos()))
552 def _create_context_menu(self
):
553 """Set up the status menu for the repo status tree."""
555 menu
= qtutils
.create_menu('Status', self
)
556 selected_indexes
= self
.selected_indexes()
558 category
, idx
= selected_indexes
[0]
559 # A header item e.g. 'Staged', 'Modified', etc.
560 if category
== self
.idx_header
:
561 return self
._create
_header
_context
_menu
(menu
, idx
)
564 self
._create
_staged
_context
_menu
(menu
, s
)
566 self
._create
_unmerged
_context
_menu
(menu
, s
)
568 self
._create
_unstaged
_context
_menu
(menu
, s
)
570 if not utils
.is_win32():
571 if not menu
.isEmpty():
573 if not self
.selection_model
.is_empty():
574 menu
.addAction(self
.default_app_action
)
575 menu
.addAction(self
.parent_dir_action
)
576 menu
.addAction(self
.terminal_action
)
578 self
._add
_copy
_actions
(menu
)
582 def _add_copy_actions(self
, menu
):
583 """Add the "Copy" sub-menu"""
584 enabled
= self
.selection_model
.filename() is not None
585 self
.copy_path_action
.setEnabled(enabled
)
586 self
.copy_relpath_action
.setEnabled(enabled
)
587 self
.copy_leading_path_action
.setEnabled(enabled
)
588 self
.copy_basename_action
.setEnabled(enabled
)
589 copy_icon
= icons
.copy()
592 copy_menu
= QtWidgets
.QMenu(N_('Copy...'), menu
)
593 menu
.addMenu(copy_menu
)
595 copy_menu
.setIcon(copy_icon
)
596 copy_menu
.addAction(self
.copy_path_action
)
597 copy_menu
.addAction(self
.copy_relpath_action
)
598 copy_menu
.addAction(self
.copy_leading_path_action
)
599 copy_menu
.addAction(self
.copy_basename_action
)
601 current_settings
= settings
.Settings()
602 current_settings
.load()
604 copy_formats
= current_settings
.copy_formats
606 copy_menu
.addSeparator()
608 context
= self
.context
609 for entry
in copy_formats
:
610 name
= entry
.get('name', '')
611 fmt
= entry
.get('format', '')
613 action
= copy_menu
.addAction(
614 name
, partial(copy_format
, context
, fmt
))
615 action
.setIcon(copy_icon
)
616 action
.setEnabled(enabled
)
618 copy_menu
.addSeparator()
619 copy_menu
.addAction(self
.copy_customize_action
)
621 def _create_header_context_menu(self
, menu
, idx
):
622 context
= self
.context
623 if idx
== self
.idx_staged
:
624 menu
.addAction(icons
.remove(), N_('Unstage All'),
625 cmds
.run(cmds
.UnstageAll
, context
))
626 elif idx
== self
.idx_unmerged
:
627 action
= menu
.addAction(icons
.add(), cmds
.StageUnmerged
.name(),
628 cmds
.run(cmds
.StageUnmerged
, context
))
629 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
630 elif idx
== self
.idx_modified
:
631 action
= menu
.addAction(icons
.add(), cmds
.StageModified
.name(),
632 cmds
.run(cmds
.StageModified
, context
))
633 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
634 elif idx
== self
.idx_untracked
:
635 action
= menu
.addAction(icons
.add(), cmds
.StageUntracked
.name(),
636 cmds
.run(cmds
.StageUntracked
, context
))
637 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
640 def _create_staged_context_menu(self
, menu
, s
):
641 if s
.staged
[0] in self
.m
.submodules
:
642 return self
._create
_staged
_submodule
_context
_menu
(menu
, s
)
644 context
= self
.context
645 if self
.m
.unstageable():
646 action
= menu
.addAction(
647 icons
.remove(), N_('Unstage Selected'),
648 cmds
.run(cmds
.Unstage
, context
, self
.staged()))
649 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
651 menu
.addAction(self
.launch_editor_action
)
653 # Do all of the selected items exist?
654 all_exist
= all(i
not in self
.m
.staged_deleted
and core
.exists(i
)
655 for i
in self
.staged())
658 menu
.addAction(self
.launch_difftool_action
)
660 if self
.m
.undoable():
661 menu
.addAction(self
.revert_unstaged_edits_action
)
663 menu
.addAction(self
.view_history_action
)
664 menu
.addAction(self
.view_blame_action
)
667 def _create_staged_submodule_context_menu(self
, menu
, s
):
668 context
= self
.context
669 path
= core
.abspath(s
.staged
[0])
670 menu
.addAction(icons
.cola(), N_('Launch git-cola'),
671 cmds
.run(cmds
.OpenRepo
, context
, path
))
672 action
= menu
.addAction(
673 icons
.remove(), N_('Unstage Selected'),
674 cmds
.run(cmds
.Unstage
, context
, self
.staged()))
675 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
677 menu
.addAction(self
.view_history_action
)
680 def _create_unmerged_context_menu(self
, menu
, _s
):
681 context
= self
.context
682 menu
.addAction(self
.launch_difftool_action
)
684 action
= menu
.addAction(
685 icons
.add(), N_('Stage Selected'),
686 cmds
.run(cmds
.Stage
, context
, self
.unstaged()))
687 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
689 menu
.addAction(self
.launch_editor_action
)
690 menu
.addAction(self
.view_history_action
)
691 menu
.addAction(self
.view_blame_action
)
694 def _create_unstaged_context_menu(self
, menu
, s
):
695 context
= self
.context
696 modified_submodule
= (s
.modified
and
697 s
.modified
[0] in self
.m
.submodules
)
698 if modified_submodule
:
699 return self
._create
_modified
_submodule
_context
_menu
(menu
, s
)
701 if self
.m
.stageable():
702 action
= menu
.addAction(
703 icons
.add(), N_('Stage Selected'),
704 cmds
.run(cmds
.Stage
, context
, self
.unstaged()))
705 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
707 if not self
.selection_model
.is_empty():
708 menu
.addAction(self
.launch_editor_action
)
710 # Do all of the selected items exist?
711 all_exist
= all(i
not in self
.m
.unstaged_deleted
and core
.exists(i
)
712 for i
in self
.staged())
714 if all_exist
and s
.modified
and self
.m
.stageable():
715 menu
.addAction(self
.launch_difftool_action
)
717 if s
.modified
and self
.m
.stageable():
718 if self
.m
.undoable():
720 menu
.addAction(self
.revert_unstaged_edits_action
)
722 if all_exist
and s
.untracked
:
723 # Git Annex / Git LFS
725 lfs
= core
.find_executable('git-lfs')
729 menu
.addAction(self
.annex_add_action
)
731 menu
.addAction(self
.lfs_track_action
)
734 if self
.move_to_trash_action
is not None:
735 menu
.addAction(self
.move_to_trash_action
)
736 menu
.addAction(self
.delete_untracked_files_action
)
738 menu
.addAction(icons
.edit(), N_('Add to .gitignore'),
739 partial(gitignore
.gitignore_view
, self
.context
))
741 if not self
.selection_model
.is_empty():
742 menu
.addAction(self
.view_history_action
)
743 menu
.addAction(self
.view_blame_action
)
746 def _create_modified_submodule_context_menu(self
, menu
, s
):
747 context
= self
.context
748 path
= core
.abspath(s
.modified
[0])
749 menu
.addAction(icons
.cola(), N_('Launch git-cola'),
750 cmds
.run(cmds
.OpenRepo
, context
, path
))
752 if self
.m
.stageable():
754 action
= menu
.addAction(
755 icons
.add(), N_('Stage Selected'),
756 cmds
.run(cmds
.Stage
, context
, self
.unstaged()))
757 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
759 menu
.addAction(self
.view_history_action
)
762 def _delete_untracked_files(self
):
763 cmds
.do(cmds
.Delete
, self
.context
, self
.untracked())
765 def _trash_untracked_files(self
):
766 cmds
.do(cmds
.MoveToTrash
, self
.context
, self
.untracked())
768 def selected_path(self
):
769 s
= self
.single_selection()
770 return s
.staged
or s
.unmerged
or s
.modified
or s
.untracked
or None
772 def single_selection(self
):
773 """Scan across staged, modified, etc. and return a single item."""
783 unmerged
= s
.unmerged
[0]
785 modified
= s
.modified
[0]
787 untracked
= s
.untracked
[0]
789 return selection
.State(staged
, unmerged
, modified
, untracked
)
791 def selected_indexes(self
):
792 """Returns a list of (category, row) representing the tree selection."""
793 selected
= self
.selectedIndexes()
796 if idx
.parent().isValid():
797 parent_idx
= idx
.parent()
798 entry
= (parent_idx
.row(), idx
.row())
800 entry
= (self
.idx_header
, idx
.row())
805 """Return the current selection in the repo status tree."""
806 return selection
.State(self
.staged(), self
.unmerged(),
807 self
.modified(), self
.untracked())
810 return selection
.State(self
.m
.staged
, self
.m
.unmerged
,
811 self
.m
.modified
, self
.m
.untracked
)
815 return c
.staged
+ c
.unmerged
+ c
.modified
+ c
.untracked
817 def selected_group(self
):
818 """A list of selected files in various states of being"""
819 return selection
.pick(self
.selection())
821 def selected_idx(self
):
823 s
= self
.single_selection()
825 for content
, sel
in zip(c
, s
):
829 return offset
+ content
.index(sel
)
830 offset
+= len(content
)
833 def select_by_index(self
, idx
):
836 (c
.staged
, self
.idx_staged
),
837 (c
.unmerged
, self
.idx_unmerged
),
838 (c
.modified
, self
.idx_modified
),
839 (c
.untracked
, self
.idx_untracked
),
841 for content
, toplevel_idx
in to_try
:
844 if idx
< len(content
):
845 parent
= self
.topLevelItem(toplevel_idx
)
846 item
= parent
.child(idx
)
848 self
.select_item(item
)
852 def scroll_to_item(self
, item
):
853 # First, scroll to the item, but keep the original hscroll
855 hscrollbar
= self
.horizontalScrollBar()
857 hscroll
= get(hscrollbar
)
858 self
.scrollToItem(item
)
859 if hscroll
is not None:
860 hscrollbar
.setValue(hscroll
)
862 def select_item(self
, item
):
863 self
.scroll_to_item(item
)
864 self
.setCurrentItem(item
)
865 item
.setSelected(True)
868 return self
._subtree
_selection
(self
.idx_staged
, self
.m
.staged
)
871 return self
.unmerged() + self
.modified() + self
.untracked()
874 return self
._subtree
_selection
(self
.idx_modified
, self
.m
.modified
)
877 return self
._subtree
_selection
(self
.idx_unmerged
, self
.m
.unmerged
)
880 return self
._subtree
_selection
(self
.idx_untracked
, self
.m
.untracked
)
882 def staged_items(self
):
883 return self
._subtree
_selection
_items
(self
.idx_staged
)
885 def unstaged_items(self
):
886 return (self
.unmerged_items() + self
.modified_items() +
887 self
.untracked_items())
889 def modified_items(self
):
890 return self
._subtree
_selection
_items
(self
.idx_modified
)
892 def unmerged_items(self
):
893 return self
._subtree
_selection
_items
(self
.idx_unmerged
)
895 def untracked_items(self
):
896 return self
._subtree
_selection
_items
(self
.idx_untracked
)
898 def _subtree_selection(self
, idx
, items
):
899 item
= self
.topLevelItem(idx
)
900 return qtutils
.tree_selection(item
, items
)
902 def _subtree_selection_items(self
, idx
):
903 item
= self
.topLevelItem(idx
)
904 return qtutils
.tree_selection_items(item
)
906 def _double_clicked(self
, _item
, _idx
):
907 """Called when an item is double-clicked in the repo status tree."""
908 cmds
.do(cmds
.StageOrUnstage
, self
.context
)
910 def show_selection(self
):
911 """Show the selected item."""
912 context
= self
.context
913 self
.scroll_to_item(self
.currentItem())
914 # Sync the selection model
915 selected
= self
.selection()
916 selection_model
= self
.selection_model
917 selection_model
.set_selection(selected
)
918 self
._update
_actions
(selected
=selected
)
920 selected_indexes
= self
.selected_indexes()
921 if not selected_indexes
:
922 if self
.m
.amending():
923 cmds
.do(cmds
.SetDiffText
, context
, '')
925 cmds
.do(cmds
.ResetMode
, context
)
928 # A header item e.g. 'Staged', 'Modified', etc.
929 category
, idx
= selected_indexes
[0]
930 header
= category
== self
.idx_header
933 self
.idx_staged
: cmds
.DiffStagedSummary
,
934 self
.idx_modified
: cmds
.Diffstat
,
935 # TODO implement UnmergedSummary
936 # self.idx_unmerged: cmds.UnmergedSummary,
937 self
.idx_untracked
: cmds
.UntrackedSummary
,
938 }.get(idx
, cmds
.Diffstat
)
939 cmds
.do(cls
, context
)
942 staged
= category
== self
.idx_staged
943 modified
= category
== self
.idx_modified
944 unmerged
= category
== self
.idx_unmerged
945 untracked
= category
== self
.idx_untracked
948 item
= self
.staged_items()[0]
950 item
= self
.unmerged_items()[0]
952 item
= self
.modified_items()[0]
954 item
= self
.unstaged_items()[0]
956 item
= None # this shouldn't happen
957 assert item
is not None
960 deleted
= item
.deleted
961 image
= self
.image_formats
.ok(path
)
963 # Images are diffed differently
965 cmds
.do(cmds
.DiffImage
, context
, path
, deleted
,
966 staged
, modified
, unmerged
, untracked
)
968 cmds
.do(cmds
.DiffStaged
, context
, path
, deleted
=deleted
)
970 cmds
.do(cmds
.Diff
, context
, path
, deleted
=deleted
)
972 cmds
.do(cmds
.Diff
, context
, path
)
974 cmds
.do(cmds
.ShowUntracked
, context
, path
)
976 def select_header(self
):
977 """Select an active header, which triggers a diffstat"""
978 for idx
in (self
.idx_staged
, self
.idx_unmerged
,
979 self
.idx_modified
, self
.idx_untracked
):
980 item
= self
.topLevelItem(idx
)
981 if item
.childCount() > 0:
982 self
.clearSelection()
983 self
.setCurrentItem(item
)
987 idx
= self
.selected_idx()
988 all_files
= self
.all_files()
990 selected_indexes
= self
.selected_indexes()
992 category
, toplevel_idx
= selected_indexes
[0]
993 if category
== self
.idx_header
:
994 item
= self
.itemAbove(self
.topLevelItem(toplevel_idx
))
996 self
.select_item(item
)
999 self
.select_by_index(len(all_files
) - 1)
1002 self
.select_by_index(idx
- 1)
1004 self
.select_by_index(len(all_files
) - 1)
1006 def move_down(self
):
1007 idx
= self
.selected_idx()
1008 all_files
= self
.all_files()
1010 selected_indexes
= self
.selected_indexes()
1011 if selected_indexes
:
1012 category
, toplevel_idx
= selected_indexes
[0]
1013 if category
== self
.idx_header
:
1014 item
= self
.itemBelow(self
.topLevelItem(toplevel_idx
))
1015 if item
is not None:
1016 self
.select_item(item
)
1019 self
.select_by_index(0)
1021 if idx
+ 1 < len(all_files
):
1022 self
.select_by_index(idx
+ 1)
1024 self
.select_by_index(0)
1026 def mimeData(self
, items
):
1027 """Return a list of absolute-path URLs"""
1028 context
= self
.context
1029 paths
= qtutils
.paths_from_items(items
, item_filter
=_item_filter
)
1030 return qtutils
.mimedata_from_paths(context
, paths
)
1032 # pylint: disable=no-self-use
1033 def mimeTypes(self
):
1034 return qtutils
.path_mimetypes()
1037 def _item_filter(item
):
1038 return not item
.deleted
and core
.exists(item
.path
)
1041 def view_blame(context
):
1042 """Signal that we should view blame for paths."""
1043 cmds
.do(cmds
.BlamePaths
, context
, context
.selection
.union())
1046 def view_history(context
):
1047 """Signal that we should view history for paths."""
1048 cmds
.do(cmds
.VisualizePaths
, context
, context
.selection
.union())
1051 def copy_path(context
, absolute
=True):
1052 """Copy a selected path to the clipboard"""
1053 filename
= context
.selection
.filename()
1054 qtutils
.copy_path(filename
, absolute
=absolute
)
1057 def copy_relpath(context
):
1058 """Copy a selected relative path to the clipboard"""
1059 copy_path(context
, absolute
=False)
1062 def copy_basename(context
):
1063 filename
= os
.path
.basename(context
.selection
.filename())
1064 basename
, _
= os
.path
.splitext(filename
)
1065 qtutils
.copy_path(basename
, absolute
=False)
1068 def copy_leading_path(context
):
1069 """Copy the selected leading path to the clipboard"""
1070 filename
= context
.selection
.filename()
1071 dirname
= os
.path
.dirname(filename
)
1072 qtutils
.copy_path(dirname
, absolute
=False)
1075 def copy_format(context
, fmt
):
1077 values
['path'] = path
= context
.selection
.filename()
1078 values
['abspath'] = abspath
= os
.path
.abspath(path
)
1079 values
['absdirname'] = os
.path
.dirname(abspath
)
1080 values
['dirname'] = os
.path
.dirname(path
)
1081 values
['filename'] = os
.path
.basename(path
)
1082 values
['basename'], values
['ext'] = (
1083 os
.path
.splitext(os
.path
.basename(path
)))
1084 qtutils
.set_clipboard(fmt
% values
)
1087 def show_help(context
):
1089 Format String Variables
1090 -----------------------
1091 %(path)s = relative file path
1092 %(abspath)s = absolute file path
1093 %(dirname)s = relative directory path
1094 %(absdirname)s = absolute directory path
1095 %(filename)s = file basename
1096 %(basename)s = file basename without extension
1097 %(ext)s = file extension
1099 title
= N_('Help - Custom Copy Actions')
1100 return text
.text_dialog(context
, help_text
, title
)
1103 class StatusFilterWidget(QtWidgets
.QWidget
):
1105 def __init__(self
, context
, parent
=None):
1106 QtWidgets
.QWidget
.__init
__(self
, parent
)
1107 self
.main_model
= context
.model
1109 hint
= N_('Filter paths...')
1110 self
.text
= completion
.GitStatusFilterLineEdit(
1111 context
, hint
=hint
, parent
=self
)
1112 self
.text
.setToolTip(hint
)
1113 self
.setFocusProxy(self
.text
)
1116 self
.main_layout
= qtutils
.hbox(defs
.no_margin
, defs
.spacing
, self
.text
)
1117 self
.setLayout(self
.main_layout
)
1120 widget
.changed
.connect(self
.apply_filter
)
1121 widget
.cleared
.connect(self
.apply_filter
)
1122 widget
.enter
.connect(self
.apply_filter
)
1123 widget
.editingFinished
.connect(self
.apply_filter
)
1125 def apply_filter(self
):
1126 value
= get(self
.text
)
1127 if value
== self
._filter
:
1129 self
._filter
= value
1130 paths
= utils
.shell_split(value
)
1131 self
.main_model
.update_path_filter(paths
)
1134 def customize_copy_actions(context
, parent
):
1135 """Customize copy actions"""
1136 dialog
= CustomizeCopyActions(context
, parent
)
1141 class CustomizeCopyActions(standard
.Dialog
):
1143 def __init__(self
, context
, parent
):
1144 standard
.Dialog
.__init
__(self
, parent
=parent
)
1145 self
.setWindowTitle(N_('Custom Copy Actions'))
1147 self
.table
= QtWidgets
.QTableWidget(self
)
1148 self
.table
.setColumnCount(2)
1149 self
.table
.setHorizontalHeaderLabels([
1151 N_('Format String'),
1153 self
.table
.setSortingEnabled(False)
1154 self
.table
.verticalHeader().hide()
1155 self
.table
.horizontalHeader().setStretchLastSection(True)
1157 self
.add_button
= qtutils
.create_button(N_('Add'))
1158 self
.remove_button
= qtutils
.create_button(N_('Remove'))
1159 self
.remove_button
.setEnabled(False)
1160 self
.show_help_button
= qtutils
.create_button(N_('Show Help'))
1161 self
.show_help_button
.setShortcut(hotkeys
.QUESTION
)
1163 self
.close_button
= qtutils
.close_button()
1164 self
.save_button
= qtutils
.ok_button(N_('Save'))
1166 self
.buttons
= qtutils
.hbox(defs
.no_margin
, defs
.button_spacing
,
1169 self
.show_help_button
,
1174 layout
= qtutils
.vbox(defs
.margin
, defs
.spacing
,
1175 self
.table
, self
.buttons
)
1176 self
.setLayout(layout
)
1178 qtutils
.connect_button(self
.add_button
, self
.add
)
1179 qtutils
.connect_button(self
.remove_button
, self
.remove
)
1180 qtutils
.connect_button(
1181 self
.show_help_button
, partial(show_help
, context
))
1182 qtutils
.connect_button(self
.close_button
, self
.reject
)
1183 qtutils
.connect_button(self
.save_button
, self
.save
)
1184 qtutils
.add_close_action(self
)
1185 self
.table
.itemSelectionChanged
.connect(self
.table_selection_changed
)
1187 self
.init_size(parent
=parent
)
1189 self
.settings
= settings
.Settings()
1190 QtCore
.QTimer
.singleShot(0, self
.reload_settings
)
1192 def reload_settings(self
):
1193 # Called once after the GUI is initialized
1194 self
.settings
.load()
1196 for entry
in self
.settings
.copy_formats
:
1197 name_string
= entry
.get('name', '')
1198 format_string
= entry
.get('format', '')
1199 if name_string
and format_string
:
1200 name
= QtWidgets
.QTableWidgetItem(name_string
)
1201 fmt
= QtWidgets
.QTableWidgetItem(format_string
)
1202 rows
= table
.rowCount()
1203 table
.setRowCount(rows
+ 1)
1204 table
.setItem(rows
, 0, name
)
1205 table
.setItem(rows
, 1, fmt
)
1207 def export_state(self
):
1208 state
= super(CustomizeCopyActions
, self
).export_state()
1209 standard
.export_header_columns(self
.table
, state
)
1212 def apply_state(self
, state
):
1213 result
= super(CustomizeCopyActions
, self
).apply_state(state
)
1214 standard
.apply_header_columns(self
.table
, state
)
1218 self
.table
.setFocus(True)
1219 rows
= self
.table
.rowCount()
1220 self
.table
.setRowCount(rows
+ 1)
1222 name
= QtWidgets
.QTableWidgetItem(N_('Name'))
1223 fmt
= QtWidgets
.QTableWidgetItem(r
'%(path)s')
1224 self
.table
.setItem(rows
, 0, name
)
1225 self
.table
.setItem(rows
, 1, fmt
)
1227 self
.table
.setCurrentCell(rows
, 0)
1228 self
.table
.editItem(name
)
1231 """Remove selected items"""
1232 # Gather a unique set of rows and remove them in reverse order
1234 items
= self
.table
.selectedItems()
1236 rows
.add(self
.table
.row(item
))
1238 for row
in reversed(sorted(rows
)):
1239 self
.table
.removeRow(row
)
1243 for row
in range(self
.table
.rowCount()):
1244 name
= self
.table
.item(row
, 0)
1245 fmt
= self
.table
.item(row
, 1)
1248 'name': name
.text(),
1249 'format': fmt
.text(),
1251 copy_formats
.append(entry
)
1253 while self
.settings
.copy_formats
:
1254 self
.settings
.copy_formats
.pop()
1256 self
.settings
.copy_formats
.extend(copy_formats
)
1257 self
.settings
.save()
1261 def table_selection_changed(self
):
1262 items
= self
.table
.selectedItems()
1263 self
.remove_button
.setEnabled(bool(items
))