views.status: Add submodule menu actions
[git-cola.git] / cola / views / status.py
blob4006826eb94ea50c58bab50973abe1e88354fdaa
1 import os
3 from PyQt4 import QtGui
4 from PyQt4.QtCore import SIGNAL
6 import cola
7 from cola import signals
8 from cola import qtutils
9 from cola import utils
10 from cola.compat import set
11 from cola.qtutils import SLOT
14 _widget = None
15 def widget(parent=None):
16 global _widget
17 if not _widget:
18 _widget = StatusWidget(parent)
19 return _widget
22 class StatusWidget(QtGui.QWidget):
23 """
24 Provides a git-status-like repository widget.
26 This widget observes the main model and broadcasts
27 Qt signals.
29 """
30 # Item categories
31 idx_header = -1
32 idx_staged = 0
33 idx_modified = 1
34 idx_unmerged = 2
35 idx_untracked = 3
36 idx_end = 4
38 # Read-only access to the mode state
39 mode = property(lambda self: self.model.mode)
41 def __init__(self, parent=None):
42 QtGui.QWidget.__init__(self, parent)
44 self.layout = QtGui.QVBoxLayout(self)
45 self.setLayout(self.layout)
47 self.tree = QtGui.QTreeWidget(self)
48 self.layout.addWidget(self.tree)
49 self.layout.setContentsMargins(0, 0, 0, 0)
51 self.tree.setSelectionMode(QtGui.QAbstractItemView.ExtendedSelection)
52 self.tree.headerItem().setHidden(True)
53 self.tree.setAllColumnsShowFocus(True)
54 self.tree.setSortingEnabled(False)
56 self.add_item('Staged', 'plus.png')
57 self.add_item('Modified', 'modified.png')
58 self.add_item('Unmerged', 'unmerged.png')
59 self.add_item('Untracked', 'untracked.png')
61 # Used to restore the selection
62 self.old_selection = None
64 # Handle these events here
65 self.tree.contextMenuEvent = self.tree_context_menu_event
66 self.tree.mousePressEvent = self.tree_click
68 self.expanded_items = set()
69 self.model = cola.model()
70 self.model.add_message_observer(self.model.message_about_to_update,
71 self.about_to_update)
72 self.model.add_message_observer(self.model.message_updated,
73 self.updated)
74 self.connect(self.tree, SIGNAL('itemSelectionChanged()'),
75 self.tree_selection)
76 self.connect(self.tree,
77 SIGNAL('itemDoubleClicked(QTreeWidgetItem*, int)'),
78 self.tree_doubleclick)
80 def add_item(self, txt, path):
81 """Create a new top-level item in the status tree."""
82 item = QtGui.QTreeWidgetItem(self.tree)
83 item.setText(0, self.tr(txt))
84 item.setIcon(0, qtutils.icon(path))
86 def restore_selection(self):
87 if not self.old_selection:
88 return
89 (staged, modified, unmerged, untracked) = self.old_selection
91 # unstaged is an aggregate
92 unstaged = modified + unmerged + untracked
93 # restore selection
94 updated_staged = self.model.staged
95 updated_modified = self.model.modified
96 updated_unmerged = self.model.unmerged
97 updated_untracked = self.model.untracked
98 # unstaged is an aggregate
99 updated_unstaged = (updated_modified +
100 updated_unmerged +
101 updated_untracked)
103 # Updating the status resets the repo status tree so
104 # restore the selected items and re-run the diff
105 showdiff = False
106 mode = self.mode
107 if mode == self.model.mode_worktree:
108 # Update unstaged items
109 if unstaged:
110 for item in unstaged:
111 if item in updated_unstaged:
112 idx = updated_unstaged.index(item)
113 item = self.unstaged_item(idx)
114 if item:
115 showdiff = True
116 item.setSelected(True)
117 self.tree.setCurrentItem(item)
118 self.tree.setItemSelected(item, True)
120 elif mode in (self.model.mode_index, self.model.mode_amend):
121 # Ditto for staged items
122 if staged:
123 for item in staged:
124 if item in updated_staged:
125 idx = updated_staged.index(item)
126 item = self.staged_item(idx)
127 if item:
128 showdiff = True
129 item.setSelected(True)
130 self.tree.setCurrentItem(item)
131 self.tree.setItemSelected(item, True)
134 def staged_item(self, itemidx):
135 return self._subtree_item(self.idx_staged, itemidx)
137 def modified_item(self, itemidx):
138 return self._subtree_item(self.idx_modified, itemidx)
140 def unstaged_item(self, itemidx):
141 tree = self.tree
142 # is it modified?
143 item = tree.topLevelItem(self.idx_modified)
144 count = item.childCount()
145 if itemidx < count:
146 return item.child(itemidx)
147 # is it unmerged?
148 item = tree.topLevelItem(self.idx_unmerged)
149 count += item.childCount()
150 if itemidx < count:
151 return item.child(itemidx)
152 # is it untracked?
153 item = tree.topLevelItem(self.idx_untracked)
154 count += item.childCount()
155 if itemidx < count:
156 return item.child(itemidx)
157 # Nope..
158 return None
160 def _subtree_item(self, idx, itemidx):
161 parent = self.tree.topLevelItem(idx)
162 return parent.child(itemidx)
164 def about_to_update(self):
165 self.old_selection = self.selection()
167 def updated(self):
168 """Update display from model data."""
169 self.set_staged(self.model.staged)
170 self.set_modified(self.model.modified)
171 self.set_unmerged(self.model.unmerged)
172 self.set_untracked(self.model.untracked)
174 self.restore_selection()
176 if not self.model.staged:
177 return
178 staged = self.tree.topLevelItem(self.idx_staged)
179 if self.mode in self.model.modes_read_only:
180 staged.setText(0, self.tr('Changed'))
181 else:
182 staged.setText(0, self.tr('Staged'))
184 def set_staged(self, items):
185 """Adds items to the 'Staged' subtree."""
186 self._set_subtree(items, self.idx_staged, staged=True,
187 check=not self.model.read_only())
189 def set_modified(self, items):
190 """Adds items to the 'Modified' subtree."""
191 self._set_subtree(items, self.idx_modified)
193 def set_unmerged(self, items):
194 """Adds items to the 'Unmerged' subtree."""
195 self._set_subtree(items, self.idx_unmerged)
197 def set_untracked(self, items):
198 """Adds items to the 'Untracked' subtree."""
199 self._set_subtree(items, self.idx_untracked)
201 def _set_subtree(self, items, idx,
202 staged=False,
203 untracked=False,
204 check=True):
205 """Add a list of items to a treewidget item."""
206 parent = self.tree.topLevelItem(idx)
207 if items:
208 self.tree.setItemHidden(parent, False)
209 else:
210 self.tree.setItemHidden(parent, True)
211 parent.takeChildren()
212 for item in items:
213 treeitem = qtutils.create_treeitem(item,
214 staged=staged,
215 check=check,
216 untracked=untracked)
217 parent.addChild(treeitem)
218 self.expand_items(idx, items)
220 def expand_items(self, idx, items):
221 """Expand the top-level category "folder" once and only once."""
222 # Don't do this if items is empty; this makes it so that we
223 # don't add the top-level index into the expanded_items set
224 # until an item appears in a particular category.
225 if not items:
226 return
227 # Only run this once; we don't want to re-expand items that
228 # we've clicked on to re-collapse on updated().
229 if idx in self.expanded_items:
230 return
231 self.expanded_items.add(idx)
232 item = self.tree.topLevelItem(idx)
233 if item:
234 self.tree.expandItem(item)
236 def tree_context_menu_event(self, event):
237 """Create context menus for the repo status tree."""
238 menu = self.tree_context_menu_setup()
239 menu.exec_(self.tree.mapToGlobal(event.pos()))
241 def tree_context_menu_setup(self):
242 """Set up the status menu for the repo status tree."""
243 staged, modified, unmerged, untracked = self.selection()
244 menu = QtGui.QMenu(self)
246 if staged and staged[0] in cola.model().submodules:
247 menu.addAction(self.tr('Unstage Selected'),
248 SLOT(signals.unstage, self.staged()))
249 menu.addAction(self.tr('Launch git-cola'),
250 SLOT(signals.open_repo, os.path.abspath(staged[0])))
251 return menu
252 elif staged:
253 menu.addAction(self.tr('Unstage Selected'),
254 SLOT(signals.unstage, self.staged()))
255 menu.addSeparator()
256 menu.addAction(self.tr('Launch Editor'),
257 SLOT(signals.edit, self.staged()))
258 menu.addAction(self.tr('Launch Diff Tool'),
259 SLOT(signals.difftool, True, self.staged()))
260 menu.addSeparator()
261 menu.addAction(self.tr('Remove Unstaged Edits'),
262 lambda: self._remove_unstaged_edits(use_staged=True))
263 return menu
265 if unmerged:
266 if not utils.is_broken():
267 menu.addAction(self.tr('Launch Merge Tool'),
268 SLOT(signals.mergetool, self.unmerged()))
269 menu.addAction(self.tr('Launch Editor'),
270 SLOT(signals.edit, self.unmerged()))
271 menu.addSeparator()
272 menu.addAction(self.tr('Stage Selected'),
273 SLOT(signals.stage, self.unmerged()))
274 return menu
276 modified_submodule = (modified and
277 modified[0] in cola.model().submodules)
278 enable_staging = self.model.enable_staging()
279 if enable_staging:
280 menu.addAction(self.tr('Stage Selected'),
281 SLOT(signals.stage, self.unstaged()))
282 menu.addSeparator()
284 if modified_submodule:
285 menu.addAction(self.tr('Launch git-cola'),
286 SLOT(signals.open_repo,
287 os.path.abspath(modified[0])))
288 else:
289 menu.addAction(self.tr('Launch Editor'),
290 SLOT(signals.edit, self.unstaged()))
292 if modified and enable_staging and not modified_submodule:
293 menu.addAction(self.tr('Launch Diff Tool'),
294 SLOT(signals.difftool, False, self.modified()))
295 menu.addSeparator()
296 menu.addAction(self.tr('Remove Unstaged Edits'),
297 self._remove_unstaged_edits)
298 menu.addAction(self.tr('Remove Uncommited Edits'),
299 self._remove_uncommitted_edits)
301 if untracked:
302 menu.addSeparator()
303 menu.addAction(self.tr('Delete File(s)'),
304 SLOT(signals.delete, self.untracked()))
306 return menu
308 def _remove_unstaged_edits(self, use_staged=False):
309 if not self.model.undoable():
310 return
311 if use_staged:
312 items_to_undo = self.staged()
313 else:
314 items_to_undo = self.modified()
316 if items_to_undo:
317 if not qtutils.question(self,
318 'Remove Unstaged Edits?',
319 'This operation removes '
320 'unstaged edits.\n'
321 'There\'s no going back. Continue?',
322 default=False):
323 return
324 cola.notifier().broadcast(signals.checkout,
325 ['--'] + items_to_undo)
326 else:
327 qtutils.log(1, self.tr('No files selected for '
328 'checkout from HEAD.'))
330 def _remove_uncommitted_edits(self):
331 if not self.model.undoable():
332 return
333 items_to_undo = self.modified()
334 if items_to_undo:
335 if not qtutils.question(self,
336 'Remove Uncommitted edits?',
337 'This operation removes '
338 'uncommitted edits.\n'
339 'There\'s no going back. Continue?',
340 default=False):
341 return
342 cola.notifier().broadcast(signals.checkout,
343 ['HEAD', '--'] + items_to_undo)
344 else:
345 qtutils.log(1, self.tr('No files selected for '
346 'checkout from HEAD.'))
348 def single_selection(self):
349 """Scan across staged, modified, etc. and return a single item."""
350 staged, modified, unmerged, untracked = self.selection()
351 s = None
352 m = None
353 um = None
354 ut = None
355 if staged:
356 s = staged[0]
357 elif modified:
358 m = modified[0]
359 elif unmerged:
360 um = unmerged[0]
361 elif untracked:
362 ut = untracked[0]
363 return s, m, um, ut
365 def selected_indexes(self):
366 """Returns a list of (category, row) representing the tree selection."""
367 selected = self.tree.selectedIndexes()
368 result = []
369 for idx in selected:
370 if idx.parent().isValid():
371 parent_idx = idx.parent()
372 entry = (parent_idx.row(), idx.row())
373 else:
374 entry = (-1, idx.row())
375 result.append(entry)
376 return result
378 def selection(self):
379 """Return the current selection in the repo status tree."""
380 return (self.staged(), self.modified(),
381 self.unmerged(), self.untracked())
383 def staged(self):
384 return self._subtree_selection(self.idx_staged, self.model.staged)
386 def unstaged(self):
387 return self.modified() + self.unmerged() + self.untracked()
389 def modified(self):
390 return self._subtree_selection(self.idx_modified, self.model.modified)
392 def unmerged(self):
393 return self._subtree_selection(self.idx_unmerged, self.model.unmerged)
395 def untracked(self):
396 return self._subtree_selection(self.idx_untracked, self.model.untracked)
398 def _subtree_selection(self, idx, items):
399 item = self.tree.topLevelItem(idx)
400 return qtutils.tree_selection(item, items)
402 def tree_click(self, event):
404 Called when a repo status tree item is clicked.
406 This handles the behavior where clicking on the icon invokes
407 the same appropriate action.
410 result = QtGui.QTreeWidget.mousePressEvent(self.tree, event)
412 # Sync the selection model
413 s, m, um, ut = self.selection()
414 cola.selection_model().set_selection(s, m, um, ut)
416 # Get the item that was clicked
417 item = self.tree.itemAt(event.pos())
418 if not item:
419 # Nothing was clicked -- reset the display and return
420 cola.notifier().broadcast(signals.reset_mode)
421 items = self.tree.selectedItems()
422 self.tree.blockSignals(True)
423 for i in items:
424 i.setSelected(False)
425 self.tree.blockSignals(False)
426 return result
428 # An item was clicked -- get its index in the model
429 staged, idx = self.index_for_item(item)
430 if idx == self.idx_header:
431 return result
433 if self.model.read_only():
434 return result
436 # Handle when the icons are clicked
437 # TODO query Qt for the event position relative to the icon.
438 xpos = event.pos().x()
439 if xpos > 45 and xpos < 59:
440 if staged:
441 cola.notifier().broadcast(signals.unstage, self.staged())
442 else:
443 cola.notifier().broadcast(signals.stage, self.unstaged())
444 return result
446 def tree_doubleclick(self, item, column):
447 """Called when an item is double-clicked in the repo status tree."""
448 if self.model.read_only():
449 return
450 staged, modified, unmerged, untracked = self.selection()
451 if staged:
452 cola.notifier().broadcast(signals.unstage, staged)
453 elif modified:
454 cola.notifier().broadcast(signals.stage, modified)
455 elif untracked:
456 cola.notifier().broadcast(signals.stage, untracked)
457 elif unmerged:
458 cola.notifier().broadcast(signals.stage, unmerged)
460 def tree_selection(self):
461 """Show a data for the selected item."""
462 # Sync the selection model
463 s, m, um, ut = self.selection()
464 cola.selection_model().set_selection(s, m, um, ut)
466 selection = self.selected_indexes()
467 if not selection:
468 return
469 category, idx = selection[0]
470 # A header item e.g. 'Staged', 'Modified', etc.
471 if category == self.idx_header:
472 signal = {
473 self.idx_staged: signals.staged_summary,
474 self.idx_modified: signals.modified_summary,
475 self.idx_unmerged: signals.unmerged_summary,
476 self.idx_untracked: signals.untracked_summary,
477 }.get(idx, signals.diffstat)
478 cola.notifier().broadcast(signal)
479 # A staged file
480 elif category == self.idx_staged:
481 cola.notifier().broadcast(signals.diff_staged, self.staged())
483 # A modified file
484 elif category == self.idx_modified:
485 cola.notifier().broadcast(signals.diff, self.modified())
487 elif category == self.idx_unmerged:
488 cola.notifier().broadcast(signals.diff, self.unmerged())
490 elif category == self.idx_untracked:
491 cola.notifier().broadcast(signals.show_untracked, self.unstaged())
493 def index_for_item(self, item):
495 Given an item, returns the index of the item.
497 The indexes for unstaged items are grouped such that
498 the index of unmerged[1] = len(modified) + 1, etc.
501 if not item:
502 return False, -1
504 parent = item.parent()
505 if not parent:
506 return False, -1
508 pidx = self.tree.indexOfTopLevelItem(parent)
509 if pidx == self.idx_staged:
510 return True, parent.indexOfChild(item)
511 elif pidx == self.idx_modified:
512 return False, parent.indexOfChild(item)
514 count = self.tree.topLevelItem(self.idx_modified).childCount()
515 if pidx == self.idx_unmerged:
516 return False, count + parent.indexOfChild(item)
518 count += self.tree.topLevelItem(self.idx_unmerged).childCount()
519 if pidx == self.idx_untracked:
520 return False, count + parent.indexOfChild(item)
522 return False, -1