6 from PyQt4
import QtGui
7 from PyQt4
.QtCore
import SIGNAL
10 from cola
import signals
11 from cola
import qtutils
12 from cola
.compat
import set
13 from cola
.qtutils
import SLOT
16 def select_item(tree
, item
):
19 tree
.setItemSelected(item
, True)
20 parent
= item
.parent()
22 tree
.scrollToItem(parent
)
23 tree
.scrollToItem(item
)
26 class StatusWidget(QtGui
.QWidget
):
28 Provides a git-status-like repository widget.
30 This widget observes the main model and broadcasts
34 def __init__(self
, parent
=None):
35 QtGui
.QWidget
.__init
__(self
, parent
)
36 self
.layout
= QtGui
.QVBoxLayout(self
)
37 self
.setLayout(self
.layout
)
39 self
.tree
= StatusTreeWidget(self
)
40 self
.layout
.addWidget(self
.tree
)
41 self
.layout
.setContentsMargins(0, 0, 0, 0)
44 class StatusTreeWidget(QtGui
.QTreeWidget
):
53 # Read-only access to the mode state
54 mode
= property(lambda self
: self
.m
.mode
)
56 def __init__(self
, parent
):
57 QtGui
.QTreeWidget
.__init
__(self
, parent
)
59 self
.setSelectionMode(QtGui
.QAbstractItemView
.ExtendedSelection
)
60 self
.headerItem().setHidden(True)
61 self
.setAllColumnsShowFocus(True)
62 self
.setSortingEnabled(False)
63 self
.setUniformRowHeights(True)
64 self
.setAnimated(True)
66 self
.add_item('Staged', 'plus.png', hide
=True)
67 self
.add_item('Unmerged', 'unmerged.png', hide
=True)
68 self
.add_item('Modified', 'modified.png', hide
=True)
69 self
.add_item('Untracked', 'untracked.png', hide
=True)
71 # Used to restore the selection
72 self
.old_scroll
= None
73 self
.old_selection
= None
74 self
.old_contents
= None
76 self
.expanded_items
= set()
78 self
.process_selection
= qtutils
.add_action(self
,
79 'Process Selection', self
._process
_selection
, 'Ctrl+S')
81 self
.launch_difftool
= qtutils
.add_action(self
,
82 'Process Selection', self
._launch
_difftool
, 'Ctrl+D')
84 self
.launch_difftool
= qtutils
.add_action(self
,
85 'Launch Editor', self
._launch
_editor
, 'Ctrl+E')
87 self
.up
= qtutils
.add_action(self
, 'Move Up', self
.move_up
, 'K')
88 self
.down
= qtutils
.add_action(self
, 'Move Down', self
.move_down
, 'J')
90 self
.connect(self
, SIGNAL('about_to_update'), self
._about
_to
_update
)
91 self
.connect(self
, SIGNAL('updated'), self
._updated
)
94 self
.m
.add_message_observer(self
.m
.message_about_to_update
,
96 self
.m
.add_message_observer(self
.m
.message_updated
, self
.updated
)
98 self
.connect(self
, SIGNAL('itemSelectionChanged()'),
101 SIGNAL('itemDoubleClicked(QTreeWidgetItem*,int)'),
104 SIGNAL('itemClicked(QTreeWidgetItem*,int)'),
107 def add_item(self
, txt
, path
, hide
=False):
108 """Create a new top-level item in the status tree."""
109 item
= QtGui
.QTreeWidgetItem(self
)
110 item
.setText(0, self
.tr(txt
))
111 item
.setIcon(0, qtutils
.icon(path
))
113 self
.setItemHidden(item
, True)
115 def restore_selection(self
):
116 if not self
.old_selection
or not self
.old_contents
:
119 (staged
, modified
, unmerged
, untracked
) = self
.old_contents
121 (staged_sel
, modified_sel
,
122 unmerged_sel
, untracked_sel
) = self
.old_selection
124 (updated_staged
, updated_modified
,
125 updated_unmerged
, updated_untracked
) = self
.contents()
127 def select_modified(item
):
128 idx
= updated_modified
.index(item
)
129 select_item(self
, self
.modified_item(idx
))
131 def select_unmerged(item
):
132 idx
= updated_unmerged
.index(item
)
133 select_item(self
, self
.unmerged_item(idx
))
135 def select_untracked(item
):
136 idx
= updated_untracked
.index(item
)
137 select_item(self
, self
.untracked_item(idx
))
139 def select_staged(item
):
140 idx
= updated_staged
.index(item
)
141 select_item(self
, self
.staged_item(idx
))
143 restore_selection_actions
= (
144 (updated_modified
, modified
, modified_sel
, select_modified
),
145 (updated_unmerged
, unmerged
, unmerged_sel
, select_unmerged
),
146 (updated_untracked
, untracked
, untracked_sel
, select_untracked
),
147 (updated_staged
, staged
, staged_sel
, select_staged
),
150 for (new
, old
, selection
, action
) in restore_selection_actions
:
151 # When modified is staged, select the next modified item
152 # When unmerged is staged, select the next unmerged item
153 # When untracked is staged, select the next untracked item
154 # When something is unstaged we should select the next staged item
156 if len(new
) < len(old
) and old
:
157 for idx
, i
in enumerate(old
):
159 for j
in itertools
.chain(old
[idx
+1:],
160 reversed(old
[:idx
])):
165 for (new
, old
, selection
, action
) in restore_selection_actions
:
166 # Reselect items when doing partial-staging
168 for item
in selection
:
172 def staged_item(self
, itemidx
):
173 return self
._subtree
_item
(self
.idx_staged
, itemidx
)
175 def modified_item(self
, itemidx
):
176 return self
._subtree
_item
(self
.idx_modified
, itemidx
)
178 def unmerged_item(self
, itemidx
):
179 return self
._subtree
_item
(self
.idx_unmerged
, itemidx
)
181 def untracked_item(self
, itemidx
):
182 return self
._subtree
_item
(self
.idx_untracked
, itemidx
)
184 def unstaged_item(self
, itemidx
):
186 item
= self
.topLevelItem(self
.idx_modified
)
187 count
= item
.childCount()
189 return item
.child(itemidx
)
191 item
= self
.topLevelItem(self
.idx_unmerged
)
192 count
+= item
.childCount()
194 return item
.child(itemidx
)
196 item
= self
.topLevelItem(self
.idx_untracked
)
197 count
+= item
.childCount()
199 return item
.child(itemidx
)
203 def _subtree_item(self
, idx
, itemidx
):
204 parent
= self
.topLevelItem(idx
)
205 return parent
.child(itemidx
)
207 def about_to_update(self
):
208 self
.emit(SIGNAL('about_to_update'))
210 def _about_to_update(self
):
211 self
.old_selection
= copy
.deepcopy(self
.selection())
212 self
.old_contents
= copy
.deepcopy(self
.contents())
214 self
.old_scroll
= None
215 vscroll
= self
.verticalScrollBar()
217 self
.old_scroll
= vscroll
.value()
220 """Update display from model data."""
221 self
.emit(SIGNAL('updated'))
224 self
.set_staged(self
.m
.staged
)
225 self
.set_modified(self
.m
.modified
)
226 self
.set_unmerged(self
.m
.unmerged
)
227 self
.set_untracked(self
.m
.untracked
)
229 vscroll
= self
.verticalScrollBar()
230 if vscroll
and self
.old_scroll
is not None:
231 vscroll
.setValue(self
.old_scroll
)
232 self
.old_scroll
= None
234 self
.restore_selection()
236 if not self
.m
.staged
:
239 staged
= self
.topLevelItem(self
.idx_staged
)
240 if self
.mode
in self
.m
.modes_read_only
:
241 staged
.setText(0, self
.tr('Changed'))
243 staged
.setText(0, self
.tr('Staged'))
245 def set_staged(self
, items
):
246 """Adds items to the 'Staged' subtree."""
247 self
._set
_subtree
(items
, self
.idx_staged
, staged
=True,
248 check
=not self
.m
.read_only())
250 def set_modified(self
, items
):
251 """Adds items to the 'Modified' subtree."""
252 self
._set
_subtree
(items
, self
.idx_modified
)
254 def set_unmerged(self
, items
):
255 """Adds items to the 'Unmerged' subtree."""
256 self
._set
_subtree
(items
, self
.idx_unmerged
)
258 def set_untracked(self
, items
):
259 """Adds items to the 'Untracked' subtree."""
260 self
._set
_subtree
(items
, self
.idx_untracked
)
262 def _set_subtree(self
, items
, idx
,
266 """Add a list of items to a treewidget item."""
267 parent
= self
.topLevelItem(idx
)
269 self
.setItemHidden(parent
, False)
271 self
.setItemHidden(parent
, True)
272 parent
.takeChildren()
274 treeitem
= qtutils
.create_treeitem(item
,
278 parent
.addChild(treeitem
)
279 self
.expand_items(idx
, items
)
281 def expand_items(self
, idx
, items
):
282 """Expand the top-level category "folder" once and only once."""
283 # Don't do this if items is empty; this makes it so that we
284 # don't add the top-level index into the expanded_items set
285 # until an item appears in a particular category.
288 # Only run this once; we don't want to re-expand items that
289 # we've clicked on to re-collapse on updated().
290 if idx
in self
.expanded_items
:
292 self
.expanded_items
.add(idx
)
293 item
= self
.topLevelItem(idx
)
295 self
.expandItem(item
)
297 def contextMenuEvent(self
, event
):
298 """Create context menus for the repo status tree."""
299 menu
= self
.create_context_menu()
300 menu
.exec_(self
.mapToGlobal(event
.pos()))
302 def create_context_menu(self
):
303 """Set up the status menu for the repo status tree."""
304 staged
, modified
, unmerged
, untracked
= self
.selection()
305 menu
= QtGui
.QMenu(self
)
307 enable_staging
= self
.m
.enable_staging()
308 if not enable_staging
:
309 menu
.addAction(qtutils
.icon('remove.svg'),
310 self
.tr('Unstage Selected'),
311 SLOT(signals
.unstage
, self
.staged()))
313 if staged
and staged
[0] in self
.m
.submodules
:
314 menu
.addAction(qtutils
.git_icon(),
315 self
.tr('Launch git-cola'),
316 SLOT(signals
.open_repo
, os
.path
.abspath(staged
[0])))
320 menu
.addAction(qtutils
.icon('open.svg'),
321 self
.tr('Launch Editor'),
322 SLOT(signals
.edit
, self
.staged()))
323 menu
.addAction(qtutils
.git_icon(),
324 self
.tr('Launch Diff Tool'),
325 SLOT(signals
.difftool
, True, self
.staged()))
327 menu
.addAction(qtutils
.icon('undo.svg'),
328 self
.tr('Revert Unstaged Edits...'),
329 lambda: self
._revert
_unstaged
_edits
(use_staged
=True))
333 menu
.addAction(qtutils
.git_icon(),
334 self
.tr('Launch Merge Tool'),
335 SLOT(signals
.mergetool
, self
.unmerged()))
336 menu
.addAction(qtutils
.icon('open.svg'),
337 self
.tr('Launch Editor'),
338 SLOT(signals
.edit
, self
.unmerged()))
340 menu
.addAction(qtutils
.icon('add.svg'),
341 self
.tr('Stage Selected'),
342 SLOT(signals
.stage
, self
.unmerged()))
345 modified_submodule
= (modified
and
346 modified
[0] in self
.m
.submodules
)
348 menu
.addAction(qtutils
.icon('add.svg'),
349 self
.tr('Stage Selected'),
350 SLOT(signals
.stage
, self
.unstaged()))
353 if modified_submodule
:
354 menu
.addAction(qtutils
.git_icon(),
355 self
.tr('Launch git-cola'),
356 SLOT(signals
.open_repo
,
357 os
.path
.abspath(modified
[0])))
358 elif self
.unstaged():
359 menu
.addAction(qtutils
.icon('open.svg'),
360 self
.tr('Launch Editor'),
361 SLOT(signals
.edit
, self
.unstaged()))
363 if modified
and enable_staging
and not modified_submodule
:
364 menu
.addAction(qtutils
.git_icon(),
365 self
.tr('Launch Diff Tool'),
366 SLOT(signals
.difftool
, False, self
.modified()))
368 menu
.addAction(qtutils
.icon('undo.svg'),
369 self
.tr('Revert Unstaged Edits...'),
370 self
._revert
_unstaged
_edits
)
371 menu
.addAction(qtutils
.icon('undo.svg'),
372 self
.tr('Revert Uncommited Edits...'),
373 self
._revert
_uncommitted
_edits
)
377 menu
.addAction(qtutils
.discard_icon(),
378 self
.tr('Delete File(s)...'), self
._delete
_files
)
380 menu
.addAction(qtutils
.icon('edit-clear.svg'),
381 self
.tr('Add to .gitignore'),
383 map(lambda x
: '/' + x
, self
.untracked())))
386 def _delete_files(self
):
387 files
= self
.untracked()
392 title
= 'Delete Files?'
393 msg
= self
.tr('The following files will be deleted:\n\n')
395 fileinfo
= subprocess
.list2cmdline(files
)
396 if len(fileinfo
) > 2048:
397 fileinfo
= fileinfo
[:2048].rstrip() + '...'
400 info_txt
= unicode(self
.tr('Delete %d file(s)?')) % count
401 ok_txt
= 'Delete Files'
403 if qtutils
.confirm(title
, msg
, info_txt
, ok_txt
,
405 icon
=qtutils
.discard_icon()):
406 cola
.notifier().broadcast(signals
.delete
, files
)
408 def _revert_unstaged_edits(self
, use_staged
=False):
409 if not self
.m
.undoable():
412 items_to_undo
= self
.staged()
414 items_to_undo
= self
.modified()
417 if not qtutils
.confirm('Revert Unstaged Changes?',
418 'This operation drops unstaged changes.'
419 '\nThese changes cannot be recovered.',
420 'Revert the unstaged changes?',
421 'Revert Unstaged Changes',
423 icon
=qtutils
.icon('undo.svg')):
425 cola
.notifier().broadcast(signals
.checkout
,
426 ['--'] + items_to_undo
)
428 qtutils
.log(1, self
.tr('No files selected for '
429 'checkout from HEAD.'))
431 def _revert_uncommitted_edits(self
):
432 if not self
.m
.undoable():
434 items_to_undo
= self
.modified()
436 if not qtutils
.confirm('Revert Uncommitted Changes?',
437 'This operation drops uncommitted changes.'
438 '\nThese changes cannot be recovered.',
439 'Revert the uncommitted changes?',
440 'Revert Uncommitted Changes',
442 icon
=qtutils
.icon('undo.svg')):
444 cola
.notifier().broadcast(signals
.checkout
,
445 ['HEAD', '--'] + items_to_undo
)
447 qtutils
.log(1, self
.tr('No files selected for '
448 'checkout from HEAD.'))
450 def single_selection(self
):
451 """Scan across staged, modified, etc. and return a single item."""
452 staged
, modified
, unmerged
, untracked
= self
.selection()
467 def selected_indexes(self
):
468 """Returns a list of (category, row) representing the tree selection."""
469 selected
= self
.selectedIndexes()
472 if idx
.parent().isValid():
473 parent_idx
= idx
.parent()
474 entry
= (parent_idx
.row(), idx
.row())
476 entry
= (-1, idx
.row())
481 """Return the current selection in the repo status tree."""
482 return (self
.staged(), self
.modified(),
483 self
.unmerged(), self
.untracked())
486 return (self
.m
.staged
, self
.m
.modified
,
487 self
.m
.unmerged
, self
.m
.untracked
)
490 return self
._subtree
_selection
(self
.idx_staged
, self
.m
.staged
)
493 return self
.modified() + self
.unmerged() + self
.untracked()
496 return self
._subtree
_selection
(self
.idx_modified
, self
.m
.modified
)
499 return self
._subtree
_selection
(self
.idx_unmerged
, self
.m
.unmerged
)
502 return self
._subtree
_selection
(self
.idx_untracked
, self
.m
.untracked
)
504 def _subtree_selection(self
, idx
, items
):
505 item
= self
.topLevelItem(idx
)
506 return qtutils
.tree_selection(item
, items
)
508 def mouseReleaseEvent(self
, event
):
509 result
= QtGui
.QTreeWidget
.mouseReleaseEvent(self
, event
)
513 def clicked(self
, item
=None, idx
=None):
514 """Called when a repo status tree item is clicked.
516 This handles the behavior where clicking on the icon invokes
517 the a context-specific action.
520 if self
.m
.read_only():
523 # Sync the selection model
524 staged
, modified
, unmerged
, untracked
= self
.selection()
525 cola
.selection_model().set_selection(staged
, modified
,
528 # Clear the selection if an empty area was clicked
529 selection
= self
.selected_indexes()
531 if self
.mode
== self
.m
.mode_amend
:
532 cola
.notifier().broadcast(signals
.set_diff_text
, '')
534 cola
.notifier().broadcast(signals
.reset_mode
)
535 self
.blockSignals(True)
536 self
.clearSelection()
537 self
.blockSignals(False)
541 qtutils
.set_clipboard(staged
[0])
543 qtutils
.set_clipboard(modified
[0])
545 qtutils
.set_clipboard(unmerged
[0])
547 qtutils
.set_clipboard(untracked
[0])
549 def double_clicked(self
, item
, idx
):
550 """Called when an item is double-clicked in the repo status tree."""
551 self
._process
_selection
()
553 def _process_selection(self
):
554 if self
.m
.read_only():
556 staged
, modified
, unmerged
, untracked
= self
.selection()
558 cola
.notifier().broadcast(signals
.unstage
, staged
)
560 cola
.notifier().broadcast(signals
.stage
, modified
)
562 cola
.notifier().broadcast(signals
.stage
, unmerged
)
564 cola
.notifier().broadcast(signals
.stage
, untracked
)
566 def _launch_difftool(self
):
567 staged
, modified
, unmerged
, untracked
= self
.selection()
576 cola
.notifier().broadcast(signals
.difftool
, bool(staged
), selection
)
578 def _launch_editor(self
):
579 staged
, modified
, unmerged
, untracked
= self
.selection()
587 selection
= untracked
590 cola
.notifier().broadcast(signals
.edit
, selection
)
592 def show_selection(self
):
593 """Show the selected item."""
594 # Sync the selection model
595 s
, m
, um
, ut
= self
.selection()
596 cola
.selection_model().set_selection(s
, m
, um
, ut
)
598 selection
= self
.selected_indexes()
601 category
, idx
= selection
[0]
602 # A header item e.g. 'Staged', 'Modified', etc.
603 if category
== self
.idx_header
:
605 self
.idx_staged
: signals
.staged_summary
,
606 self
.idx_modified
: signals
.modified_summary
,
607 self
.idx_unmerged
: signals
.unmerged_summary
,
608 self
.idx_untracked
: signals
.untracked_summary
,
609 }.get(idx
, signals
.diffstat
)
610 cola
.notifier().broadcast(signal
)
612 elif category
== self
.idx_staged
:
613 cola
.notifier().broadcast(signals
.diff_staged
, self
.staged())
616 elif category
== self
.idx_modified
:
617 cola
.notifier().broadcast(signals
.diff
, self
.modified())
619 elif category
== self
.idx_unmerged
:
620 cola
.notifier().broadcast(signals
.diff
, self
.unmerged())
622 elif category
== self
.idx_untracked
:
623 cola
.notifier().broadcast(signals
.show_untracked
, self
.unstaged())
631 def move(self
, direction
):
632 staged
, modified
, unmerged
, untracked
= self
.single_selection()
634 (staged
, self
.m
.staged
, self
.idx_staged
),
635 (unmerged
, self
.m
.unmerged
, self
.idx_unmerged
),
636 (modified
, self
.m
.modified
, self
.idx_modified
),
637 (untracked
, self
.m
.untracked
, self
.idx_untracked
),
639 going_up
= direction
== 'up'
641 self
.scrollToItem(item
)
642 self
.setCurrentItem(item
)
643 self
.setItemSelected(item
, True)
645 has_selection
= [i
[0] for i
in to_try
if i
[0] is not None]
646 if not has_selection
:
647 indexes
= self
.selectedIndexes()
649 # No files are selected but the tree has a selection;
650 # it must be one of the container items.
652 parent_item
= self
.itemFromIndex(index
)
654 item
= self
.itemAbove(parent_item
)
656 item
= self
.itemBelow(parent_item
)
662 # go to the last item
663 for dummy
, itemlist
, parent_idx
in reversed(to_try
):
664 idx
= len(itemlist
)-1
666 parent
= self
.topLevelItem(parent_idx
)
667 select(parent
.child(idx
))
670 # go to the first item
671 for dummy
, itemlist
, parent_idx
in to_try
:
673 parent
= self
.topLevelItem(parent_idx
)
674 item
= parent
.child(0)
675 select(parent
.child(0))
679 for item
, itemlist
, parent_idx
in to_try
:
682 idx
= itemlist
.index(item
)
685 parent
= self
.topLevelItem(parent_idx
)
686 select(parent
.child(idx
- 1))
689 if idx
< len(itemlist
) - 1:
690 parent
= self
.topLevelItem(parent_idx
)
691 select(parent
.child(idx
+ 1))
694 # Jump across category boundaries to select the next file.
695 # We do not use itemAbove/Below because we want to avoid
696 # selecting the 'Staged', 'Modified', etc. parent items
705 for item
, itemlist
, parent_idx
in to_try
:
706 if item
is not None and not ready
:
711 best_idx
= parent_idx
714 parent
= self
.topLevelItem(best_idx
)
716 select(parent
.child(len(best_list
) - 1))
718 select(parent
.child(0))