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(1, self
.restore_size
)
75 def restore_size(self
):
76 self
.setMaximumWidth(2 ** 13)
79 self
.tree
.show_selection()
81 def set_filter(self
, txt
):
82 self
.filter_widget
.setVisible(True)
83 self
.filter_widget
.text
.set_value(txt
)
84 self
.filter_widget
.apply_filter()
92 def select_header(self
):
93 self
.tree
.select_header()
96 class StatusTreeWidget(QtWidgets
.QTreeWidget
):
98 about_to_update
= Signal()
100 diff_text_changed
= Signal()
110 # Read-only access to the mode state
111 mode
= property(lambda self
: self
.m
.mode
)
113 def __init__(self
, context
, parent
=None):
114 QtWidgets
.QTreeWidget
.__init
__(self
, parent
)
115 self
.context
= context
116 self
.selection_model
= context
.selection
118 self
.setSelectionMode(QtWidgets
.QAbstractItemView
.ExtendedSelection
)
119 self
.headerItem().setHidden(True)
120 self
.setAllColumnsShowFocus(True)
121 self
.setSortingEnabled(False)
122 self
.setUniformRowHeights(True)
123 self
.setAnimated(True)
124 self
.setRootIsDecorated(False)
125 self
.setIndentation(0)
126 self
.setDragEnabled(True)
127 self
.setAutoScroll(False)
130 compare
= icons
.compare()
131 question
= icons
.question()
132 self
.add_toplevel_item(N_('Staged'), ok
, hide
=True)
133 self
.add_toplevel_item(N_('Unmerged'), compare
, hide
=True)
134 self
.add_toplevel_item(N_('Modified'), compare
, hide
=True)
135 self
.add_toplevel_item(N_('Untracked'), question
, hide
=True)
137 # Used to restore the selection
138 self
.old_vscroll
= None
139 self
.old_hscroll
= None
140 self
.old_selection
= None
141 self
.old_contents
= None
142 self
.old_current_item
= None
143 self
.was_visible
= True
144 self
.expanded_items
= set()
146 self
.image_formats
= qtutils
.ImageFormats()
148 self
.process_selection_action
= qtutils
.add_action(
149 self
, cmds
.StageOrUnstage
.name(), self
.stage_selection
,
150 hotkeys
.STAGE_SELECTION
)
152 self
.revert_unstaged_edits_action
= qtutils
.add_action(
153 self
, cmds
.RevertUnstagedEdits
.name(),
154 cmds
.run(cmds
.RevertUnstagedEdits
, context
), hotkeys
.REVERT
)
155 self
.revert_unstaged_edits_action
.setIcon(icons
.undo())
157 self
.launch_difftool_action
= qtutils
.add_action(
158 self
, cmds
.LaunchDifftool
.name(),
159 cmds
.run(cmds
.LaunchDifftool
, context
), hotkeys
.DIFF
)
160 self
.launch_difftool_action
.setIcon(icons
.diff())
162 self
.launch_editor_action
= actions
.launch_editor(
163 context
, self
, *hotkeys
.ACCEPT
)
165 if not utils
.is_win32():
166 self
.default_app_action
= common
.default_app_action(
167 context
, self
, self
.selected_group
)
169 self
.parent_dir_action
= common
.parent_dir_action(
170 context
, self
, self
.selected_group
)
172 self
.terminal_action
= common
.terminal_action(
173 context
, self
, self
.selected_group
)
175 self
.up_action
= qtutils
.add_action(
176 self
, N_('Move Up'), self
.move_up
,
177 hotkeys
.MOVE_UP
, hotkeys
.MOVE_UP_SECONDARY
)
179 self
.down_action
= qtutils
.add_action(
180 self
, N_('Move Down'), self
.move_down
,
181 hotkeys
.MOVE_DOWN
, hotkeys
.MOVE_DOWN_SECONDARY
)
183 self
.copy_path_action
= qtutils
.add_action(
184 self
, N_('Copy Path to Clipboard'),
185 partial(copy_path
, context
), hotkeys
.COPY
)
186 self
.copy_path_action
.setIcon(icons
.copy())
188 self
.copy_relpath_action
= qtutils
.add_action(
189 self
, N_('Copy Relative Path to Clipboard'),
190 partial(copy_relpath
, context
), hotkeys
.CUT
)
191 self
.copy_relpath_action
.setIcon(icons
.copy())
193 self
.copy_leading_path_action
= qtutils
.add_action(
194 self
, N_('Copy Leading Path to Clipboard'),
195 partial(copy_leading_path
, context
))
196 self
.copy_leading_path_action
.setIcon(icons
.copy())
198 self
.copy_basename_action
= qtutils
.add_action(
199 self
, N_('Copy Basename to Clipboard'),
200 partial(copy_basename
, context
))
201 self
.copy_basename_action
.setIcon(icons
.copy())
203 self
.copy_customize_action
= qtutils
.add_action(
204 self
, N_('Customize...'),
205 partial(customize_copy_actions
, context
, self
))
206 self
.copy_customize_action
.setIcon(icons
.configure())
208 self
.view_history_action
= qtutils
.add_action(
209 self
, N_('View History...'), partial(view_history
, context
),
212 self
.view_blame_action
= qtutils
.add_action(
213 self
, N_('Blame...'),
214 partial(view_blame
, context
), hotkeys
.BLAME
)
216 self
.annex_add_action
= qtutils
.add_action(
217 self
, N_('Add to Git Annex'), cmds
.run(cmds
.AnnexAdd
, context
))
219 self
.lfs_track_action
= qtutils
.add_action(
220 self
, N_('Add to Git LFS'), cmds
.run(cmds
.LFSTrack
, context
))
222 # MoveToTrash and Delete use the same shortcut.
223 # We will only bind one of them, depending on whether or not the
224 # MoveToTrash command is available. When available, the hotkey
225 # is bound to MoveToTrash, otherwise it is bound to Delete.
226 if cmds
.MoveToTrash
.AVAILABLE
:
227 self
.move_to_trash_action
= qtutils
.add_action(
228 self
, N_('Move files to trash'),
229 self
._trash
_untracked
_files
, hotkeys
.TRASH
)
230 self
.move_to_trash_action
.setIcon(icons
.discard())
231 delete_shortcut
= hotkeys
.DELETE_FILE
233 self
.move_to_trash_action
= None
234 delete_shortcut
= hotkeys
.DELETE_FILE_SECONDARY
236 self
.delete_untracked_files_action
= qtutils
.add_action(
237 self
, N_('Delete Files...'),
238 self
._delete
_untracked
_files
, delete_shortcut
)
239 self
.delete_untracked_files_action
.setIcon(icons
.discard())
241 about_to_update
= self
._about
_to
_update
242 self
.about_to_update
.connect(about_to_update
, type=Qt
.QueuedConnection
)
243 self
.updated
.connect(self
.refresh
, type=Qt
.QueuedConnection
)
244 self
.diff_text_changed
.connect(
245 self
._make
_current
_item
_visible
, type=Qt
.QueuedConnection
)
247 self
.m
= context
.model
248 self
.m
.add_observer(self
.m
.message_about_to_update
,
249 self
.about_to_update
.emit
)
250 self
.m
.add_observer(self
.m
.message_updated
, self
.updated
.emit
)
251 self
.m
.add_observer(self
.m
.message_diff_text_changed
,
252 self
.diff_text_changed
.emit
)
254 self
.itemSelectionChanged
.connect(self
.show_selection
)
255 self
.itemDoubleClicked
.connect(self
._double
_clicked
)
256 self
.itemCollapsed
.connect(lambda x
: self
._update
_column
_widths
())
257 self
.itemExpanded
.connect(lambda x
: self
._update
_column
_widths
())
259 def _make_current_item_visible(self
):
260 item
= self
.currentItem()
262 self
.scroll_to_item(item
)
264 def add_toplevel_item(self
, txt
, icon
, hide
=False):
265 context
= self
.context
267 if prefs
.bold_headers(context
):
272 item
= QtWidgets
.QTreeWidgetItem(self
)
273 item
.setFont(0, font
)
275 item
.setIcon(0, icon
)
276 if prefs
.bold_headers(context
):
277 item
.setBackground(0, self
.palette().midlight())
281 def _restore_selection(self
):
282 if not self
.old_selection
or not self
.old_contents
:
284 old_c
= self
.old_contents
285 old_s
= self
.old_selection
286 new_c
= self
.contents()
288 def mkselect(lst
, widget_getter
):
289 def select(item
, current
=False):
290 idx
= lst
.index(item
)
291 item
= widget_getter(idx
)
293 self
.setCurrentItem(item
)
294 item
.setSelected(True)
297 select_staged
= mkselect(new_c
.staged
, self
.staged_item
)
298 select_unmerged
= mkselect(new_c
.unmerged
, self
.unmerged_item
)
299 select_modified
= mkselect(new_c
.modified
, self
.modified_item
)
300 select_untracked
= mkselect(new_c
.untracked
, self
.untracked_item
)
303 (set(new_c
.staged
), old_c
.staged
, set(old_s
.staged
),
306 (set(new_c
.unmerged
), old_c
.unmerged
, set(old_s
.unmerged
),
309 (set(new_c
.modified
), old_c
.modified
, set(old_s
.modified
),
312 (set(new_c
.untracked
), old_c
.untracked
, set(old_s
.untracked
),
316 # Restore the current item
317 if self
.old_current_item
:
318 category
, idx
= self
.old_current_item
319 if category
== self
.idx_header
:
320 item
= self
.invisibleRootItem().child(idx
)
322 self
.blockSignals(True)
323 self
.setCurrentItem(item
)
324 item
.setSelected(True)
325 self
.blockSignals(False)
326 self
.show_selection()
328 # Reselect the current item
329 selection_info
= saved_selection
[category
]
330 new
= selection_info
[0]
331 old
= selection_info
[1]
332 reselect
= selection_info
[3]
338 reselect(item
, current
=True)
341 # When reselecting we only care that the items are selected;
342 # we do not need to rerun the callbacks which were triggered
343 # above. Block signals to skip the callbacks.
344 self
.blockSignals(True)
345 for (new
, old
, sel
, reselect
) in saved_selection
:
348 reselect(item
, current
=False)
349 self
.blockSignals(False)
351 for (new
, old
, sel
, reselect
) in saved_selection
:
352 # When modified is staged, select the next modified item
353 # When unmerged is staged, select the next unmerged item
354 # When unstaging, select the next staged item
355 # When staging untracked files, select the next untracked item
356 if len(new
) >= len(old
):
357 # The list did not shrink so it is not one of these cases.
360 # The item still exists so ignore it
361 if item
in new
or item
not in old
:
363 # The item no longer exists in this list so search for
364 # its nearest neighbors and select them instead.
365 idx
= old
.index(item
)
366 for j
in itertools
.chain(old
[idx
+1:], reversed(old
[:idx
])):
368 reselect(j
, current
=True)
371 def _restore_scrollbars(self
):
372 vscroll
= self
.verticalScrollBar()
373 if vscroll
and self
.old_vscroll
is not None:
374 vscroll
.setValue(self
.old_vscroll
)
375 self
.old_vscroll
= None
377 hscroll
= self
.horizontalScrollBar()
378 if hscroll
and self
.old_hscroll
is not None:
379 hscroll
.setValue(self
.old_hscroll
)
380 self
.old_hscroll
= None
382 def stage_selection(self
):
383 """Stage or unstage files according to the selection"""
384 context
= self
.context
385 selected_indexes
= self
.selected_indexes()
387 category
, idx
= selected_indexes
[0]
388 # A header item e.g. 'Staged', 'Modified', etc.
389 if category
== self
.idx_header
:
390 if idx
== self
.idx_staged
:
391 cmds
.do(cmds
.UnstageAll
, context
)
392 elif idx
== self
.idx_modified
:
393 cmds
.do(cmds
.StageModified
, context
)
394 elif idx
== self
.idx_untracked
:
395 cmds
.do(cmds
.StageUntracked
, context
)
397 pass # Do nothing for unmerged items, by design
399 cmds
.do(cmds
.StageOrUnstage
, context
)
401 def staged_item(self
, itemidx
):
402 return self
._subtree
_item
(self
.idx_staged
, itemidx
)
404 def modified_item(self
, itemidx
):
405 return self
._subtree
_item
(self
.idx_modified
, itemidx
)
407 def unmerged_item(self
, itemidx
):
408 return self
._subtree
_item
(self
.idx_unmerged
, itemidx
)
410 def untracked_item(self
, itemidx
):
411 return self
._subtree
_item
(self
.idx_untracked
, itemidx
)
413 def unstaged_item(self
, itemidx
):
415 item
= self
.topLevelItem(self
.idx_modified
)
416 count
= item
.childCount()
418 return item
.child(itemidx
)
420 item
= self
.topLevelItem(self
.idx_unmerged
)
421 count
+= item
.childCount()
423 return item
.child(itemidx
)
425 item
= self
.topLevelItem(self
.idx_untracked
)
426 count
+= item
.childCount()
428 return item
.child(itemidx
)
432 def _subtree_item(self
, idx
, itemidx
):
433 parent
= self
.topLevelItem(idx
)
434 return parent
.child(itemidx
)
436 def _about_to_update(self
):
437 self
.save_scrollbars()
438 self
.save_selection()
440 def save_scrollbars(self
):
441 vscroll
= self
.verticalScrollBar()
443 self
.old_vscroll
= get(vscroll
)
445 hscroll
= self
.horizontalScrollBar()
447 self
.old_hscroll
= get(hscroll
)
449 def current_item(self
):
450 s
= self
.selected_indexes()
453 current
= self
.currentItem()
456 idx
= self
.indexFromItem(current
, 0)
457 if idx
.parent().isValid():
458 parent_idx
= idx
.parent()
459 entry
= (parent_idx
.row(), idx
.row())
461 entry
= (self
.idx_header
, idx
.row())
464 def save_selection(self
):
465 self
.old_contents
= self
.contents()
466 self
.old_selection
= self
.selection()
467 self
.old_current_item
= self
.current_item()
470 self
.set_staged(self
.m
.staged
)
471 self
.set_modified(self
.m
.modified
)
472 self
.set_unmerged(self
.m
.unmerged
)
473 self
.set_untracked(self
.m
.untracked
)
474 self
._update
_column
_widths
()
475 self
._update
_actions
()
476 self
._restore
_selection
()
477 self
._restore
_scrollbars
()
479 def _update_actions(self
, selected
=None):
481 selected
= self
.selection_model
.selection()
482 can_revert_edits
= bool(selected
.staged
or selected
.modified
)
483 self
.revert_unstaged_edits_action
.setEnabled(can_revert_edits
)
485 def set_staged(self
, items
):
486 """Adds items to the 'Staged' subtree."""
487 self
._set
_subtree
(items
, self
.idx_staged
, staged
=True,
488 deleted_set
=self
.m
.staged_deleted
)
490 def set_modified(self
, items
):
491 """Adds items to the 'Modified' subtree."""
492 self
._set
_subtree
(items
, self
.idx_modified
,
493 deleted_set
=self
.m
.unstaged_deleted
)
495 def set_unmerged(self
, items
):
496 """Adds items to the 'Unmerged' subtree."""
497 deleted_set
= set([path
for path
in items
if not core
.exists(path
)])
498 self
._set
_subtree
(items
, self
.idx_unmerged
,
499 deleted_set
=deleted_set
)
501 def set_untracked(self
, items
):
502 """Adds items to the 'Untracked' subtree."""
503 self
._set
_subtree
(items
, self
.idx_untracked
, untracked
=True)
505 def _set_subtree(self
, items
, idx
,
509 """Add a list of items to a treewidget item."""
510 self
.blockSignals(True)
511 parent
= self
.topLevelItem(idx
)
512 hide
= not bool(items
)
513 parent
.setHidden(hide
)
515 # sip v4.14.7 and below leak memory in parent.takeChildren()
516 # so we use this backwards-compatible construct instead
517 while parent
.takeChild(0) is not None:
521 deleted
= (deleted_set
is not None and item
in deleted_set
)
522 treeitem
= qtutils
.create_treeitem(item
,
526 parent
.addChild(treeitem
)
527 self
.expand_items(idx
, items
)
528 self
.blockSignals(False)
530 def _update_column_widths(self
):
531 self
.resizeColumnToContents(0)
533 def expand_items(self
, idx
, items
):
534 """Expand the top-level category "folder" once and only once."""
535 # Don't do this if items is empty; this makes it so that we
536 # don't add the top-level index into the expanded_items set
537 # until an item appears in a particular category.
540 # Only run this once; we don't want to re-expand items that
541 # we've clicked on to re-collapse on updated().
542 if idx
in self
.expanded_items
:
544 self
.expanded_items
.add(idx
)
545 item
= self
.topLevelItem(idx
)
547 self
.expandItem(item
)
549 def contextMenuEvent(self
, event
):
550 """Create context menus for the repo status tree."""
551 menu
= self
._create
_context
_menu
()
552 menu
.exec_(self
.mapToGlobal(event
.pos()))
554 def _create_context_menu(self
):
555 """Set up the status menu for the repo status tree."""
557 menu
= qtutils
.create_menu('Status', self
)
558 selected_indexes
= self
.selected_indexes()
560 category
, idx
= selected_indexes
[0]
561 # A header item e.g. 'Staged', 'Modified', etc.
562 if category
== self
.idx_header
:
563 return self
._create
_header
_context
_menu
(menu
, idx
)
566 self
._create
_staged
_context
_menu
(menu
, s
)
568 self
._create
_unmerged
_context
_menu
(menu
, s
)
570 self
._create
_unstaged
_context
_menu
(menu
, s
)
572 if not utils
.is_win32():
573 if not menu
.isEmpty():
575 if not self
.selection_model
.is_empty():
576 menu
.addAction(self
.default_app_action
)
577 menu
.addAction(self
.parent_dir_action
)
578 menu
.addAction(self
.terminal_action
)
580 self
._add
_copy
_actions
(menu
)
584 def _add_copy_actions(self
, menu
):
585 """Add the "Copy" sub-menu"""
586 enabled
= self
.selection_model
.filename() is not None
587 self
.copy_path_action
.setEnabled(enabled
)
588 self
.copy_relpath_action
.setEnabled(enabled
)
589 self
.copy_leading_path_action
.setEnabled(enabled
)
590 self
.copy_basename_action
.setEnabled(enabled
)
591 copy_icon
= icons
.copy()
594 copy_menu
= QtWidgets
.QMenu(N_('Copy...'), menu
)
595 menu
.addMenu(copy_menu
)
597 copy_menu
.setIcon(copy_icon
)
598 copy_menu
.addAction(self
.copy_path_action
)
599 copy_menu
.addAction(self
.copy_relpath_action
)
600 copy_menu
.addAction(self
.copy_leading_path_action
)
601 copy_menu
.addAction(self
.copy_basename_action
)
603 current_settings
= settings
.Settings()
604 current_settings
.load()
606 copy_formats
= current_settings
.copy_formats
608 copy_menu
.addSeparator()
610 context
= self
.context
611 for entry
in copy_formats
:
612 name
= entry
.get('name', '')
613 fmt
= entry
.get('format', '')
615 action
= copy_menu
.addAction(
616 name
, partial(copy_format
, context
, fmt
))
617 action
.setIcon(copy_icon
)
618 action
.setEnabled(enabled
)
620 copy_menu
.addSeparator()
621 copy_menu
.addAction(self
.copy_customize_action
)
623 def _create_header_context_menu(self
, menu
, idx
):
624 context
= self
.context
625 if idx
== self
.idx_staged
:
626 menu
.addAction(icons
.remove(), N_('Unstage All'),
627 cmds
.run(cmds
.UnstageAll
, context
))
628 elif idx
== self
.idx_unmerged
:
629 action
= menu
.addAction(icons
.add(), cmds
.StageUnmerged
.name(),
630 cmds
.run(cmds
.StageUnmerged
, context
))
631 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
632 elif idx
== self
.idx_modified
:
633 action
= menu
.addAction(icons
.add(), cmds
.StageModified
.name(),
634 cmds
.run(cmds
.StageModified
, context
))
635 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
636 elif idx
== self
.idx_untracked
:
637 action
= menu
.addAction(icons
.add(), cmds
.StageUntracked
.name(),
638 cmds
.run(cmds
.StageUntracked
, context
))
639 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
642 def _create_staged_context_menu(self
, menu
, s
):
643 if s
.staged
[0] in self
.m
.submodules
:
644 return self
._create
_staged
_submodule
_context
_menu
(menu
, s
)
646 context
= self
.context
647 if self
.m
.unstageable():
648 action
= menu
.addAction(
649 icons
.remove(), N_('Unstage Selected'),
650 cmds
.run(cmds
.Unstage
, context
, self
.staged()))
651 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
653 menu
.addAction(self
.launch_editor_action
)
655 # Do all of the selected items exist?
656 all_exist
= all(i
not in self
.m
.staged_deleted
and core
.exists(i
)
657 for i
in self
.staged())
660 menu
.addAction(self
.launch_difftool_action
)
662 if self
.m
.undoable():
663 menu
.addAction(self
.revert_unstaged_edits_action
)
665 menu
.addAction(self
.view_history_action
)
666 menu
.addAction(self
.view_blame_action
)
669 def _create_staged_submodule_context_menu(self
, menu
, s
):
670 context
= self
.context
671 path
= core
.abspath(s
.staged
[0])
672 menu
.addAction(icons
.cola(), N_('Launch git-cola'),
673 cmds
.run(cmds
.OpenRepo
, context
, path
))
674 action
= menu
.addAction(
675 icons
.remove(), N_('Unstage Selected'),
676 cmds
.run(cmds
.Unstage
, context
, self
.staged()))
677 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
679 menu
.addAction(self
.view_history_action
)
682 def _create_unmerged_context_menu(self
, menu
, _s
):
683 context
= self
.context
684 menu
.addAction(self
.launch_difftool_action
)
686 action
= menu
.addAction(
687 icons
.add(), N_('Stage Selected'),
688 cmds
.run(cmds
.Stage
, context
, self
.unstaged()))
689 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
691 menu
.addAction(self
.launch_editor_action
)
692 menu
.addAction(self
.view_history_action
)
693 menu
.addAction(self
.view_blame_action
)
696 def _create_unstaged_context_menu(self
, menu
, s
):
697 context
= self
.context
698 modified_submodule
= (s
.modified
and
699 s
.modified
[0] in self
.m
.submodules
)
700 if modified_submodule
:
701 return self
._create
_modified
_submodule
_context
_menu
(menu
, s
)
703 if self
.m
.stageable():
704 action
= menu
.addAction(
705 icons
.add(), N_('Stage Selected'),
706 cmds
.run(cmds
.Stage
, context
, self
.unstaged()))
707 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
709 if not self
.selection_model
.is_empty():
710 menu
.addAction(self
.launch_editor_action
)
712 # Do all of the selected items exist?
713 all_exist
= all(i
not in self
.m
.unstaged_deleted
and core
.exists(i
)
714 for i
in self
.staged())
716 if all_exist
and s
.modified
and self
.m
.stageable():
717 menu
.addAction(self
.launch_difftool_action
)
719 if s
.modified
and self
.m
.stageable():
720 if self
.m
.undoable():
722 menu
.addAction(self
.revert_unstaged_edits_action
)
724 if all_exist
and s
.untracked
:
725 # Git Annex / Git LFS
727 lfs
= core
.find_executable('git-lfs')
731 menu
.addAction(self
.annex_add_action
)
733 menu
.addAction(self
.lfs_track_action
)
736 if self
.move_to_trash_action
is not None:
737 menu
.addAction(self
.move_to_trash_action
)
738 menu
.addAction(self
.delete_untracked_files_action
)
740 menu
.addAction(icons
.edit(), N_('Add to .gitignore'),
741 partial(gitignore
.gitignore_view
, self
.context
))
743 if not self
.selection_model
.is_empty():
744 menu
.addAction(self
.view_history_action
)
745 menu
.addAction(self
.view_blame_action
)
748 def _create_modified_submodule_context_menu(self
, menu
, s
):
749 context
= self
.context
750 path
= core
.abspath(s
.modified
[0])
751 menu
.addAction(icons
.cola(), N_('Launch git-cola'),
752 cmds
.run(cmds
.OpenRepo
, context
, path
))
754 if self
.m
.stageable():
756 action
= menu
.addAction(
757 icons
.add(), N_('Stage Selected'),
758 cmds
.run(cmds
.Stage
, context
, self
.unstaged()))
759 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
761 menu
.addAction(self
.view_history_action
)
764 def _delete_untracked_files(self
):
765 cmds
.do(cmds
.Delete
, self
.context
, self
.untracked())
767 def _trash_untracked_files(self
):
768 cmds
.do(cmds
.MoveToTrash
, self
.context
, self
.untracked())
770 def selected_path(self
):
771 s
= self
.single_selection()
772 return s
.staged
or s
.unmerged
or s
.modified
or s
.untracked
or None
774 def single_selection(self
):
775 """Scan across staged, modified, etc. and return a single item."""
785 unmerged
= s
.unmerged
[0]
787 modified
= s
.modified
[0]
789 untracked
= s
.untracked
[0]
791 return selection
.State(staged
, unmerged
, modified
, untracked
)
793 def selected_indexes(self
):
794 """Returns a list of (category, row) representing the tree selection."""
795 selected
= self
.selectedIndexes()
798 if idx
.parent().isValid():
799 parent_idx
= idx
.parent()
800 entry
= (parent_idx
.row(), idx
.row())
802 entry
= (self
.idx_header
, idx
.row())
807 """Return the current selection in the repo status tree."""
808 return selection
.State(self
.staged(), self
.unmerged(),
809 self
.modified(), self
.untracked())
812 return selection
.State(self
.m
.staged
, self
.m
.unmerged
,
813 self
.m
.modified
, self
.m
.untracked
)
817 return c
.staged
+ c
.unmerged
+ c
.modified
+ c
.untracked
819 def selected_group(self
):
820 """A list of selected files in various states of being"""
821 return selection
.pick(self
.selection())
823 def selected_idx(self
):
825 s
= self
.single_selection()
827 for content
, sel
in zip(c
, s
):
831 return offset
+ content
.index(sel
)
832 offset
+= len(content
)
835 def select_by_index(self
, idx
):
838 (c
.staged
, self
.idx_staged
),
839 (c
.unmerged
, self
.idx_unmerged
),
840 (c
.modified
, self
.idx_modified
),
841 (c
.untracked
, self
.idx_untracked
),
843 for content
, toplevel_idx
in to_try
:
846 if idx
< len(content
):
847 parent
= self
.topLevelItem(toplevel_idx
)
848 item
= parent
.child(idx
)
850 self
.select_item(item
)
854 def scroll_to_item(self
, item
):
855 # First, scroll to the item, but keep the original hscroll
857 hscrollbar
= self
.horizontalScrollBar()
859 hscroll
= get(hscrollbar
)
860 self
.scrollToItem(item
)
861 if hscroll
is not None:
862 hscrollbar
.setValue(hscroll
)
864 def select_item(self
, item
):
865 self
.scroll_to_item(item
)
866 self
.setCurrentItem(item
)
867 item
.setSelected(True)
870 return self
._subtree
_selection
(self
.idx_staged
, self
.m
.staged
)
873 return self
.unmerged() + self
.modified() + self
.untracked()
876 return self
._subtree
_selection
(self
.idx_modified
, self
.m
.modified
)
879 return self
._subtree
_selection
(self
.idx_unmerged
, self
.m
.unmerged
)
882 return self
._subtree
_selection
(self
.idx_untracked
, self
.m
.untracked
)
884 def staged_items(self
):
885 return self
._subtree
_selection
_items
(self
.idx_staged
)
887 def unstaged_items(self
):
888 return (self
.unmerged_items() + self
.modified_items() +
889 self
.untracked_items())
891 def modified_items(self
):
892 return self
._subtree
_selection
_items
(self
.idx_modified
)
894 def unmerged_items(self
):
895 return self
._subtree
_selection
_items
(self
.idx_unmerged
)
897 def untracked_items(self
):
898 return self
._subtree
_selection
_items
(self
.idx_untracked
)
900 def _subtree_selection(self
, idx
, items
):
901 item
= self
.topLevelItem(idx
)
902 return qtutils
.tree_selection(item
, items
)
904 def _subtree_selection_items(self
, idx
):
905 item
= self
.topLevelItem(idx
)
906 return qtutils
.tree_selection_items(item
)
908 def _double_clicked(self
, _item
, _idx
):
909 """Called when an item is double-clicked in the repo status tree."""
910 cmds
.do(cmds
.StageOrUnstage
, self
.context
)
912 def show_selection(self
):
913 """Show the selected item."""
914 context
= self
.context
915 self
.scroll_to_item(self
.currentItem())
916 # Sync the selection model
917 selected
= self
.selection()
918 selection_model
= self
.selection_model
919 selection_model
.set_selection(selected
)
920 self
._update
_actions
(selected
=selected
)
922 selected_indexes
= self
.selected_indexes()
923 if not selected_indexes
:
924 if self
.m
.amending():
925 cmds
.do(cmds
.SetDiffText
, context
, '')
927 cmds
.do(cmds
.ResetMode
, context
)
930 # A header item e.g. 'Staged', 'Modified', etc.
931 category
, idx
= selected_indexes
[0]
932 header
= category
== self
.idx_header
935 self
.idx_staged
: cmds
.DiffStagedSummary
,
936 self
.idx_modified
: cmds
.Diffstat
,
937 # TODO implement UnmergedSummary
938 # self.idx_unmerged: cmds.UnmergedSummary,
939 self
.idx_untracked
: cmds
.UntrackedSummary
,
940 }.get(idx
, cmds
.Diffstat
)
941 cmds
.do(cls
, context
)
944 staged
= category
== self
.idx_staged
945 modified
= category
== self
.idx_modified
946 unmerged
= category
== self
.idx_unmerged
947 untracked
= category
== self
.idx_untracked
950 item
= self
.staged_items()[0]
952 item
= self
.unmerged_items()[0]
954 item
= self
.modified_items()[0]
956 item
= self
.unstaged_items()[0]
958 item
= None # this shouldn't happen
959 assert item
is not None
962 deleted
= item
.deleted
963 image
= self
.image_formats
.ok(path
)
965 # Images are diffed differently
967 cmds
.do(cmds
.DiffImage
, context
, path
, deleted
,
968 staged
, modified
, unmerged
, untracked
)
970 cmds
.do(cmds
.DiffStaged
, context
, path
, deleted
=deleted
)
972 cmds
.do(cmds
.Diff
, context
, path
, deleted
=deleted
)
974 cmds
.do(cmds
.Diff
, context
, path
)
976 cmds
.do(cmds
.ShowUntracked
, context
, path
)
978 def select_header(self
):
979 """Select an active header, which triggers a diffstat"""
980 for idx
in (self
.idx_staged
, self
.idx_unmerged
,
981 self
.idx_modified
, self
.idx_untracked
):
982 item
= self
.topLevelItem(idx
)
983 if item
.childCount() > 0:
984 self
.clearSelection()
985 self
.setCurrentItem(item
)
989 idx
= self
.selected_idx()
990 all_files
= self
.all_files()
992 selected_indexes
= self
.selected_indexes()
994 category
, toplevel_idx
= selected_indexes
[0]
995 if category
== self
.idx_header
:
996 item
= self
.itemAbove(self
.topLevelItem(toplevel_idx
))
998 self
.select_item(item
)
1001 self
.select_by_index(len(all_files
) - 1)
1004 self
.select_by_index(idx
- 1)
1006 self
.select_by_index(len(all_files
) - 1)
1008 def move_down(self
):
1009 idx
= self
.selected_idx()
1010 all_files
= self
.all_files()
1012 selected_indexes
= self
.selected_indexes()
1013 if selected_indexes
:
1014 category
, toplevel_idx
= selected_indexes
[0]
1015 if category
== self
.idx_header
:
1016 item
= self
.itemBelow(self
.topLevelItem(toplevel_idx
))
1017 if item
is not None:
1018 self
.select_item(item
)
1021 self
.select_by_index(0)
1023 if idx
+ 1 < len(all_files
):
1024 self
.select_by_index(idx
+ 1)
1026 self
.select_by_index(0)
1028 def mimeData(self
, items
):
1029 """Return a list of absolute-path URLs"""
1030 context
= self
.context
1031 paths
= qtutils
.paths_from_items(items
, item_filter
=_item_filter
)
1032 return qtutils
.mimedata_from_paths(context
, paths
)
1034 def mimeTypes(self
):
1035 return qtutils
.path_mimetypes()
1038 def _item_filter(item
):
1039 return not item
.deleted
and core
.exists(item
.path
)
1042 def view_blame(context
):
1043 """Signal that we should view blame for paths."""
1044 cmds
.do(cmds
.BlamePaths
, context
, context
.selection
.union())
1047 def view_history(context
):
1048 """Signal that we should view history for paths."""
1049 cmds
.do(cmds
.VisualizePaths
, context
, context
.selection
.union())
1052 def copy_path(context
, absolute
=True):
1053 """Copy a selected path to the clipboard"""
1054 filename
= context
.selection
.filename()
1055 qtutils
.copy_path(filename
, absolute
=absolute
)
1058 def copy_relpath(context
):
1059 """Copy a selected relative path to the clipboard"""
1060 copy_path(context
, absolute
=False)
1063 def copy_basename(context
):
1064 filename
= os
.path
.basename(context
.selection
.filename())
1065 basename
, _
= os
.path
.splitext(filename
)
1066 qtutils
.copy_path(basename
, absolute
=False)
1069 def copy_leading_path(context
):
1070 """Copy the selected leading path to the clipboard"""
1071 filename
= context
.selection
.filename()
1072 dirname
= os
.path
.dirname(filename
)
1073 qtutils
.copy_path(dirname
, absolute
=False)
1076 def copy_format(context
, fmt
):
1078 values
['path'] = path
= context
.selection
.filename()
1079 values
['abspath'] = abspath
= os
.path
.abspath(path
)
1080 values
['absdirname'] = os
.path
.dirname(abspath
)
1081 values
['dirname'] = os
.path
.dirname(path
)
1082 values
['filename'] = os
.path
.basename(path
)
1083 values
['basename'], values
['ext'] = (
1084 os
.path
.splitext(os
.path
.basename(path
)))
1085 qtutils
.set_clipboard(fmt
% values
)
1088 def show_help(context
):
1090 Format String Variables
1091 -----------------------
1092 %(path)s = relative file path
1093 %(abspath)s = absolute file path
1094 %(dirname)s = relative directory path
1095 %(absdirname)s = absolute directory path
1096 %(filename)s = file basename
1097 %(basename)s = file basename without extension
1098 %(ext)s = file extension
1100 title
= N_('Help - Custom Copy Actions')
1101 return text
.text_dialog(context
, help_text
, title
)
1104 class StatusFilterWidget(QtWidgets
.QWidget
):
1106 def __init__(self
, context
, parent
=None):
1107 QtWidgets
.QWidget
.__init
__(self
, parent
)
1108 self
.main_model
= context
.model
1110 hint
= N_('Filter paths...')
1111 self
.text
= completion
.GitStatusFilterLineEdit(
1112 context
, hint
=hint
, parent
=self
)
1113 self
.text
.setToolTip(hint
)
1114 self
.setFocusProxy(self
.text
)
1117 self
.main_layout
= qtutils
.hbox(defs
.no_margin
, defs
.spacing
, self
.text
)
1118 self
.setLayout(self
.main_layout
)
1121 widget
.changed
.connect(self
.apply_filter
)
1122 widget
.cleared
.connect(self
.apply_filter
)
1123 widget
.enter
.connect(self
.apply_filter
)
1124 widget
.editingFinished
.connect(self
.apply_filter
)
1126 def apply_filter(self
):
1127 value
= get(self
.text
)
1128 if value
== self
._filter
:
1130 self
._filter
= value
1131 paths
= utils
.shell_split(value
)
1132 self
.main_model
.update_path_filter(paths
)
1135 def customize_copy_actions(context
, parent
):
1136 """Customize copy actions"""
1137 dialog
= CustomizeCopyActions(context
, parent
)
1142 class CustomizeCopyActions(standard
.Dialog
):
1144 def __init__(self
, context
, parent
):
1145 standard
.Dialog
.__init
__(self
, parent
=parent
)
1146 self
.setWindowTitle(N_('Custom Copy Actions'))
1148 self
.table
= QtWidgets
.QTableWidget(self
)
1149 self
.table
.setColumnCount(2)
1150 self
.table
.setHorizontalHeaderLabels([
1152 N_('Format String'),
1154 self
.table
.setSortingEnabled(False)
1155 self
.table
.verticalHeader().hide()
1156 self
.table
.horizontalHeader().setStretchLastSection(True)
1158 self
.add_button
= qtutils
.create_button(N_('Add'))
1159 self
.remove_button
= qtutils
.create_button(N_('Remove'))
1160 self
.remove_button
.setEnabled(False)
1161 self
.show_help_button
= qtutils
.create_button(N_('Show Help'))
1162 self
.show_help_button
.setShortcut(hotkeys
.QUESTION
)
1164 self
.close_button
= qtutils
.close_button()
1165 self
.save_button
= qtutils
.ok_button(N_('Save'))
1167 self
.buttons
= qtutils
.hbox(defs
.no_margin
, defs
.button_spacing
,
1170 self
.show_help_button
,
1175 layout
= qtutils
.vbox(defs
.margin
, defs
.spacing
,
1176 self
.table
, self
.buttons
)
1177 self
.setLayout(layout
)
1179 qtutils
.connect_button(self
.add_button
, self
.add
)
1180 qtutils
.connect_button(self
.remove_button
, self
.remove
)
1181 qtutils
.connect_button(
1182 self
.show_help_button
, partial(show_help
, context
))
1183 qtutils
.connect_button(self
.close_button
, self
.reject
)
1184 qtutils
.connect_button(self
.save_button
, self
.save
)
1185 qtutils
.add_close_action(self
)
1186 self
.table
.itemSelectionChanged
.connect(self
.table_selection_changed
)
1188 self
.init_size(parent
=parent
)
1190 self
.settings
= settings
.Settings()
1191 QtCore
.QTimer
.singleShot(0, self
.reload_settings
)
1193 def reload_settings(self
):
1194 # Called once after the GUI is initialized
1195 self
.settings
.load()
1197 for entry
in self
.settings
.copy_formats
:
1198 name_string
= entry
.get('name', '')
1199 format_string
= entry
.get('format', '')
1200 if name_string
and format_string
:
1201 name
= QtWidgets
.QTableWidgetItem(name_string
)
1202 fmt
= QtWidgets
.QTableWidgetItem(format_string
)
1203 rows
= table
.rowCount()
1204 table
.setRowCount(rows
+ 1)
1205 table
.setItem(rows
, 0, name
)
1206 table
.setItem(rows
, 1, fmt
)
1208 def export_state(self
):
1209 state
= super(CustomizeCopyActions
, self
).export_state()
1210 standard
.export_header_columns(self
.table
, state
)
1213 def apply_state(self
, state
):
1214 result
= super(CustomizeCopyActions
, self
).apply_state(state
)
1215 standard
.apply_header_columns(self
.table
, state
)
1219 self
.table
.setFocus(True)
1220 rows
= self
.table
.rowCount()
1221 self
.table
.setRowCount(rows
+ 1)
1223 name
= QtWidgets
.QTableWidgetItem(N_('Name'))
1224 fmt
= QtWidgets
.QTableWidgetItem(r
'%(path)s')
1225 self
.table
.setItem(rows
, 0, name
)
1226 self
.table
.setItem(rows
, 1, fmt
)
1228 self
.table
.setCurrentCell(rows
, 0)
1229 self
.table
.editItem(name
)
1232 """Remove selected items"""
1233 # Gather a unique set of rows and remove them in reverse order
1235 items
= self
.table
.selectedItems()
1237 rows
.add(self
.table
.row(item
))
1239 for row
in reversed(sorted(rows
)):
1240 self
.table
.removeRow(row
)
1244 for row
in range(self
.table
.rowCount()):
1245 name
= self
.table
.item(row
, 0)
1246 fmt
= self
.table
.item(row
, 1)
1249 'name': name
.text(),
1250 'format': fmt
.text(),
1252 copy_formats
.append(entry
)
1254 while self
.settings
.copy_formats
:
1255 self
.settings
.copy_formats
.pop()
1257 self
.settings
.copy_formats
.extend(copy_formats
)
1258 self
.settings
.save()
1262 def table_selection_changed(self
):
1263 items
= self
.table
.selectedItems()
1264 self
.remove_button
.setEnabled(bool(items
))