status: use Italics instead of Bold-with-background for headers
[git-cola.git] / cola / widgets / status.py
blob91041f516010aadb44de606ddd8d530951a1edd8
1 from __future__ import division, absolute_import, unicode_literals
3 import itertools
5 from PyQt4 import QtCore
6 from PyQt4 import QtGui
7 from PyQt4.QtCore import Qt
8 from PyQt4.QtCore import SIGNAL
10 from cola import cmds
11 from cola import core
12 from cola import hotkeys
13 from cola import icons
14 from cola import qtutils
15 from cola import utils
16 from cola.i18n import N_
17 from cola.models import main
18 from cola.models import selection
19 from cola.widgets import completion
20 from cola.widgets import defs
23 class StatusWidget(QtGui.QWidget):
24 """
25 Provides a git-status-like repository widget.
27 This widget observes the main model and broadcasts
28 Qt signals.
30 """
31 def __init__(self, titlebar, parent=None):
32 QtGui.QWidget.__init__(self, parent)
34 tooltip = N_('Toggle the paths filter')
35 icon = icons.ellipsis()
36 self.filter_button = qtutils.create_action_button(tooltip=tooltip,
37 icon=icon)
38 self.filter_widget = StatusFilterWidget()
39 self.filter_widget.hide()
40 self.tree = StatusTreeWidget()
41 self.setFocusProxy(self.tree)
43 self.main_layout = qtutils.vbox(defs.no_margin, defs.no_spacing,
44 self.filter_widget, self.tree)
45 self.setLayout(self.main_layout)
47 self.toggle_action = qtutils.add_action(self, tooltip,
48 self.toggle_filter,
49 hotkeys.FILTER)
51 titlebar.add_corner_widget(self.filter_button)
52 qtutils.connect_button(self.filter_button, self.toggle_filter)
54 def toggle_filter(self):
55 shown = not self.filter_widget.isVisible()
56 self.filter_widget.setVisible(shown)
57 if shown:
58 self.filter_widget.setFocus(True)
59 else:
60 self.tree.setFocus(True)
62 def set_initial_size(self):
63 self.setMaximumWidth(222)
64 QtCore.QTimer.singleShot(1, self.restore_size)
66 def restore_size(self):
67 self.setMaximumWidth(2 ** 13)
69 def refresh(self):
70 self.tree.show_selection()
72 def set_filter(self, txt):
73 self.filter_widget.setVisible(True)
74 self.filter_widget.text.set_value(txt)
75 self.filter_widget.apply_filter()
77 def move_up(self):
78 self.tree.move_up()
80 def move_down(self):
81 self.tree.move_down()
84 class StatusTreeWidget(QtGui.QTreeWidget):
85 # Item categories
86 idx_header = -1
87 idx_staged = 0
88 idx_unmerged = 1
89 idx_modified = 2
90 idx_untracked = 3
91 idx_end = 4
93 # Read-only access to the mode state
94 mode = property(lambda self: self.m.mode)
96 def __init__(self, parent=None):
97 QtGui.QTreeWidget.__init__(self, parent)
99 self.setSelectionMode(QtGui.QAbstractItemView.ExtendedSelection)
100 self.headerItem().setHidden(True)
101 self.setAllColumnsShowFocus(True)
102 self.setSortingEnabled(False)
103 self.setUniformRowHeights(True)
104 self.setAnimated(True)
105 self.setRootIsDecorated(False)
106 self.setIndentation(0)
107 self.setDragEnabled(True)
109 ok = icons.ok()
110 compare = icons.compare()
111 question = icons.question()
112 self.add_toplevel_item(N_('Staged'), ok, hide=True)
113 self.add_toplevel_item(N_('Unmerged'), compare, hide=True)
114 self.add_toplevel_item(N_('Modified'), compare, hide=True)
115 self.add_toplevel_item(N_('Untracked'), question, hide=True)
117 # Used to restore the selection
118 self.old_scroll = None
119 self.old_selection = None
120 self.old_contents = None
121 self.old_current_item = None
122 self.expanded_items = set()
124 self.process_selection_action = qtutils.add_action(
125 self, cmds.StageOrUnstage.name(),
126 cmds.run(cmds.StageOrUnstage), hotkeys.STAGE_SELECTION)
128 self.revert_unstaged_edits_action = qtutils.add_action(
129 self, cmds.RevertUnstagedEdits.name(),
130 cmds.run(cmds.RevertUnstagedEdits), hotkeys.REVERT)
131 self.revert_unstaged_edits_action.setIcon(icons.undo())
133 self.launch_difftool_action = qtutils.add_action(
134 self, cmds.LaunchDifftool.name(),
135 cmds.run(cmds.LaunchDifftool), hotkeys.DIFF)
136 self.launch_difftool_action.setIcon(icons.diff())
138 self.launch_editor_action = qtutils.add_action(
139 self, cmds.LaunchEditor.name(),
140 cmds.run(cmds.LaunchEditor), hotkeys.EDIT, *hotkeys.ACCEPT)
141 self.launch_editor_action.setIcon(icons.edit())
143 if not utils.is_win32():
144 self.open_using_default_app = qtutils.add_action(
145 self, cmds.OpenDefaultApp.name(),
146 self._open_using_default_app, hotkeys.PRIMARY_ACTION)
147 self.open_using_default_app.setIcon(icons.default_app())
149 self.open_parent_dir_action = qtutils.add_action(
150 self, cmds.OpenParentDir.name(),
151 self._open_parent_dir, hotkeys.SECONDARY_ACTION)
152 self.open_parent_dir_action.setIcon(icons.folder())
154 self.up_action = qtutils.add_action(
155 self, N_('Move Up'), self.move_up,
156 hotkeys.MOVE_UP, hotkeys.MOVE_UP_SECONDARY)
158 self.down_action = qtutils.add_action(
159 self, N_('Move Down'), self.move_down,
160 hotkeys.MOVE_DOWN, hotkeys.MOVE_DOWN_SECONDARY)
162 self.copy_path_action = qtutils.add_action(
163 self, N_('Copy Path to Clipboard'), self.copy_path, hotkeys.COPY)
164 self.copy_path_action.setIcon(icons.copy())
166 self.copy_relpath_action = qtutils.add_action(
167 self, N_('Copy Relative Path to Clipboard'),
168 self.copy_relpath, hotkeys.CUT)
169 self.copy_relpath_action.setIcon(icons.copy())
171 self.view_history_action = qtutils.add_action(
172 self, N_('View History...'), self.view_history, hotkeys.HISTORY)
174 # MoveToTrash and Delete use the same shortcut.
175 # We will only bind one of them, depending on whether or not the
176 # MoveToTrash command is avaialble. When available, the hotkey
177 # is bound to MoveToTrash, otherwise it is bound to Delete.
178 if cmds.MoveToTrash.AVAILABLE:
179 self.move_to_trash_action = qtutils.add_action(
180 self, N_('Move file(s) to trash'),
181 self._trash_untracked_files, hotkeys.TRASH)
182 self.move_to_trash_action.setIcon(icons.discard())
183 delete_shortcut = hotkeys.DELETE_FILE
184 else:
185 self.move_to_trash_action = None
186 delete_shortcut = hotkeys.DELETE_FILE_SECONDARY
188 self.delete_untracked_files_action = qtutils.add_action(
189 self, N_('Delete File(s)...'),
190 self._delete_untracked_files, delete_shortcut)
191 self.delete_untracked_files_action.setIcon(icons.discard())
193 self.connect(self, SIGNAL('about_to_update()'),
194 self._about_to_update, Qt.QueuedConnection)
195 self.connect(self, SIGNAL('updated()'),
196 self._updated, Qt.QueuedConnection)
198 self.m = main.model()
199 self.m.add_observer(self.m.message_about_to_update,
200 self.about_to_update)
201 self.m.add_observer(self.m.message_updated, self.updated)
203 self.connect(self, SIGNAL('itemSelectionChanged()'),
204 self.show_selection)
206 self.connect(self, SIGNAL('itemDoubleClicked(QTreeWidgetItem*,int)'),
207 self.double_clicked)
209 self.connect(self, SIGNAL('itemCollapsed(QTreeWidgetItem*)'),
210 lambda x: self.update_column_widths())
212 self.connect(self, SIGNAL('itemExpanded(QTreeWidgetItem*)'),
213 lambda x: self.update_column_widths())
215 def add_toplevel_item(self, txt, icon, hide=False):
216 font = self.font()
217 font.setItalic(True)
219 item = QtGui.QTreeWidgetItem(self)
220 item.setFont(0, font)
221 item.setText(0, txt)
222 item.setIcon(0, icon)
223 if hide:
224 self.setItemHidden(item, True)
226 def restore_selection(self):
227 if not self.old_selection or not self.old_contents:
228 return
229 old_c = self.old_contents
230 old_s = self.old_selection
231 new_c = self.contents()
233 def mkselect(lst, widget_getter):
234 def select(item, current=False):
235 idx = lst.index(item)
236 widget = widget_getter(idx)
237 if current:
238 self.setCurrentItem(widget)
239 self.setItemSelected(widget, True)
240 return select
242 select_staged = mkselect(new_c.staged, self.staged_item)
243 select_unmerged = mkselect(new_c.unmerged, self.unmerged_item)
244 select_modified = mkselect(new_c.modified, self.modified_item)
245 select_untracked = mkselect(new_c.untracked, self.untracked_item)
247 saved_selection = [
248 (set(new_c.staged), old_c.staged, set(old_s.staged),
249 select_staged),
251 (set(new_c.unmerged), old_c.unmerged, set(old_s.unmerged),
252 select_unmerged),
254 (set(new_c.modified), old_c.modified, set(old_s.modified),
255 select_modified),
257 (set(new_c.untracked), old_c.untracked, set(old_s.untracked),
258 select_untracked),
261 # Restore the current item
262 if self.old_current_item:
263 category, idx = self.old_current_item
264 if category == self.idx_header:
265 item = self.invisibleRootItem().child(idx)
266 if item is not None:
267 self.setCurrentItem(item)
268 self.setItemSelected(item, True)
269 return
270 # Reselect the current item
271 selection_info = saved_selection[category]
272 new = selection_info[0]
273 old = selection_info[1]
274 reselect = selection_info[3]
275 try:
276 item = old[idx]
277 except:
278 return
279 if item in new:
280 reselect(item, current=True)
282 # Restore selection
283 # When reselecting we only care that the items are selected;
284 # we do not need to rerun the callbacks which were triggered
285 # above. Block signals to skip the callbacks.
286 self.blockSignals(True)
287 for (new, old, sel, reselect) in saved_selection:
288 for item in sel:
289 if item in new:
290 reselect(item, current=False)
291 self.blockSignals(False)
293 for (new, old, sel, reselect) in saved_selection:
294 # When modified is staged, select the next modified item
295 # When unmerged is staged, select the next unmerged item
296 # When unstaging, select the next staged item
297 # When staging untracked files, select the next untracked item
298 if len(new) >= len(old):
299 # The list did not shrink so it is not one of these cases.
300 continue
301 for item in sel:
302 # The item still exists so ignore it
303 if item in new or item not in old:
304 continue
305 # The item no longer exists in this list so search for
306 # its nearest neighbors and select them instead.
307 idx = old.index(item)
308 for j in itertools.chain(old[idx+1:], reversed(old[:idx])):
309 if j in new:
310 reselect(j, current=True)
311 return
313 def restore_scrollbar(self):
314 vscroll = self.verticalScrollBar()
315 if vscroll and self.old_scroll is not None:
316 vscroll.setValue(self.old_scroll)
317 self.old_scroll = None
319 def staged_item(self, itemidx):
320 return self._subtree_item(self.idx_staged, itemidx)
322 def modified_item(self, itemidx):
323 return self._subtree_item(self.idx_modified, itemidx)
325 def unmerged_item(self, itemidx):
326 return self._subtree_item(self.idx_unmerged, itemidx)
328 def untracked_item(self, itemidx):
329 return self._subtree_item(self.idx_untracked, itemidx)
331 def unstaged_item(self, itemidx):
332 # is it modified?
333 item = self.topLevelItem(self.idx_modified)
334 count = item.childCount()
335 if itemidx < count:
336 return item.child(itemidx)
337 # is it unmerged?
338 item = self.topLevelItem(self.idx_unmerged)
339 count += item.childCount()
340 if itemidx < count:
341 return item.child(itemidx)
342 # is it untracked?
343 item = self.topLevelItem(self.idx_untracked)
344 count += item.childCount()
345 if itemidx < count:
346 return item.child(itemidx)
347 # Nope..
348 return None
350 def _subtree_item(self, idx, itemidx):
351 parent = self.topLevelItem(idx)
352 return parent.child(itemidx)
354 def about_to_update(self):
355 self.emit(SIGNAL('about_to_update()'))
357 def _about_to_update(self):
358 self.save_selection()
359 self.save_scrollbar()
361 def save_scrollbar(self):
362 vscroll = self.verticalScrollBar()
363 if vscroll:
364 self.old_scroll = vscroll.value()
365 else:
366 self.old_scroll = None
368 def current_item(self):
369 s = self.selected_indexes()
370 if not s:
371 return None
372 current = self.currentItem()
373 if not current:
374 return None
375 idx = self.indexFromItem(current, 0)
376 if idx.parent().isValid():
377 parent_idx = idx.parent()
378 entry = (parent_idx.row(), idx.row())
379 else:
380 entry = (self.idx_header, idx.row())
381 return entry
383 def save_selection(self):
384 self.old_contents = self.contents()
385 self.old_selection = self.selection()
386 self.old_current_item = self.current_item()
388 def updated(self):
389 """Update display from model data."""
390 self.emit(SIGNAL('updated()'))
392 def _updated(self):
393 self.set_staged(self.m.staged)
394 self.set_modified(self.m.modified)
395 self.set_unmerged(self.m.unmerged)
396 self.set_untracked(self.m.untracked)
397 self.restore_selection()
398 self.restore_scrollbar()
399 self.update_column_widths()
400 self.update_actions()
402 def update_actions(self, selected=None):
403 if selected is None:
404 selected = selection.selection()
405 can_revert_edits = bool(selected.staged or selected.modified)
406 self.revert_unstaged_edits_action.setEnabled(can_revert_edits)
408 def set_staged(self, items):
409 """Adds items to the 'Staged' subtree."""
410 self._set_subtree(items, self.idx_staged, staged=True,
411 deleted_set=self.m.staged_deleted)
413 def set_modified(self, items):
414 """Adds items to the 'Modified' subtree."""
415 self._set_subtree(items, self.idx_modified,
416 deleted_set=self.m.unstaged_deleted)
418 def set_unmerged(self, items):
419 """Adds items to the 'Unmerged' subtree."""
420 self._set_subtree(items, self.idx_unmerged)
422 def set_untracked(self, items):
423 """Adds items to the 'Untracked' subtree."""
424 self._set_subtree(items, self.idx_untracked, untracked=True)
426 def _set_subtree(self, items, idx,
427 staged=False,
428 untracked=False,
429 deleted_set=None):
430 """Add a list of items to a treewidget item."""
431 self.blockSignals(True)
432 parent = self.topLevelItem(idx)
433 if items:
434 self.setItemHidden(parent, False)
435 else:
436 self.setItemHidden(parent, True)
438 # sip v4.14.7 and below leak memory in parent.takeChildren()
439 # so we use this backwards-compatible construct instead
440 while parent.takeChild(0) is not None:
441 pass
443 for item in items:
444 deleted = (deleted_set is not None and item in deleted_set)
445 treeitem = qtutils.create_treeitem(item,
446 staged=staged,
447 deleted=deleted,
448 untracked=untracked)
449 parent.addChild(treeitem)
450 self.expand_items(idx, items)
451 self.blockSignals(False)
453 def update_column_widths(self):
454 self.resizeColumnToContents(0)
456 def expand_items(self, idx, items):
457 """Expand the top-level category "folder" once and only once."""
458 # Don't do this if items is empty; this makes it so that we
459 # don't add the top-level index into the expanded_items set
460 # until an item appears in a particular category.
461 if not items:
462 return
463 # Only run this once; we don't want to re-expand items that
464 # we've clicked on to re-collapse on updated().
465 if idx in self.expanded_items:
466 return
467 self.expanded_items.add(idx)
468 item = self.topLevelItem(idx)
469 if item:
470 self.expandItem(item)
472 def contextMenuEvent(self, event):
473 """Create context menus for the repo status tree."""
474 menu = self.create_context_menu()
475 menu.exec_(self.mapToGlobal(event.pos()))
477 def create_context_menu(self):
478 """Set up the status menu for the repo status tree."""
479 s = self.selection()
480 menu = QtGui.QMenu(self)
482 selected_indexes = self.selected_indexes()
483 if selected_indexes:
484 category, idx = selected_indexes[0]
485 # A header item e.g. 'Staged', 'Modified', etc.
486 if category == self.idx_header:
487 return self._create_header_context_menu(menu, idx)
489 if s.staged:
490 return self._create_staged_context_menu(menu, s)
492 elif s.unmerged:
493 return self._create_unmerged_context_menu(menu, s)
494 else:
495 return self._create_unstaged_context_menu(menu, s)
497 def _create_header_context_menu(self, menu, idx):
498 if idx == self.idx_staged:
499 menu.addAction(icons.remove(), N_('Unstage All'),
500 cmds.run(cmds.UnstageAll))
501 return menu
502 elif idx == self.idx_unmerged:
503 action = menu.addAction(icons.add(), cmds.StageUnmerged.name(),
504 cmds.run(cmds.StageUnmerged))
505 action.setShortcut(hotkeys.STAGE_SELECTION)
506 return menu
507 elif idx == self.idx_modified:
508 action = menu.addAction(icons.add(), cmds.StageModified.name(),
509 cmds.run(cmds.StageModified))
510 action.setShortcut(hotkeys.STAGE_SELECTION)
511 return menu
513 elif idx == self.idx_untracked:
514 action = menu.addAction(icons.add(), cmds.StageUntracked.name(),
515 cmds.run(cmds.StageUntracked))
516 action.setShortcut(hotkeys.STAGE_SELECTION)
517 return menu
519 def _create_staged_context_menu(self, menu, s):
520 if s.staged[0] in self.m.submodules:
521 return self._create_staged_submodule_context_menu(menu, s)
523 if self.m.unstageable():
524 action = menu.addAction(icons.remove(), N_('Unstage Selected'),
525 cmds.run(cmds.Unstage, self.staged()))
526 action.setShortcut(hotkeys.STAGE_SELECTION)
528 # Do all of the selected items exist?
529 all_exist = all(not i in self.m.staged_deleted and core.exists(i)
530 for i in self.staged())
532 if all_exist:
533 menu.addAction(self.launch_editor_action)
534 menu.addAction(self.launch_difftool_action)
536 if all_exist and not utils.is_win32():
537 menu.addSeparator()
538 open_default = cmds.run(cmds.OpenDefaultApp, self.staged())
539 action = menu.addAction(icons.default_app(),
540 cmds.OpenDefaultApp.name(), open_default)
541 action.setShortcut(hotkeys.PRIMARY_ACTION)
543 action = menu.addAction(icons.folder(),
544 cmds.OpenParentDir.name(),
545 self._open_parent_dir)
546 action.setShortcut(hotkeys.SECONDARY_ACTION)
548 if self.m.undoable():
549 menu.addSeparator()
550 menu.addAction(self.revert_unstaged_edits_action)
552 menu.addSeparator()
553 menu.addAction(self.copy_path_action)
554 menu.addAction(self.copy_relpath_action)
555 menu.addAction(self.view_history_action)
556 return menu
558 def _create_staged_submodule_context_menu(self, menu, s):
559 menu.addAction(icons.cola(), N_('Launch git-cola'),
560 cmds.run(cmds.OpenRepo,
561 core.abspath(s.staged[0])))
563 menu.addAction(self.launch_editor_action)
564 menu.addSeparator()
566 action = menu.addAction(icons.remove(), N_('Unstage Selected'),
567 cmds.run(cmds.Unstage, self.staged()))
568 action.setShortcut(hotkeys.STAGE_SELECTION)
569 menu.addSeparator()
571 menu.addAction(self.copy_path_action)
572 menu.addAction(self.copy_relpath_action)
573 menu.addAction(self.view_history_action)
574 return menu
576 def _create_unmerged_context_menu(self, menu, s):
577 menu.addAction(self.launch_difftool_action)
579 action = menu.addAction(icons.add(), N_('Stage Selected'),
580 cmds.run(cmds.Stage, self.unstaged()))
581 action.setShortcut(hotkeys.STAGE_SELECTION)
582 menu.addSeparator()
583 menu.addAction(self.launch_editor_action)
585 if not utils.is_win32():
586 menu.addSeparator()
587 open_default = cmds.run(cmds.OpenDefaultApp, self.unmerged())
588 action = menu.addAction(icons.default_app(),
589 cmds.OpenDefaultApp.name(), open_default)
590 action.setShortcut(hotkeys.PRIMARY_ACTION)
592 action = menu.addAction(icons.folder(),
593 cmds.OpenParentDir.name(),
594 self._open_parent_dir)
595 action.setShortcut(hotkeys.SECONDARY_ACTION)
597 menu.addSeparator()
598 menu.addAction(self.copy_path_action)
599 menu.addAction(self.copy_relpath_action)
600 menu.addAction(self.view_history_action)
601 return menu
603 def _create_unstaged_context_menu(self, menu, s):
604 modified_submodule = (s.modified and
605 s.modified[0] in self.m.submodules)
606 if modified_submodule:
607 return self._create_modified_submodule_context_menu(menu, s)
609 if self.m.stageable():
610 action = menu.addAction(icons.add(), N_('Stage Selected'),
611 cmds.run(cmds.Stage, self.unstaged()))
612 action.setShortcut(hotkeys.STAGE_SELECTION)
614 # Do all of the selected items exist?
615 all_exist = all(not i in self.m.unstaged_deleted and core.exists(i)
616 for i in self.staged())
618 if all_exist and self.unstaged():
619 menu.addAction(self.launch_editor_action)
621 if all_exist and s.modified and self.m.stageable():
622 menu.addAction(self.launch_difftool_action)
624 if s.modified and self.m.stageable():
625 if self.m.undoable():
626 menu.addSeparator()
627 menu.addAction(self.revert_unstaged_edits_action)
629 if all_exist and self.unstaged() and not utils.is_win32():
630 menu.addSeparator()
631 open_default = cmds.run(cmds.OpenDefaultApp, self.unstaged())
632 action = menu.addAction(icons.default_app(),
633 cmds.OpenDefaultApp.name(), open_default)
634 action.setShortcut(hotkeys.PRIMARY_ACTION)
636 action = menu.addAction(icons.folder(),
637 cmds.OpenParentDir.name(),
638 self._open_parent_dir)
639 action.setShortcut(hotkeys.SECONDARY_ACTION)
641 if all_exist and s.untracked:
642 menu.addSeparator()
643 if self.move_to_trash_action is not None:
644 menu.addAction(self.move_to_trash_action)
645 menu.addAction(self.delete_untracked_files_action)
646 menu.addSeparator()
647 menu.addAction(icons.edit(),
648 N_('Add to .gitignore'),
649 cmds.run(cmds.Ignore,
650 map(lambda x: '/' + x, self.untracked())))
651 menu.addSeparator()
652 menu.addAction(self.copy_path_action)
653 menu.addAction(self.copy_relpath_action)
654 if not selection.selection_model().is_empty():
655 menu.addAction(self.view_history_action)
656 return menu
658 def _create_modified_submodule_context_menu(self, menu, s):
659 menu.addAction(icons.cola(), N_('Launch git-cola'),
660 cmds.run(cmds.OpenRepo, core.abspath(s.modified[0])))
662 menu.addAction(self.launch_editor_action)
664 if self.m.stageable():
665 menu.addSeparator()
666 action = menu.addAction(icons.add(), N_('Stage Selected'),
667 cmds.run(cmds.Stage, self.unstaged()))
668 action.setShortcut(hotkeys.STAGE_SELECTION)
670 menu.addSeparator()
671 menu.addAction(self.copy_path_action)
672 menu.addAction(self.copy_relpath_action)
673 menu.addAction(self.view_history_action)
674 return menu
677 def _delete_untracked_files(self):
678 cmds.do(cmds.Delete, self.untracked())
680 def _trash_untracked_files(self):
681 cmds.do(cmds.MoveToTrash, self.untracked())
683 def single_selection(self):
684 """Scan across staged, modified, etc. and return a single item."""
685 st = None
686 um = None
687 m = None
688 ut = None
690 s = self.selection()
691 if s.staged:
692 st = s.staged[0]
693 elif s.modified:
694 m = s.modified[0]
695 elif s.unmerged:
696 um = s.unmerged[0]
697 elif s.untracked:
698 ut = s.untracked[0]
700 return selection.State(st, um, m, ut)
702 def selected_indexes(self):
703 """Returns a list of (category, row) representing the tree selection."""
704 selected = self.selectedIndexes()
705 result = []
706 for idx in selected:
707 if idx.parent().isValid():
708 parent_idx = idx.parent()
709 entry = (parent_idx.row(), idx.row())
710 else:
711 entry = (self.idx_header, idx.row())
712 result.append(entry)
713 return result
715 def selection(self):
716 """Return the current selection in the repo status tree."""
717 return selection.State(self.staged(), self.unmerged(),
718 self.modified(), self.untracked())
720 def contents(self):
721 return selection.State(self.m.staged, self.m.unmerged,
722 self.m.modified, self.m.untracked)
724 def all_files(self):
725 c = self.contents()
726 return c.staged + c.unmerged + c.modified + c.untracked
728 def selected_group(self):
729 """A list of selected files in various states of being"""
730 return selection.pick(self.selection())
732 def selected_idx(self):
733 c = self.contents()
734 s = self.single_selection()
735 offset = 0
736 for content, selection in zip(c, s):
737 if len(content) == 0:
738 continue
739 if selection is not None:
740 return offset + content.index(selection)
741 offset += len(content)
742 return None
744 def select_by_index(self, idx):
745 c = self.contents()
746 to_try = [
747 (c.staged, self.idx_staged),
748 (c.unmerged, self.idx_unmerged),
749 (c.modified, self.idx_modified),
750 (c.untracked, self.idx_untracked),
752 for content, toplevel_idx in to_try:
753 if len(content) == 0:
754 continue
755 if idx < len(content):
756 parent = self.topLevelItem(toplevel_idx)
757 item = parent.child(idx)
758 self.select_item(item)
759 return
760 idx -= len(content)
762 def select_item(self, item):
763 self.scrollToItem(item)
764 self.setCurrentItem(item)
765 self.setItemSelected(item, True)
767 def staged(self):
768 return self._subtree_selection(self.idx_staged, self.m.staged)
770 def unstaged(self):
771 return self.unmerged() + self.modified() + self.untracked()
773 def modified(self):
774 return self._subtree_selection(self.idx_modified, self.m.modified)
776 def unmerged(self):
777 return self._subtree_selection(self.idx_unmerged, self.m.unmerged)
779 def untracked(self):
780 return self._subtree_selection(self.idx_untracked, self.m.untracked)
782 def staged_items(self):
783 return self._subtree_selection_items(self.idx_staged)
785 def unstaged_items(self):
786 return (self.unmerged_items() + self.modified_items() +
787 self.untracked_items())
789 def modified_items(self):
790 return self._subtree_selection_items(self.idx_modified)
792 def unmerged_items(self):
793 return self._subtree_selection_items(self.idx_unmerged)
795 def untracked_items(self):
796 return self._subtree_selection_items(self.idx_untracked)
798 def _subtree_selection(self, idx, items):
799 item = self.topLevelItem(idx)
800 return qtutils.tree_selection(item, items)
802 def _subtree_selection_items(self, idx):
803 item = self.topLevelItem(idx)
804 return qtutils.tree_selection_items(item)
806 def double_clicked(self, item, idx):
807 """Called when an item is double-clicked in the repo status tree."""
808 cmds.do(cmds.StageOrUnstage)
810 def _open_using_default_app(self):
811 cmds.do(cmds.OpenDefaultApp, self.selected_group())
813 def _open_parent_dir(self):
814 cmds.do(cmds.OpenParentDir, self.selected_group())
816 def show_selection(self):
817 """Show the selected item."""
818 # Sync the selection model
819 selected = self.selection()
820 selection.selection_model().set_selection(selected)
821 self.update_actions(selected=selected)
823 selected_indexes = self.selected_indexes()
824 if not selected_indexes:
825 if self.m.amending():
826 cmds.do(cmds.SetDiffText, '')
827 else:
828 cmds.do(cmds.ResetMode)
829 return
830 category, idx = selected_indexes[0]
831 # A header item e.g. 'Staged', 'Modified', etc.
832 if category == self.idx_header:
833 cls = {
834 self.idx_staged: cmds.DiffStagedSummary,
835 self.idx_modified: cmds.Diffstat,
836 # TODO implement UnmergedSummary
837 #self.idx_unmerged: cmds.UnmergedSummary,
838 self.idx_untracked: cmds.UntrackedSummary,
839 }.get(idx, cmds.Diffstat)
840 cmds.do(cls)
841 # A staged file
842 elif category == self.idx_staged:
843 item = self.staged_items()[0]
844 cmds.do(cmds.DiffStaged, item.path, deleted=item.deleted)
846 # A modified file
847 elif category == self.idx_modified:
848 item = self.modified_items()[0]
849 cmds.do(cmds.Diff, item.path, deleted=item.deleted)
851 elif category == self.idx_unmerged:
852 item = self.unmerged_items()[0]
853 cmds.do(cmds.Diff, item.path)
855 elif category == self.idx_untracked:
856 item = self.unstaged_items()[0]
857 cmds.do(cmds.ShowUntracked, item.path)
859 def move_up(self):
860 idx = self.selected_idx()
861 all_files = self.all_files()
862 if idx is None:
863 selected_indexes = self.selected_indexes()
864 if selected_indexes:
865 category, toplevel_idx = selected_indexes[0]
866 if category == self.idx_header:
867 item = self.itemAbove(self.topLevelItem(toplevel_idx))
868 if item is not None:
869 self.select_item(item)
870 return
871 if all_files:
872 self.select_by_index(len(all_files) - 1)
873 return
874 if idx - 1 >= 0:
875 self.select_by_index(idx - 1)
876 else:
877 self.select_by_index(len(all_files) - 1)
879 def move_down(self):
880 idx = self.selected_idx()
881 all_files = self.all_files()
882 if idx is None:
883 selected_indexes = self.selected_indexes()
884 if selected_indexes:
885 category, toplevel_idx = selected_indexes[0]
886 if category == self.idx_header:
887 item = self.itemBelow(self.topLevelItem(toplevel_idx))
888 if item is not None:
889 self.select_item(item)
890 return
891 if all_files:
892 self.select_by_index(0)
893 return
894 if idx + 1 < len(all_files):
895 self.select_by_index(idx + 1)
896 else:
897 self.select_by_index(0)
899 def copy_path(self, absolute=True):
900 """Copy a selected path to the clipboard"""
901 filename = selection.selection_model().filename()
902 qtutils.copy_path(filename, absolute=absolute)
904 def copy_relpath(self):
905 """Copy a selected relative path to the clipboard"""
906 self.copy_path(absolute=False)
908 def mimeData(self, items):
909 """Return a list of absolute-path URLs"""
910 paths = qtutils.paths_from_items(items, item_filter=lambda item:
911 not item.deleted
912 and core.exists(item.path))
913 return qtutils.mimedata_from_paths(paths)
915 def mimeTypes(self):
916 return qtutils.path_mimetypes()
918 def view_history(self):
919 """Signal that we should view history for paths."""
920 cmds.do(cmds.VisualizePaths, selection.union(selection.selection_model()))
923 class StatusFilterWidget(QtGui.QWidget):
925 def __init__(self, parent=None):
926 QtGui.QWidget.__init__(self, parent)
927 self.main_model = main.model()
929 hint = N_('Filter paths...')
930 self.text = completion.GitStatusFilterLineEdit(hint=hint, parent=self)
931 self.text.setToolTip(hint)
932 self.text.hint.enable(True)
933 self.setFocusProxy(self.text)
934 self._filter = None
936 self.main_layout = qtutils.hbox(defs.no_margin, defs.spacing, self.text)
937 self.setLayout(self.main_layout)
939 self.connect(self.text, SIGNAL('changed()'), self.apply_filter)
940 self.connect(self.text, SIGNAL('cleared()'), self.apply_filter)
941 self.connect(self.text, SIGNAL('return()'), self.apply_filter)
942 self.connect(self.text, SIGNAL('editingFinished()'), self.apply_filter)
944 def apply_filter(self):
945 text = self.text.value()
946 if text == self._filter:
947 return
948 self._filter = text
949 paths = utils.shell_split(text)
950 self.main_model.update_path_filter(paths)