5 from PyQt4
import QtGui
6 from PyQt4
.QtCore
import SIGNAL
9 from cola
import signals
10 from cola
import qtutils
11 from cola
.compat
import set
12 from cola
.qtutils
import SLOT
13 from cola
.widgets
import defs
14 from cola
.models
.selection
import State
17 def select_item(tree
, item
):
20 tree
.setItemSelected(item
, True)
21 parent
= item
.parent()
23 tree
.scrollToItem(parent
)
24 tree
.scrollToItem(item
)
27 class StatusWidget(QtGui
.QWidget
):
29 Provides a git-status-like repository widget.
31 This widget observes the main model and broadcasts
35 def __init__(self
, parent
=None):
36 QtGui
.QWidget
.__init
__(self
, parent
)
37 self
.layout
= QtGui
.QVBoxLayout(self
)
38 self
.setLayout(self
.layout
)
40 self
.tree
= StatusTreeWidget(self
)
41 self
.layout
.addWidget(self
.tree
)
42 self
.layout
.setContentsMargins(0, 0, 0, 0)
45 class StatusTreeWidget(QtGui
.QTreeWidget
):
54 # Read-only access to the mode state
55 mode
= property(lambda self
: self
.m
.mode
)
57 def __init__(self
, parent
):
58 QtGui
.QTreeWidget
.__init
__(self
, parent
)
60 self
.setSelectionMode(QtGui
.QAbstractItemView
.ExtendedSelection
)
61 self
.headerItem().setHidden(True)
62 self
.setAllColumnsShowFocus(True)
63 self
.setSortingEnabled(False)
64 self
.setUniformRowHeights(True)
65 self
.setAnimated(True)
66 self
.setRootIsDecorated(False)
68 self
.add_item('Staged', 'plus.png', hide
=True)
69 self
.add_item('Unmerged', 'unmerged.png', hide
=True)
70 self
.add_item('Modified', 'modified.png', hide
=True)
71 self
.add_item('Untracked', 'untracked.png', hide
=True)
73 # Used to restore the selection
74 self
.old_scroll
= None
75 self
.old_selection
= None
76 self
.old_contents
= None
78 self
.expanded_items
= set()
80 self
.process_selection
= qtutils
.add_action(self
,
81 'Process Selection', self
._process
_selection
,
84 self
.launch_difftool
= qtutils
.add_action(self
,
85 'Launch Diff Tool', self
._launch
_difftool
,
86 defs
.difftool_shortcut
)
87 self
.launch_difftool
.setIcon(qtutils
.icon('git.svg'))
89 self
.launch_editor
= qtutils
.add_action(self
,
90 'Launch Editor', self
._launch
_editor
,
92 self
.launch_editor
.setIcon(qtutils
.open_file_icon())
94 self
.up
= qtutils
.add_action(self
, 'Move Up', self
.move_up
, 'K')
95 self
.down
= qtutils
.add_action(self
, 'Move Down', self
.move_down
, 'J')
97 self
.connect(self
, SIGNAL('about_to_update'), self
._about
_to
_update
)
98 self
.connect(self
, SIGNAL('updated'), self
._updated
)
100 self
.m
= cola
.model()
101 self
.m
.add_observer(self
.m
.message_about_to_update
,
102 self
.about_to_update
)
103 self
.m
.add_observer(self
.m
.message_updated
, self
.updated
)
105 self
.connect(self
, SIGNAL('itemSelectionChanged()'),
109 SIGNAL('itemDoubleClicked(QTreeWidgetItem*,int)'),
113 SIGNAL('itemCollapsed(QTreeWidgetItem*)'),
114 lambda x
: self
.update_column_widths())
117 SIGNAL('itemExpanded(QTreeWidgetItem*)'),
118 lambda x
: self
.update_column_widths())
120 def add_item(self
, txt
, path
, hide
=False):
121 """Create a new top-level item in the status tree."""
122 item
= QtGui
.QTreeWidgetItem(self
)
123 item
.setText(0, self
.tr(txt
))
124 item
.setIcon(0, qtutils
.icon(path
))
126 self
.setItemHidden(item
, True)
128 def restore_selection(self
):
129 if not self
.old_selection
or not self
.old_contents
:
132 old_c
= self
.old_contents
133 old_s
= self
.old_selection
134 new_c
= self
.contents()
136 def select_modified(item
):
137 idx
= new_c
.modified
.index(item
)
138 select_item(self
, self
.modified_item(idx
))
140 def select_unmerged(item
):
141 idx
= new_c
.unmerged
.index(item
)
142 select_item(self
, self
.unmerged_item(idx
))
144 def select_untracked(item
):
145 idx
= new_c
.untracked
.index(item
)
146 select_item(self
, self
.untracked_item(idx
))
148 def select_staged(item
):
149 idx
= new_c
.staged
.index(item
)
150 select_item(self
, self
.staged_item(idx
))
152 restore_selection_actions
= (
153 (new_c
.modified
, old_c
.modified
, old_s
.modified
, select_modified
),
154 (new_c
.unmerged
, old_c
.unmerged
, old_s
.unmerged
, select_unmerged
),
155 (new_c
.untracked
, old_c
.untracked
, old_s
.untracked
, select_untracked
),
156 (new_c
.staged
, old_c
.staged
, old_s
.staged
, select_staged
),
159 for (new
, old
, selection
, action
) in restore_selection_actions
:
160 # When modified is staged, select the next modified item
161 # When unmerged is staged, select the next unmerged item
162 # When untracked is staged, select the next untracked item
163 # When something is unstaged we should select the next staged item
165 if len(new
) < len(old
) and old
:
166 for idx
, i
in enumerate(old
):
168 for j
in itertools
.chain(old
[idx
+1:],
169 reversed(old
[:idx
])):
174 for (new
, old
, selection
, action
) in restore_selection_actions
:
175 # Reselect items when doing partial-staging
177 for item
in selection
:
181 def staged_item(self
, itemidx
):
182 return self
._subtree
_item
(self
.idx_staged
, itemidx
)
184 def modified_item(self
, itemidx
):
185 return self
._subtree
_item
(self
.idx_modified
, itemidx
)
187 def unmerged_item(self
, itemidx
):
188 return self
._subtree
_item
(self
.idx_unmerged
, itemidx
)
190 def untracked_item(self
, itemidx
):
191 return self
._subtree
_item
(self
.idx_untracked
, itemidx
)
193 def unstaged_item(self
, itemidx
):
195 item
= self
.topLevelItem(self
.idx_modified
)
196 count
= item
.childCount()
198 return item
.child(itemidx
)
200 item
= self
.topLevelItem(self
.idx_unmerged
)
201 count
+= item
.childCount()
203 return item
.child(itemidx
)
205 item
= self
.topLevelItem(self
.idx_untracked
)
206 count
+= item
.childCount()
208 return item
.child(itemidx
)
212 def _subtree_item(self
, idx
, itemidx
):
213 parent
= self
.topLevelItem(idx
)
214 return parent
.child(itemidx
)
216 def about_to_update(self
):
217 self
.emit(SIGNAL('about_to_update'))
219 def _about_to_update(self
):
220 self
.old_selection
= self
.selection()
221 self
.old_contents
= self
.contents()
223 self
.old_scroll
= None
224 vscroll
= self
.verticalScrollBar()
226 self
.old_scroll
= vscroll
.value()
229 """Update display from model data."""
230 self
.emit(SIGNAL('updated'))
233 self
.set_staged(self
.m
.staged
)
234 self
.set_modified(self
.m
.modified
)
235 self
.set_unmerged(self
.m
.unmerged
)
236 self
.set_untracked(self
.m
.untracked
)
238 vscroll
= self
.verticalScrollBar()
239 if vscroll
and self
.old_scroll
is not None:
240 vscroll
.setValue(self
.old_scroll
)
241 self
.old_scroll
= None
243 self
.restore_selection()
244 self
.update_column_widths()
246 def set_staged(self
, items
):
247 """Adds items to the 'Staged' subtree."""
248 self
._set
_subtree
(items
, self
.idx_staged
, staged
=True,
249 check
=not self
.m
.amending())
251 def set_modified(self
, items
):
252 """Adds items to the 'Modified' subtree."""
253 self
._set
_subtree
(items
, self
.idx_modified
)
255 def set_unmerged(self
, items
):
256 """Adds items to the 'Unmerged' subtree."""
257 self
._set
_subtree
(items
, self
.idx_unmerged
)
259 def set_untracked(self
, items
):
260 """Adds items to the 'Untracked' subtree."""
261 self
._set
_subtree
(items
, self
.idx_untracked
)
263 def _set_subtree(self
, items
, idx
,
267 """Add a list of items to a treewidget item."""
268 parent
= self
.topLevelItem(idx
)
270 self
.setItemHidden(parent
, False)
272 self
.setItemHidden(parent
, True)
273 parent
.takeChildren()
275 treeitem
= qtutils
.create_treeitem(item
,
279 parent
.addChild(treeitem
)
280 self
.expand_items(idx
, items
)
282 def update_column_widths(self
):
283 self
.resizeColumnToContents(0)
285 def expand_items(self
, idx
, items
):
286 """Expand the top-level category "folder" once and only once."""
287 # Don't do this if items is empty; this makes it so that we
288 # don't add the top-level index into the expanded_items set
289 # until an item appears in a particular category.
292 # Only run this once; we don't want to re-expand items that
293 # we've clicked on to re-collapse on updated().
294 if idx
in self
.expanded_items
:
296 self
.expanded_items
.add(idx
)
297 item
= self
.topLevelItem(idx
)
299 self
.expandItem(item
)
301 def contextMenuEvent(self
, event
):
302 """Create context menus for the repo status tree."""
303 menu
= self
.create_context_menu()
304 menu
.exec_(self
.mapToGlobal(event
.pos()))
306 def create_context_menu(self
):
307 """Set up the status menu for the repo status tree."""
309 menu
= QtGui
.QMenu(self
)
311 selection
= self
.selected_indexes()
313 category
, idx
= selection
[0]
314 # A header item e.g. 'Staged', 'Modified', etc.
315 if category
== self
.idx_header
:
316 if idx
== self
.idx_staged
:
317 menu
.addAction(qtutils
.icon('remove.svg'),
318 self
.tr('Unstage All'),
319 SLOT(signals
.unstage_all
))
321 elif idx
== self
.idx_unmerged
:
322 menu
.addAction(qtutils
.icon('add.svg'),
323 self
.tr('Stage Merged'),
324 SLOT(signals
.stage_unmerged
))
326 elif idx
== self
.idx_modified
:
327 menu
.addAction(qtutils
.icon('add.svg'),
328 self
.tr('Stage Modified'),
329 SLOT(signals
.stage_modified
))
332 elif idx
== self
.idx_untracked
:
333 menu
.addAction(qtutils
.icon('add.svg'),
334 self
.tr('Stage Untracked'),
335 SLOT(signals
.stage_untracked
))
338 if s
.staged
and self
.m
.unstageable():
339 menu
.addAction(qtutils
.icon('remove.svg'),
340 self
.tr('Unstage Selected'),
341 SLOT(signals
.unstage
, self
.staged()))
343 if s
.staged
and s
.staged
[0] in self
.m
.submodules
:
344 menu
.addAction(qtutils
.git_icon(),
345 self
.tr('Launch git-cola'),
346 SLOT(signals
.open_repo
,
347 os
.path
.abspath(s
.staged
[0])))
351 menu
.addAction(qtutils
.icon('open.svg'),
352 self
.tr('Launch Editor'),
353 SLOT(signals
.edit
, self
.staged()))
354 menu
.addAction(qtutils
.git_icon(),
355 self
.tr('Launch Diff Tool'),
356 SLOT(signals
.difftool
, True, self
.staged()))
357 if self
.m
.undoable():
359 menu
.addAction(qtutils
.icon('undo.svg'),
360 self
.tr('Revert Unstaged Edits...'),
361 lambda: self
._revert
_unstaged
_edits
(staged
=True))
365 menu
.addAction(qtutils
.git_icon(),
366 self
.tr('Launch Merge Tool'),
367 SLOT(signals
.mergetool
, self
.unmerged()))
368 menu
.addAction(qtutils
.icon('open.svg'),
369 self
.tr('Launch Editor'),
370 SLOT(signals
.edit
, self
.unmerged()))
372 menu
.addAction(qtutils
.icon('add.svg'),
373 self
.tr('Stage Selected'),
374 SLOT(signals
.stage
, self
.unstaged()))
377 modified_submodule
= (s
.modified
and
378 s
.modified
[0] in self
.m
.submodules
)
379 if self
.m
.stageable():
380 menu
.addAction(qtutils
.icon('add.svg'),
381 self
.tr('Stage Selected'),
382 SLOT(signals
.stage
, self
.unstaged()))
385 if modified_submodule
:
386 menu
.addAction(qtutils
.git_icon(),
387 self
.tr('Launch git-cola'),
388 SLOT(signals
.open_repo
,
389 os
.path
.abspath(s
.modified
[0])))
390 elif self
.unstaged():
391 menu
.addAction(qtutils
.icon('open.svg'),
392 self
.tr('Launch Editor'),
393 SLOT(signals
.edit
, self
.unstaged()))
395 if s
.modified
and self
.m
.stageable() and not modified_submodule
:
396 menu
.addAction(qtutils
.git_icon(),
397 self
.tr('Launch Diff Tool'),
398 SLOT(signals
.difftool
, False, self
.modified()))
400 if self
.m
.undoable():
401 menu
.addAction(qtutils
.icon('undo.svg'),
402 self
.tr('Revert Unstaged Edits...'),
403 self
._revert
_unstaged
_edits
)
404 menu
.addAction(qtutils
.icon('undo.svg'),
405 self
.tr('Revert Uncommited Edits...'),
406 self
._revert
_uncommitted
_edits
)
410 menu
.addAction(qtutils
.discard_icon(),
411 self
.tr('Delete File(s)...'), self
._delete
_files
)
413 menu
.addAction(qtutils
.icon('edit-clear.svg'),
414 self
.tr('Add to .gitignore'),
416 map(lambda x
: '/' + x
, self
.untracked())))
419 def _delete_files(self
):
420 files
= self
.untracked()
425 title
= 'Delete Files?'
426 msg
= self
.tr('The following files will be deleted:\n\n')
428 fileinfo
= subprocess
.list2cmdline(files
)
429 if len(fileinfo
) > 2048:
430 fileinfo
= fileinfo
[:2048].rstrip() + '...'
433 info_txt
= unicode(self
.tr('Delete %d file(s)?')) % count
434 ok_txt
= 'Delete Files'
436 if qtutils
.confirm(title
, msg
, info_txt
, ok_txt
,
438 icon
=qtutils
.discard_icon()):
439 cola
.notifier().broadcast(signals
.delete
, files
)
441 def _revert_unstaged_edits(self
, staged
=False):
442 if not self
.m
.undoable():
445 items_to_undo
= self
.staged()
447 items_to_undo
= self
.modified()
450 if not qtutils
.confirm('Revert Unstaged Changes?',
451 'This operation drops unstaged changes.'
452 '\nThese changes cannot be recovered.',
453 'Revert the unstaged changes?',
454 'Revert Unstaged Changes',
456 icon
=qtutils
.icon('undo.svg')):
459 if not staged
and self
.m
.amending():
460 args
.append(self
.m
.head
)
461 cola
.notifier().broadcast(signals
.checkout
,
462 args
+ ['--'] + items_to_undo
)
464 qtutils
.log(1, self
.tr('No files selected for '
465 'checkout from HEAD.'))
467 def _revert_uncommitted_edits(self
):
468 items_to_undo
= self
.modified()
470 if not qtutils
.confirm('Revert Uncommitted Changes?',
471 'This operation drops uncommitted changes.'
472 '\nThese changes cannot be recovered.',
473 'Revert the uncommitted changes?',
474 'Revert Uncommitted Changes',
476 icon
=qtutils
.icon('undo.svg')):
478 cola
.notifier().broadcast(signals
.checkout
,
479 [self
.m
.head
, '--'] + items_to_undo
)
481 qtutils
.log(1, self
.tr('No files selected for '
482 'checkout from HEAD.'))
484 def single_selection(self
):
485 """Scan across staged, modified, etc. and return a single item."""
501 return State(st
, um
, m
, ut
)
503 def selected_indexes(self
):
504 """Returns a list of (category, row) representing the tree selection."""
505 selected
= self
.selectedIndexes()
508 if idx
.parent().isValid():
509 parent_idx
= idx
.parent()
510 entry
= (parent_idx
.row(), idx
.row())
512 entry
= (-1, idx
.row())
517 """Return the current selection in the repo status tree."""
518 return State(self
.staged(), self
.unmerged(),
519 self
.modified(), self
.untracked())
522 return State(self
.m
.staged
, self
.m
.unmerged
,
523 self
.m
.modified
, self
.m
.untracked
)
527 return c
.staged
+ c
.unmerged
+ c
.modified
+ c
.untracked
529 def selected_idx(self
):
531 s
= self
.single_selection()
533 for content
, selection
in zip(c
, s
):
534 if len(content
) == 0:
536 if selection
is not None:
537 return offset
+ content
.index(selection
)
538 offset
+= len(content
)
541 def select_by_index(self
, idx
):
544 (c
.staged
, self
.idx_staged
),
545 (c
.unmerged
, self
.idx_unmerged
),
546 (c
.modified
, self
.idx_modified
),
547 (c
.untracked
, self
.idx_untracked
),
549 for content
, toplevel_idx
in to_try
:
550 if len(content
) == 0:
552 if idx
< len(content
):
553 parent
= self
.topLevelItem(toplevel_idx
)
554 item
= parent
.child(idx
)
555 self
.select_item(item
)
559 def select_item(self
, item
):
560 self
.scrollToItem(item
)
561 self
.setCurrentItem(item
)
562 self
.setItemSelected(item
, True)
565 return self
._subtree
_selection
(self
.idx_staged
, self
.m
.staged
)
568 return self
.unmerged() + self
.modified() + self
.untracked()
571 return self
._subtree
_selection
(self
.idx_modified
, self
.m
.modified
)
574 return self
._subtree
_selection
(self
.idx_unmerged
, self
.m
.unmerged
)
577 return self
._subtree
_selection
(self
.idx_untracked
, self
.m
.untracked
)
579 def _subtree_selection(self
, idx
, items
):
580 item
= self
.topLevelItem(idx
)
581 return qtutils
.tree_selection(item
, items
)
583 def mouseReleaseEvent(self
, event
):
584 result
= QtGui
.QTreeWidget
.mouseReleaseEvent(self
, event
)
588 def clicked(self
, item
=None, idx
=None):
589 """Called when a repo status tree item is clicked.
591 This handles the behavior where clicking on the icon invokes
592 the a context-specific action.
595 # Sync the selection model
597 cola
.selection_model().set_selection(s
)
599 # Clear the selection if an empty area was clicked
600 selection
= self
.selected_indexes()
602 if self
.m
.amending():
603 cola
.notifier().broadcast(signals
.set_diff_text
, '')
605 cola
.notifier().broadcast(signals
.reset_mode
)
606 self
.blockSignals(True)
607 self
.clearSelection()
608 self
.blockSignals(False)
611 filename
= cola
.selection_model().filename()
612 if filename
is not None:
613 qtutils
.set_clipboard(filename
)
615 def double_clicked(self
, item
, idx
):
616 """Called when an item is double-clicked in the repo status tree."""
617 self
._process
_selection
()
619 def _process_selection(self
):
622 cola
.notifier().broadcast(signals
.unstage
, s
.staged
)
626 unstaged
.extend(s
.unmerged
)
628 unstaged
.extend(s
.modified
)
630 unstaged
.extend(s
.untracked
)
632 cola
.notifier().broadcast(signals
.stage
, unstaged
)
634 def _launch_difftool(self
):
635 staged
, modified
, unmerged
, untracked
= self
.selection()
644 cola
.notifier().broadcast(signals
.difftool
, bool(staged
), selection
)
646 def _launch_editor(self
):
651 selection
= s
.unmerged
653 selection
= s
.modified
655 selection
= s
.untracked
658 cola
.notifier().broadcast(signals
.edit
, selection
)
660 def show_selection(self
):
661 """Show the selected item."""
662 # Sync the selection model
663 cola
.selection_model().set_selection(self
.selection())
665 selection
= self
.selected_indexes()
668 category
, idx
= selection
[0]
669 # A header item e.g. 'Staged', 'Modified', etc.
670 if category
== self
.idx_header
:
672 self
.idx_staged
: signals
.staged_summary
,
673 self
.idx_modified
: signals
.modified_summary
,
674 self
.idx_unmerged
: signals
.unmerged_summary
,
675 self
.idx_untracked
: signals
.untracked_summary
,
676 }.get(idx
, signals
.diffstat
)
677 cola
.notifier().broadcast(signal
)
679 elif category
== self
.idx_staged
:
680 cola
.notifier().broadcast(signals
.diff_staged
, self
.staged())
683 elif category
== self
.idx_modified
:
684 cola
.notifier().broadcast(signals
.diff
, self
.modified())
686 elif category
== self
.idx_unmerged
:
687 cola
.notifier().broadcast(signals
.diff
, self
.unmerged())
689 elif category
== self
.idx_untracked
:
690 cola
.notifier().broadcast(signals
.show_untracked
, self
.unstaged())
693 idx
= self
.selected_idx()
694 all_files
= self
.all_files()
696 selection
= self
.selected_indexes()
698 category
, toplevel_idx
= selection
[0]
699 if category
== self
.idx_header
:
700 item
= self
.itemAbove(self
.topLevelItem(toplevel_idx
))
702 self
.select_item(item
)
705 self
.select_by_index(len(all_files
) - 1)
708 self
.select_by_index(idx
- 1)
710 self
.select_by_index(len(all_files
) - 1)
713 idx
= self
.selected_idx()
714 all_files
= self
.all_files()
716 selection
= self
.selected_indexes()
718 category
, toplevel_idx
= selection
[0]
719 if category
== self
.idx_header
:
720 item
= self
.itemBelow(self
.topLevelItem(toplevel_idx
))
722 self
.select_item(item
)
725 self
.select_by_index(0)
727 if idx
+ 1 < len(all_files
):
728 self
.select_by_index(idx
+ 1)
730 self
.select_by_index(0)