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 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
.setDragEnabled(True)
124 self
.setAutoScroll(False)
126 if not prefs
.status_indent(context
):
127 self
.setIndentation(0)
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
, N_('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
, N_('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
, N_('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
, N_('Untracked'),
506 def _set_subtree(self
, items
, idx
, parent_title
,
510 """Add a list of items to a treewidget item."""
511 self
.blockSignals(True)
512 parent
= self
.topLevelItem(idx
)
513 hide
= not bool(items
)
514 parent
.setHidden(hide
)
516 # sip v4.14.7 and below leak memory in parent.takeChildren()
517 # so we use this backwards-compatible construct instead
518 while parent
.takeChild(0) is not None:
522 deleted
= (deleted_set
is not None and item
in deleted_set
)
523 treeitem
= qtutils
.create_treeitem(item
,
527 parent
.addChild(treeitem
)
528 self
._expand
_items
(idx
, items
)
529 self
.blockSignals(False)
530 if prefs
.status_show_totals(self
.context
):
531 parent
.setText(0, '%s (%s)' % (parent_title
, len(items
)))
533 def _update_column_widths(self
):
534 self
.resizeColumnToContents(0)
536 def _expand_items(self
, idx
, items
):
537 """Expand the top-level category "folder" once and only once."""
538 # Don't do this if items is empty; this makes it so that we
539 # don't add the top-level index into the expanded_items set
540 # until an item appears in a particular category.
543 # Only run this once; we don't want to re-expand items that
544 # we've clicked on to re-collapse on updated().
545 if idx
in self
.expanded_items
:
547 self
.expanded_items
.add(idx
)
548 item
= self
.topLevelItem(idx
)
550 self
.expandItem(item
)
552 def contextMenuEvent(self
, event
):
553 """Create context menus for the repo status tree."""
554 menu
= self
._create
_context
_menu
()
555 menu
.exec_(self
.mapToGlobal(event
.pos()))
557 def _create_context_menu(self
):
558 """Set up the status menu for the repo status tree."""
560 menu
= qtutils
.create_menu('Status', self
)
561 selected_indexes
= self
.selected_indexes()
563 category
, idx
= selected_indexes
[0]
564 # A header item e.g. 'Staged', 'Modified', etc.
565 if category
== self
.idx_header
:
566 return self
._create
_header
_context
_menu
(menu
, idx
)
569 self
._create
_staged
_context
_menu
(menu
, s
)
571 self
._create
_unmerged
_context
_menu
(menu
, s
)
573 self
._create
_unstaged
_context
_menu
(menu
, s
)
575 if not utils
.is_win32():
576 if not menu
.isEmpty():
578 if not self
.selection_model
.is_empty():
579 menu
.addAction(self
.default_app_action
)
580 menu
.addAction(self
.parent_dir_action
)
582 if self
.terminal_action
is not None:
583 menu
.addAction(self
.terminal_action
)
585 self
._add
_copy
_actions
(menu
)
589 def _add_copy_actions(self
, menu
):
590 """Add the "Copy" sub-menu"""
591 enabled
= self
.selection_model
.filename() is not None
592 self
.copy_path_action
.setEnabled(enabled
)
593 self
.copy_relpath_action
.setEnabled(enabled
)
594 self
.copy_leading_path_action
.setEnabled(enabled
)
595 self
.copy_basename_action
.setEnabled(enabled
)
596 copy_icon
= icons
.copy()
599 copy_menu
= QtWidgets
.QMenu(N_('Copy...'), menu
)
600 menu
.addMenu(copy_menu
)
602 copy_menu
.setIcon(copy_icon
)
603 copy_menu
.addAction(self
.copy_path_action
)
604 copy_menu
.addAction(self
.copy_relpath_action
)
605 copy_menu
.addAction(self
.copy_leading_path_action
)
606 copy_menu
.addAction(self
.copy_basename_action
)
608 current_settings
= settings
.Settings()
609 current_settings
.load()
611 copy_formats
= current_settings
.copy_formats
613 copy_menu
.addSeparator()
615 context
= self
.context
616 for entry
in copy_formats
:
617 name
= entry
.get('name', '')
618 fmt
= entry
.get('format', '')
620 action
= copy_menu
.addAction(
621 name
, partial(copy_format
, context
, fmt
))
622 action
.setIcon(copy_icon
)
623 action
.setEnabled(enabled
)
625 copy_menu
.addSeparator()
626 copy_menu
.addAction(self
.copy_customize_action
)
628 def _create_header_context_menu(self
, menu
, idx
):
629 context
= self
.context
630 if idx
== self
.idx_staged
:
631 menu
.addAction(icons
.remove(), N_('Unstage All'),
632 cmds
.run(cmds
.UnstageAll
, context
))
633 elif idx
== self
.idx_unmerged
:
634 action
= menu
.addAction(icons
.add(), cmds
.StageUnmerged
.name(),
635 cmds
.run(cmds
.StageUnmerged
, context
))
636 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
637 elif idx
== self
.idx_modified
:
638 action
= menu
.addAction(icons
.add(), cmds
.StageModified
.name(),
639 cmds
.run(cmds
.StageModified
, context
))
640 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
641 elif idx
== self
.idx_untracked
:
642 action
= menu
.addAction(icons
.add(), cmds
.StageUntracked
.name(),
643 cmds
.run(cmds
.StageUntracked
, context
))
644 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
647 def _create_staged_context_menu(self
, menu
, s
):
648 if s
.staged
[0] in self
.m
.submodules
:
649 return self
._create
_staged
_submodule
_context
_menu
(menu
, s
)
651 context
= self
.context
652 if self
.m
.unstageable():
653 action
= menu
.addAction(
654 icons
.remove(), N_('Unstage Selected'),
655 cmds
.run(cmds
.Unstage
, context
, self
.staged()))
656 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
658 menu
.addAction(self
.launch_editor_action
)
660 # Do all of the selected items exist?
661 all_exist
= all(i
not in self
.m
.staged_deleted
and core
.exists(i
)
662 for i
in self
.staged())
665 menu
.addAction(self
.launch_difftool_action
)
667 if self
.m
.undoable():
668 menu
.addAction(self
.revert_unstaged_edits_action
)
670 menu
.addAction(self
.view_history_action
)
671 menu
.addAction(self
.view_blame_action
)
674 def _create_staged_submodule_context_menu(self
, menu
, s
):
675 context
= self
.context
676 path
= core
.abspath(s
.staged
[0])
677 if len(self
.staged()) == 1:
678 menu
.addAction(icons
.cola(), N_('Launch git-cola'),
679 cmds
.run(cmds
.OpenRepo
, context
, path
))
681 action
= menu
.addAction(
682 icons
.remove(), N_('Unstage Selected'),
683 cmds
.run(cmds
.Unstage
, context
, self
.staged()))
684 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
686 menu
.addAction(self
.view_history_action
)
689 def _create_unmerged_context_menu(self
, menu
, _s
):
690 context
= self
.context
691 menu
.addAction(self
.launch_difftool_action
)
693 action
= menu
.addAction(
694 icons
.add(), N_('Stage Selected'),
695 cmds
.run(cmds
.Stage
, context
, self
.unstaged()))
696 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
698 menu
.addAction(self
.launch_editor_action
)
699 menu
.addAction(self
.view_history_action
)
700 menu
.addAction(self
.view_blame_action
)
703 def _create_unstaged_context_menu(self
, menu
, s
):
704 context
= self
.context
705 modified_submodule
= (s
.modified
and
706 s
.modified
[0] in self
.m
.submodules
)
707 if modified_submodule
:
708 return self
._create
_modified
_submodule
_context
_menu
(menu
, s
)
710 if self
.m
.stageable():
711 action
= menu
.addAction(
712 icons
.add(), N_('Stage Selected'),
713 cmds
.run(cmds
.Stage
, context
, self
.unstaged()))
714 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
716 if not self
.selection_model
.is_empty():
717 menu
.addAction(self
.launch_editor_action
)
719 # Do all of the selected items exist?
720 all_exist
= all(i
not in self
.m
.unstaged_deleted
and core
.exists(i
)
721 for i
in self
.staged())
723 if all_exist
and s
.modified
and self
.m
.stageable():
724 menu
.addAction(self
.launch_difftool_action
)
726 if s
.modified
and self
.m
.stageable():
727 if self
.m
.undoable():
729 menu
.addAction(self
.revert_unstaged_edits_action
)
731 if all_exist
and s
.untracked
:
732 # Git Annex / Git LFS
734 lfs
= core
.find_executable('git-lfs')
738 menu
.addAction(self
.annex_add_action
)
740 menu
.addAction(self
.lfs_track_action
)
743 if self
.move_to_trash_action
is not None:
744 menu
.addAction(self
.move_to_trash_action
)
745 menu
.addAction(self
.delete_untracked_files_action
)
747 menu
.addAction(icons
.edit(), N_('Add to .gitignore'),
748 partial(gitignore
.gitignore_view
, self
.context
))
750 if not self
.selection_model
.is_empty():
751 menu
.addAction(self
.view_history_action
)
752 menu
.addAction(self
.view_blame_action
)
755 def _create_modified_submodule_context_menu(self
, menu
, s
):
756 context
= self
.context
757 path
= core
.abspath(s
.modified
[0])
758 if len(self
.unstaged()) == 1:
759 menu
.addAction(icons
.cola(), N_('Launch git-cola'),
760 cmds
.run(cmds
.OpenRepo
, context
, path
))
761 menu
.addAction(icons
.pull(), N_('Update this submodule'),
762 cmds
.run(cmds
.SubmoduleUpdate
, context
, path
))
765 if self
.m
.stageable():
767 action
= menu
.addAction(
768 icons
.add(), N_('Stage Selected'),
769 cmds
.run(cmds
.Stage
, context
, self
.unstaged()))
770 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
772 menu
.addAction(self
.view_history_action
)
775 def _delete_untracked_files(self
):
776 cmds
.do(cmds
.Delete
, self
.context
, self
.untracked())
778 def _trash_untracked_files(self
):
779 cmds
.do(cmds
.MoveToTrash
, self
.context
, self
.untracked())
781 def selected_path(self
):
782 s
= self
.single_selection()
783 return s
.staged
or s
.unmerged
or s
.modified
or s
.untracked
or None
785 def single_selection(self
):
786 """Scan across staged, modified, etc. and return a single item."""
796 unmerged
= s
.unmerged
[0]
798 modified
= s
.modified
[0]
800 untracked
= s
.untracked
[0]
802 return selection
.State(staged
, unmerged
, modified
, untracked
)
804 def selected_indexes(self
):
805 """Returns a list of (category, row) representing the tree selection."""
806 selected
= self
.selectedIndexes()
809 if idx
.parent().isValid():
810 parent_idx
= idx
.parent()
811 entry
= (parent_idx
.row(), idx
.row())
813 entry
= (self
.idx_header
, idx
.row())
818 """Return the current selection in the repo status tree."""
819 return selection
.State(self
.staged(), self
.unmerged(),
820 self
.modified(), self
.untracked())
823 return selection
.State(self
.m
.staged
, self
.m
.unmerged
,
824 self
.m
.modified
, self
.m
.untracked
)
828 return c
.staged
+ c
.unmerged
+ c
.modified
+ c
.untracked
830 def selected_group(self
):
831 """A list of selected files in various states of being"""
832 return selection
.pick(self
.selection())
834 def selected_idx(self
):
836 s
= self
.single_selection()
838 for content
, sel
in zip(c
, s
):
842 return offset
+ content
.index(sel
)
843 offset
+= len(content
)
846 def select_by_index(self
, idx
):
849 (c
.staged
, self
.idx_staged
),
850 (c
.unmerged
, self
.idx_unmerged
),
851 (c
.modified
, self
.idx_modified
),
852 (c
.untracked
, self
.idx_untracked
),
854 for content
, toplevel_idx
in to_try
:
857 if idx
< len(content
):
858 parent
= self
.topLevelItem(toplevel_idx
)
859 item
= parent
.child(idx
)
861 self
.select_item(item
)
865 def scroll_to_item(self
, item
):
866 # First, scroll to the item, but keep the original hscroll
868 hscrollbar
= self
.horizontalScrollBar()
870 hscroll
= get(hscrollbar
)
871 self
.scrollToItem(item
)
872 if hscroll
is not None:
873 hscrollbar
.setValue(hscroll
)
875 def select_item(self
, item
):
876 self
.scroll_to_item(item
)
877 self
.setCurrentItem(item
)
878 item
.setSelected(True)
881 return self
._subtree
_selection
(self
.idx_staged
, self
.m
.staged
)
884 return self
.unmerged() + self
.modified() + self
.untracked()
887 return self
._subtree
_selection
(self
.idx_modified
, self
.m
.modified
)
890 return self
._subtree
_selection
(self
.idx_unmerged
, self
.m
.unmerged
)
893 return self
._subtree
_selection
(self
.idx_untracked
, self
.m
.untracked
)
895 def staged_items(self
):
896 return self
._subtree
_selection
_items
(self
.idx_staged
)
898 def unstaged_items(self
):
899 return (self
.unmerged_items() + self
.modified_items() +
900 self
.untracked_items())
902 def modified_items(self
):
903 return self
._subtree
_selection
_items
(self
.idx_modified
)
905 def unmerged_items(self
):
906 return self
._subtree
_selection
_items
(self
.idx_unmerged
)
908 def untracked_items(self
):
909 return self
._subtree
_selection
_items
(self
.idx_untracked
)
911 def _subtree_selection(self
, idx
, items
):
912 item
= self
.topLevelItem(idx
)
913 return qtutils
.tree_selection(item
, items
)
915 def _subtree_selection_items(self
, idx
):
916 item
= self
.topLevelItem(idx
)
917 return qtutils
.tree_selection_items(item
)
919 def _double_clicked(self
, _item
, _idx
):
920 """Called when an item is double-clicked in the repo status tree."""
921 cmds
.do(cmds
.StageOrUnstage
, self
.context
)
923 def show_selection(self
):
924 """Show the selected item."""
925 context
= self
.context
926 self
.scroll_to_item(self
.currentItem())
927 # Sync the selection model
928 selected
= self
.selection()
929 selection_model
= self
.selection_model
930 selection_model
.set_selection(selected
)
931 self
._update
_actions
(selected
=selected
)
933 selected_indexes
= self
.selected_indexes()
934 if not selected_indexes
:
935 if self
.m
.amending():
936 cmds
.do(cmds
.SetDiffText
, context
, '')
938 cmds
.do(cmds
.ResetMode
, context
)
941 # A header item e.g. 'Staged', 'Modified', etc.
942 category
, idx
= selected_indexes
[0]
943 header
= category
== self
.idx_header
946 self
.idx_staged
: cmds
.DiffStagedSummary
,
947 self
.idx_modified
: cmds
.Diffstat
,
948 # TODO implement UnmergedSummary
949 # self.idx_unmerged: cmds.UnmergedSummary,
950 self
.idx_untracked
: cmds
.UntrackedSummary
,
951 }.get(idx
, cmds
.Diffstat
)
952 cmds
.do(cls
, context
)
955 staged
= category
== self
.idx_staged
956 modified
= category
== self
.idx_modified
957 unmerged
= category
== self
.idx_unmerged
958 untracked
= category
== self
.idx_untracked
961 item
= self
.staged_items()[0]
963 item
= self
.unmerged_items()[0]
965 item
= self
.modified_items()[0]
967 item
= self
.unstaged_items()[0]
969 item
= None # this shouldn't happen
970 assert item
is not None
973 deleted
= item
.deleted
974 image
= self
.image_formats
.ok(path
)
976 # Images are diffed differently
978 cmds
.do(cmds
.DiffImage
, context
, path
, deleted
,
979 staged
, modified
, unmerged
, untracked
)
981 cmds
.do(cmds
.DiffStaged
, context
, path
, deleted
=deleted
)
983 cmds
.do(cmds
.Diff
, context
, path
, deleted
=deleted
)
985 cmds
.do(cmds
.Diff
, context
, path
)
987 cmds
.do(cmds
.ShowUntracked
, context
, path
)
989 def select_header(self
):
990 """Select an active header, which triggers a diffstat"""
991 for idx
in (self
.idx_staged
, self
.idx_unmerged
,
992 self
.idx_modified
, self
.idx_untracked
):
993 item
= self
.topLevelItem(idx
)
994 if item
.childCount() > 0:
995 self
.clearSelection()
996 self
.setCurrentItem(item
)
1000 idx
= self
.selected_idx()
1001 all_files
= self
.all_files()
1003 selected_indexes
= self
.selected_indexes()
1004 if selected_indexes
:
1005 category
, toplevel_idx
= selected_indexes
[0]
1006 if category
== self
.idx_header
:
1007 item
= self
.itemAbove(self
.topLevelItem(toplevel_idx
))
1008 if item
is not None:
1009 self
.select_item(item
)
1012 self
.select_by_index(len(all_files
) - 1)
1015 self
.select_by_index(idx
- 1)
1017 self
.select_by_index(len(all_files
) - 1)
1019 def move_down(self
):
1020 idx
= self
.selected_idx()
1021 all_files
= self
.all_files()
1023 selected_indexes
= self
.selected_indexes()
1024 if selected_indexes
:
1025 category
, toplevel_idx
= selected_indexes
[0]
1026 if category
== self
.idx_header
:
1027 item
= self
.itemBelow(self
.topLevelItem(toplevel_idx
))
1028 if item
is not None:
1029 self
.select_item(item
)
1032 self
.select_by_index(0)
1034 if idx
+ 1 < len(all_files
):
1035 self
.select_by_index(idx
+ 1)
1037 self
.select_by_index(0)
1039 def mimeData(self
, items
):
1040 """Return a list of absolute-path URLs"""
1041 context
= self
.context
1042 paths
= qtutils
.paths_from_items(items
, item_filter
=_item_filter
)
1043 return qtutils
.mimedata_from_paths(context
, paths
)
1045 # pylint: disable=no-self-use
1046 def mimeTypes(self
):
1047 return qtutils
.path_mimetypes()
1050 def _item_filter(item
):
1051 return not item
.deleted
and core
.exists(item
.path
)
1054 def view_blame(context
):
1055 """Signal that we should view blame for paths."""
1056 cmds
.do(cmds
.BlamePaths
, context
)
1059 def view_history(context
):
1060 """Signal that we should view history for paths."""
1061 cmds
.do(cmds
.VisualizePaths
, context
, context
.selection
.union())
1064 def copy_path(context
, absolute
=True):
1065 """Copy a selected path to the clipboard"""
1066 filename
= context
.selection
.filename()
1067 qtutils
.copy_path(filename
, absolute
=absolute
)
1070 def copy_relpath(context
):
1071 """Copy a selected relative path to the clipboard"""
1072 copy_path(context
, absolute
=False)
1075 def copy_basename(context
):
1076 filename
= os
.path
.basename(context
.selection
.filename())
1077 basename
, _
= os
.path
.splitext(filename
)
1078 qtutils
.copy_path(basename
, absolute
=False)
1081 def copy_leading_path(context
):
1082 """Copy the selected leading path to the clipboard"""
1083 filename
= context
.selection
.filename()
1084 dirname
= os
.path
.dirname(filename
)
1085 qtutils
.copy_path(dirname
, absolute
=False)
1088 def copy_format(context
, fmt
):
1090 values
['path'] = path
= context
.selection
.filename()
1091 values
['abspath'] = abspath
= os
.path
.abspath(path
)
1092 values
['absdirname'] = os
.path
.dirname(abspath
)
1093 values
['dirname'] = os
.path
.dirname(path
)
1094 values
['filename'] = os
.path
.basename(path
)
1095 values
['basename'], values
['ext'] = (
1096 os
.path
.splitext(os
.path
.basename(path
)))
1097 qtutils
.set_clipboard(fmt
% values
)
1100 def show_help(context
):
1102 Format String Variables
1103 -----------------------
1104 %(path)s = relative file path
1105 %(abspath)s = absolute file path
1106 %(dirname)s = relative directory path
1107 %(absdirname)s = absolute directory path
1108 %(filename)s = file basename
1109 %(basename)s = file basename without extension
1110 %(ext)s = file extension
1112 title
= N_('Help - Custom Copy Actions')
1113 return text
.text_dialog(context
, help_text
, title
)
1116 class StatusFilterWidget(QtWidgets
.QWidget
):
1118 def __init__(self
, context
, parent
=None):
1119 QtWidgets
.QWidget
.__init
__(self
, parent
)
1120 self
.main_model
= context
.model
1122 hint
= N_('Filter paths...')
1123 self
.text
= completion
.GitStatusFilterLineEdit(
1124 context
, hint
=hint
, parent
=self
)
1125 self
.text
.setToolTip(hint
)
1126 self
.setFocusProxy(self
.text
)
1129 self
.main_layout
= qtutils
.hbox(defs
.no_margin
, defs
.spacing
, self
.text
)
1130 self
.setLayout(self
.main_layout
)
1133 widget
.changed
.connect(self
.apply_filter
)
1134 widget
.cleared
.connect(self
.apply_filter
)
1135 widget
.enter
.connect(self
.apply_filter
)
1136 widget
.editingFinished
.connect(self
.apply_filter
)
1138 def apply_filter(self
):
1139 value
= get(self
.text
)
1140 if value
== self
._filter
:
1142 self
._filter
= value
1143 paths
= utils
.shell_split(value
)
1144 self
.main_model
.update_path_filter(paths
)
1147 def customize_copy_actions(context
, parent
):
1148 """Customize copy actions"""
1149 dialog
= CustomizeCopyActions(context
, parent
)
1154 class CustomizeCopyActions(standard
.Dialog
):
1156 def __init__(self
, context
, parent
):
1157 standard
.Dialog
.__init
__(self
, parent
=parent
)
1158 self
.setWindowTitle(N_('Custom Copy Actions'))
1160 self
.table
= QtWidgets
.QTableWidget(self
)
1161 self
.table
.setColumnCount(2)
1162 self
.table
.setHorizontalHeaderLabels([
1164 N_('Format String'),
1166 self
.table
.setSortingEnabled(False)
1167 self
.table
.verticalHeader().hide()
1168 self
.table
.horizontalHeader().setStretchLastSection(True)
1170 self
.add_button
= qtutils
.create_button(N_('Add'))
1171 self
.remove_button
= qtutils
.create_button(N_('Remove'))
1172 self
.remove_button
.setEnabled(False)
1173 self
.show_help_button
= qtutils
.create_button(N_('Show Help'))
1174 self
.show_help_button
.setShortcut(hotkeys
.QUESTION
)
1176 self
.close_button
= qtutils
.close_button()
1177 self
.save_button
= qtutils
.ok_button(N_('Save'))
1179 self
.buttons
= qtutils
.hbox(defs
.no_margin
, defs
.button_spacing
,
1182 self
.show_help_button
,
1187 layout
= qtutils
.vbox(defs
.margin
, defs
.spacing
,
1188 self
.table
, self
.buttons
)
1189 self
.setLayout(layout
)
1191 qtutils
.connect_button(self
.add_button
, self
.add
)
1192 qtutils
.connect_button(self
.remove_button
, self
.remove
)
1193 qtutils
.connect_button(
1194 self
.show_help_button
, partial(show_help
, context
))
1195 qtutils
.connect_button(self
.close_button
, self
.reject
)
1196 qtutils
.connect_button(self
.save_button
, self
.save
)
1197 qtutils
.add_close_action(self
)
1198 self
.table
.itemSelectionChanged
.connect(self
.table_selection_changed
)
1200 self
.init_size(parent
=parent
)
1202 self
.settings
= settings
.Settings()
1203 QtCore
.QTimer
.singleShot(0, self
.reload_settings
)
1205 def reload_settings(self
):
1206 # Called once after the GUI is initialized
1207 self
.settings
.load()
1209 for entry
in self
.settings
.copy_formats
:
1210 name_string
= entry
.get('name', '')
1211 format_string
= entry
.get('format', '')
1212 if name_string
and format_string
:
1213 name
= QtWidgets
.QTableWidgetItem(name_string
)
1214 fmt
= QtWidgets
.QTableWidgetItem(format_string
)
1215 rows
= table
.rowCount()
1216 table
.setRowCount(rows
+ 1)
1217 table
.setItem(rows
, 0, name
)
1218 table
.setItem(rows
, 1, fmt
)
1220 def export_state(self
):
1221 state
= super(CustomizeCopyActions
, self
).export_state()
1222 standard
.export_header_columns(self
.table
, state
)
1225 def apply_state(self
, state
):
1226 result
= super(CustomizeCopyActions
, self
).apply_state(state
)
1227 standard
.apply_header_columns(self
.table
, state
)
1231 self
.table
.setFocus()
1232 rows
= self
.table
.rowCount()
1233 self
.table
.setRowCount(rows
+ 1)
1235 name
= QtWidgets
.QTableWidgetItem(N_('Name'))
1236 fmt
= QtWidgets
.QTableWidgetItem(r
'%(path)s')
1237 self
.table
.setItem(rows
, 0, name
)
1238 self
.table
.setItem(rows
, 1, fmt
)
1240 self
.table
.setCurrentCell(rows
, 0)
1241 self
.table
.editItem(name
)
1244 """Remove selected items"""
1245 # Gather a unique set of rows and remove them in reverse order
1247 items
= self
.table
.selectedItems()
1249 rows
.add(self
.table
.row(item
))
1251 for row
in reversed(sorted(rows
)):
1252 self
.table
.removeRow(row
)
1256 for row
in range(self
.table
.rowCount()):
1257 name
= self
.table
.item(row
, 0)
1258 fmt
= self
.table
.item(row
, 1)
1261 'name': name
.text(),
1262 'format': fmt
.text(),
1264 copy_formats
.append(entry
)
1266 while self
.settings
.copy_formats
:
1267 self
.settings
.copy_formats
.pop()
1269 self
.settings
.copy_formats
.extend(copy_formats
)
1270 self
.settings
.save()
1274 def table_selection_changed(self
):
1275 items
= self
.table
.selectedItems()
1276 self
.remove_button
.setEnabled(bool(items
))