browse: GitTreeWidget does not need `ref` in its constructor
[git-cola.git] / cola / widgets / browse.py
blob03c70d25c64917a6a251f5569686cad1f7f670eb
1 import os
3 from PyQt4 import QtGui
4 from PyQt4 import QtCore
5 from PyQt4.QtCore import Qt
6 from PyQt4.QtCore import SIGNAL
8 from cola import cmds
9 from cola import core
10 from cola import difftool
11 from cola import gitcmds
12 from cola import utils
13 from cola import qtutils
14 from cola.cmds import BaseCommand
15 from cola.compat import set
16 from cola.git import git
17 from cola.i18n import N_
18 from cola.interaction import Interaction
19 from cola.models import main
20 from cola.models.browse import GitRepoModel
21 from cola.models.browse import GitRepoEntryManager
22 from cola.models.browse import GitRepoNameItem
23 from cola.models.selection import State
24 from cola.models.selection import selection_model
25 from cola.widgets import defs
26 from cola.widgets import standard
27 from cola.widgets.selectcommits import select_commits
30 def worktree_browser_widget(parent, update=True):
31 """Return a widget for immediate use."""
32 view = Browser(parent, update=update)
33 view.tree.setModel(GitRepoModel(view.tree))
34 view.ctl = BrowserController(view.tree)
35 return view
38 def worktree_browser(update=True):
39 """Launch a new worktree browser session."""
40 view = worktree_browser_widget(None, update=update)
41 view.show()
42 return view
45 class Browser(standard.Widget):
46 def __init__(self, parent, update=True):
47 standard.Widget.__init__(self, parent)
48 self.tree = RepoTreeView(self)
49 self.mainlayout = QtGui.QHBoxLayout()
50 self.setLayout(self.mainlayout)
51 self.mainlayout.setMargin(0)
52 self.mainlayout.setSpacing(defs.spacing)
53 self.mainlayout.addWidget(self.tree)
54 self.resize(720, 420)
56 self.connect(self, SIGNAL('updated'), self._updated_callback)
57 self.model = main.model()
58 self.model.add_observer(self.model.message_updated, self.model_updated)
59 qtutils.add_close_action(self)
60 if update:
61 self.model_updated()
63 # Read-only mode property
64 mode = property(lambda self: self.model.mode)
66 def model_updated(self):
67 """Update the title with the current branch and directory name."""
68 self.emit(SIGNAL('updated'))
70 def _updated_callback(self):
71 branch = self.model.currentbranch
72 curdir = os.getcwd()
73 msg = N_('Repository: %s') % curdir
74 msg += '\n'
75 msg += N_('Branch: %s') % branch
76 self.setToolTip(msg)
78 title = N_('%s: %s - Browse') % (self.model.project, branch)
79 if self.mode == self.model.mode_amend:
80 title += ' (%s)' % N_('Amending')
81 self.setWindowTitle(title)
84 class RepoTreeView(standard.TreeView):
85 """Provides a filesystem-like view of a git repository."""
87 def __init__(self, parent):
88 standard.TreeView.__init__(self, parent)
90 self.setRootIsDecorated(True)
91 self.setSortingEnabled(False)
92 self.setSelectionMode(QtGui.QAbstractItemView.ExtendedSelection)
94 # Observe model updates
95 model = main.model()
96 model.add_observer(model.message_updated, self.update_actions)
98 # The non-Qt cola application model
99 self.connect(self, SIGNAL('expanded(QModelIndex)'), self.size_columns)
100 self.connect(self, SIGNAL('collapsed(QModelIndex)'), self.size_columns)
102 # Sync selection before the key press event changes the model index
103 self.connect(self, SIGNAL('indexAboutToChange()'), self.sync_selection)
105 self.action_history =\
106 self._create_action(
107 N_('View History...'),
108 N_('View history for selected path(s).'),
109 self.view_history,
110 'Shift+Ctrl+H')
111 self.action_stage =\
112 self._create_action(N_('Stage Selected'),
113 N_('Stage selected path(s) for commit.'),
114 self.stage_selected,
115 cmds.Stage.SHORTCUT)
116 self.action_unstage =\
117 self._create_action(
118 N_('Unstage Selected'),
119 N_('Remove selected path(s) from the staging area.'),
120 self.unstage_selected,
121 'Ctrl+U')
123 self.action_untrack =\
124 self._create_action(N_('Untrack Selected'),
125 N_('Stop tracking path(s)'),
126 self.untrack_selected)
128 self.action_difftool =\
129 self._create_action(cmds.LaunchDifftool.name(),
130 N_('Launch git-difftool on the current path.'),
131 cmds.run(cmds.LaunchDifftool),
132 cmds.LaunchDifftool.SHORTCUT)
133 self.action_difftool_predecessor =\
134 self._create_action(N_('Diff Against Predecessor...'),
135 N_('Launch git-difftool against previous versions.'),
136 self.difftool_predecessor,
137 'Shift+Ctrl+D')
138 self.action_revert =\
139 self._create_action(N_('Revert Uncommitted Changes...'),
140 N_('Revert changes to selected path(s).'),
141 self.revert,
142 'Ctrl+Z')
143 self.action_editor =\
144 self._create_action(cmds.LaunchEditor.name(),
145 N_('Edit selected path(s).'),
146 cmds.run(cmds.LaunchEditor),
147 cmds.LaunchDifftool.SHORTCUT)
149 def size_columns(self):
150 """Set the column widths."""
151 self.resizeColumnToContents(0)
153 def update_actions(self):
154 """Enable/disable actions."""
155 selection = self.selected_paths()
156 selected = bool(selection)
157 staged = bool(self.selected_staged_paths(selection=selection))
158 modified = bool(self.selected_modified_paths(selection=selection))
159 unstaged = bool(self.selected_unstaged_paths(selection=selection))
160 tracked = bool(self.selected_tracked_paths())
162 self.action_history.setEnabled(selected)
163 self.action_stage.setEnabled(unstaged)
164 self.action_unstage.setEnabled(staged)
165 self.action_untrack.setEnabled(tracked)
166 self.action_difftool.setEnabled(staged or modified)
167 self.action_difftool_predecessor.setEnabled(tracked)
168 self.action_revert.setEnabled(tracked)
170 def contextMenuEvent(self, event):
171 """Create a context menu."""
172 self.update_actions()
173 menu = QtGui.QMenu(self)
174 menu.addAction(self.action_editor)
175 menu.addAction(self.action_stage)
176 menu.addAction(self.action_unstage)
177 menu.addSeparator()
178 menu.addAction(self.action_history)
179 menu.addAction(self.action_difftool)
180 menu.addAction(self.action_difftool_predecessor)
181 menu.addSeparator()
182 menu.addAction(self.action_revert)
183 menu.addAction(self.action_untrack)
184 menu.exec_(self.mapToGlobal(event.pos()))
186 def mousePressEvent(self, event):
187 """Synchronize the selection on mouse-press."""
188 result = QtGui.QTreeView.mousePressEvent(self, event)
189 self.sync_selection()
190 return result
192 def sync_selection(self):
193 """Push selection into the selection model."""
194 staged = []
195 unmerged = []
196 modified = []
197 untracked = []
198 state = State(staged, unmerged, modified, untracked)
200 paths = self.selected_paths()
201 model = main.model()
202 model_staged = utils.add_parents(set(model.staged))
203 model_modified = utils.add_parents(set(model.modified))
204 model_unmerged = utils.add_parents(set(model.unmerged))
205 model_untracked = utils.add_parents(set(model.untracked))
207 for path in paths:
208 if path in model_unmerged:
209 unmerged.append(path)
210 elif path in model_untracked:
211 untracked.append(path)
212 elif path in model_staged:
213 staged.append(path)
214 elif path in model_modified:
215 modified.append(path)
216 else:
217 staged.append(path)
218 # Push the new selection into the model.
219 selection_model().set_selection(state)
220 return paths
222 def selectionChanged(self, old_selection, new_selection):
223 """Override selectionChanged to update available actions."""
224 result = QtGui.QTreeView.selectionChanged(self, old_selection, new_selection)
225 self.update_actions()
226 paths = self.sync_selection()
228 if paths and self.model().path_is_interesting(paths[0]):
229 cached = paths[0] in main.model().staged
230 cmds.do(cmds.Diff, paths, cached)
231 return result
233 def setModel(self, model):
234 """Set the concrete QAbstractItemModel instance."""
235 QtGui.QTreeView.setModel(self, model)
236 self.size_columns()
238 def item_from_index(self, model_index):
239 """Return the name item corresponding to the model index."""
240 index = model_index.sibling(model_index.row(), 0)
241 return self.model().itemFromIndex(index)
243 def selected_paths(self):
244 """Return the selected paths."""
245 items = map(self.model().itemFromIndex, self.selectedIndexes())
246 return [i.path for i in items
247 if i.type() == GitRepoNameItem.TYPE]
249 def selected_staged_paths(self, selection=None):
250 """Return selected staged paths."""
251 if not selection:
252 selection = self.selected_paths()
253 staged = utils.add_parents(set(main.model().staged))
254 return [p for p in selection if p in staged]
256 def selected_modified_paths(self, selection=None):
257 """Return selected modified paths."""
258 if not selection:
259 selection = self.selected_paths()
260 model = main.model()
261 modified = utils.add_parents(set(model.modified))
262 return [p for p in selection if p in modified]
264 def selected_unstaged_paths(self, selection=None):
265 """Return selected unstaged paths."""
266 if not selection:
267 selection = self.selected_paths()
268 model = main.model()
269 modified = utils.add_parents(set(model.modified))
270 untracked = utils.add_parents(set(model.untracked))
271 unstaged = modified.union(untracked)
272 return [p for p in selection if p in unstaged]
274 def selected_tracked_paths(self, selection=None):
275 """Return selected tracked paths."""
276 if not selection:
277 selection = self.selected_paths()
278 model = main.model()
279 staged = set(self.selected_staged_paths())
280 modified = set(self.selected_modified_paths())
281 untracked = utils.add_parents(set(model.untracked))
282 tracked = staged.union(modified)
283 return [p for p in selection
284 if p not in untracked or p in tracked]
286 def _create_action(self, name, tooltip, slot, shortcut=None):
287 """Create an action with a shortcut, tooltip, and callback slot."""
288 action = QtGui.QAction(name, self)
289 action.setStatusTip(tooltip)
290 if shortcut is not None:
291 if hasattr(Qt, 'WidgetWithChildrenShortcut'):
292 action.setShortcutContext(Qt.WidgetWithChildrenShortcut)
293 action.setShortcut(shortcut)
294 self.addAction(action)
295 qtutils.connect_action(action, slot)
296 return action
298 def view_history(self):
299 """Signal that we should view history for paths."""
300 self.emit(SIGNAL('history(QStringList)'), self.selected_paths())
302 def stage_selected(self):
303 """Signal that we should stage selected paths."""
304 cmds.do(cmds.Stage, self.selected_unstaged_paths())
306 def unstage_selected(self):
307 """Signal that we should stage selected paths."""
308 cmds.do(cmds.Unstage, self.selected_staged_paths())
310 def untrack_selected(self):
311 """untrack selected paths."""
312 cmds.do(cmds.Untrack, self.selected_tracked_paths())
314 def difftool_predecessor(self):
315 """Diff paths against previous versions."""
316 paths = self.selected_tracked_paths()
317 self.emit(SIGNAL('difftool_predecessor'), paths)
319 def revert(self):
320 """Signal that we should revert changes to a path."""
321 if not qtutils.confirm(N_('Revert Uncommitted Changes?'),
322 N_('This operation drops uncommitted changes.\n'
323 'These changes cannot be recovered.'),
324 N_('Revert the uncommitted changes?'),
325 N_('Revert Uncommitted Changes'),
326 default=True,
327 icon=qtutils.icon('undo.svg')):
328 return
329 paths = self.selected_tracked_paths()
330 cmds.do(cmds.Checkout, ['HEAD', '--'] + paths)
332 def current_path(self):
333 """Return the path for the current item."""
334 index = self.currentIndex()
335 if not index.isValid():
336 return None
337 return self.item_from_index(index).path
340 class BrowserController(QtCore.QObject):
341 def __init__(self, view=None):
342 QtCore.QObject.__init__(self, view)
343 self.model = main.model()
344 self.view = view
345 self.updated = set()
346 self.connect(view, SIGNAL('history(QStringList)'),
347 self.view_history)
348 self.connect(view, SIGNAL('expanded(QModelIndex)'),
349 self.query_model)
350 self.connect(view, SIGNAL('difftool_predecessor'),
351 self.difftool_predecessor)
353 def view_history(self, entries):
354 """Launch the configured history browser path-limited to entries."""
355 entries = map(unicode, entries)
356 cmds.do(cmds.VisualizePaths, entries)
358 def query_model(self, model_index):
359 """Update information about a directory as it is expanded."""
360 item = self.view.item_from_index(model_index)
361 path = item.path
362 if path in self.updated:
363 return
364 self.updated.add(path)
365 GitRepoEntryManager.entry(path).update()
366 entry = GitRepoEntryManager.entry
367 for row in xrange(item.rowCount()):
368 path = item.child(row, 0).path
369 entry(path).update()
371 def difftool_predecessor(self, paths):
372 """Prompt for an older commit and launch difftool against it."""
373 args = ['--'] + paths
374 revs, summaries = gitcmds.log_helper(all=False, extra_args=args)
375 commits = select_commits(N_('Select Previous Version'),
376 revs, summaries, multiselect=False)
377 if not commits:
378 return
379 commit = commits[0]
380 difftool.launch([commit, '--'] + paths)
383 class BrowseModel(object):
384 def __init__(self, ref):
385 self.ref = ref
386 self.relpath = None
387 self.filename = None
390 class SaveBlob(BaseCommand):
391 def __init__(self, model):
392 self.model = model
394 def do(self):
395 model = self.model
396 cmd = ['git', 'show', '%s:%s' % (model.ref, model.relpath)]
397 with core.xopen(model.filename, 'wb') as fp:
398 proc = core.start_command(cmd, stdout=fp)
399 out, err = proc.communicate()
401 status = proc.returncode
402 msg = (N_('Saved "%(filename)s" from "%(ref)s" to "%(destination)s"') %
403 dict(filename=model.relpath,
404 ref=model.ref,
405 destination=model.filename))
406 Interaction.log_status(status, msg, '')
408 Interaction.information(
409 N_('File Saved'),
410 N_('File saved to "%s"') % model.filename)
414 class BrowseDialog(QtGui.QDialog):
416 @staticmethod
417 def browse(ref):
418 parent = qtutils.active_window()
419 model = BrowseModel(ref)
420 dlg = BrowseDialog(model, parent=parent)
421 dlg_model = GitTreeModel(ref, dlg)
422 dlg.setModel(dlg_model)
423 dlg.setWindowTitle(N_('Browsing %s') % model.ref)
424 if hasattr(parent, 'width'):
425 dlg.resize(parent.width()*3/4, 333)
426 else:
427 dlg.resize(420, 333)
428 dlg.show()
429 dlg.raise_()
430 if dlg.exec_() != dlg.Accepted:
431 return None
432 return dlg
434 @staticmethod
435 def select_file(ref):
436 parent = qtutils.active_window()
437 model = BrowseModel(ref)
438 dlg = BrowseDialog(model, select_file=True, parent=parent)
439 dlg_model = GitTreeModel(ref, dlg)
440 dlg.setModel(dlg_model)
441 dlg.setWindowTitle(N_('Select file from "%s"') % model.ref)
442 dlg.resize(parent.width()*3/4, 333)
443 dlg.show()
444 dlg.raise_()
445 if dlg.exec_() != dlg.Accepted:
446 return None
447 return model.filename
449 @staticmethod
450 def select_file_from_list(file_list, title=N_('Select File')):
451 parent = qtutils.active_window()
452 model = BrowseModel(None)
453 dlg = BrowseDialog(model, select_file=True, parent=parent)
454 dlg_model = GitFileTreeModel(dlg)
455 dlg_model.add_files(file_list)
456 dlg.setModel(dlg_model)
457 dlg.expandAll()
458 dlg.setWindowTitle(title)
459 dlg.resize(parent.width()*3/4, 333)
460 dlg.show()
461 dlg.raise_()
462 if dlg.exec_() != dlg.Accepted:
463 return None
464 return model.filename
466 def __init__(self, model, select_file=False, parent=None):
467 QtGui.QDialog.__init__(self, parent)
468 self.setAttribute(Qt.WA_MacMetalStyle)
469 if parent is not None:
470 self.setWindowModality(Qt.WindowModal)
472 # updated for use by commands
473 self.model = model
475 # widgets
476 self.tree = GitTreeWidget(parent=self)
477 self.close = QtGui.QPushButton(N_('Close'))
478 self.save = QtGui.QPushButton(select_file and N_('Select') or N_('Save'))
479 self.save.setDefault(True)
480 self.save.setEnabled(False)
482 # layouts
483 self.btnlayt = QtGui.QHBoxLayout()
484 self.btnlayt.addStretch()
485 self.btnlayt.addWidget(self.close)
486 self.btnlayt.addWidget(self.save)
488 self.layt = QtGui.QVBoxLayout()
489 self.layt.setMargin(defs.margin)
490 self.layt.setSpacing(defs.spacing)
491 self.layt.addWidget(self.tree)
492 self.layt.addLayout(self.btnlayt)
493 self.setLayout(self.layt)
495 # connections
496 if select_file:
497 self.connect(self.tree, SIGNAL('path_chosen'), self.path_chosen)
498 else:
499 self.connect(self.tree, SIGNAL('path_chosen'), self.save_path)
501 self.connect(self.tree, SIGNAL('selectionChanged()'),
502 self.selection_changed)
504 qtutils.connect_button(self.close, self.reject)
505 qtutils.connect_button(self.save, self.save_blob)
507 def expandAll(self):
508 self.tree.expandAll()
510 def setModel(self, model):
511 self.tree.setModel(model)
513 def path_chosen(self, path, close=True):
514 """Update the model from the view"""
515 model = self.model
516 model.relpath = path
517 model.filename = path
518 if close:
519 self.accept()
521 def save_path(self, path):
522 """Choose an output filename based on the selected path"""
523 self.path_chosen(path, close=False)
524 model = self.model
525 filename = qtutils.save_as(model.filename)
526 if not filename:
527 return
528 model.filename = filename
529 cmds.do(SaveBlob, model)
530 self.accept()
532 def save_blob(self):
533 """Save the currently selected file"""
534 filenames = self.tree.selected_files()
535 if not filenames:
536 return
537 self.path_chosen(filenames[0], close=True)
539 def selection_changed(self):
540 """Update actions based on the current selection"""
541 filenames = self.tree.selected_files()
542 self.save.setEnabled(bool(filenames))
545 class GitTreeWidget(standard.TreeView):
546 def __init__(self, parent=None):
547 standard.TreeView.__init__(self, parent)
548 self.setHeaderHidden(True)
550 self.connect(self, SIGNAL('doubleClicked(const QModelIndex &)'),
551 self.double_clicked)
553 def double_clicked(self, index):
554 item = self.model().itemFromIndex(index)
555 if item is None:
556 return
557 if item.is_dir:
558 return
559 self.emit(SIGNAL('path_chosen'), item.path)
561 def selected_files(self):
562 items = map(self.model().itemFromIndex, self.selectedIndexes())
563 return [i.path for i in items if not i.is_dir]
565 def selectionChanged(self, old_selection, new_selection):
566 QtGui.QTreeView.selectionChanged(self, old_selection, new_selection)
567 self.emit(SIGNAL('selectionChanged()'))
569 def select_first_file(self):
570 """Select the first filename in the tree"""
571 model = self.model()
572 idx = self.indexAt(QtCore.QPoint(0, 0))
573 item = model.itemFromIndex(idx)
574 while idx and idx.isValid() and item and item.is_dir:
575 idx = self.indexBelow(idx)
576 item = model.itemFromIndex(idx)
578 if idx and idx.isValid() and item:
579 self.setCurrentIndex(idx)
582 class GitFileTreeModel(QtGui.QStandardItemModel):
583 """Presents a list of file paths as a hierarchical tree."""
584 def __init__(self, parent):
585 QtGui.QStandardItemModel.__init__(self, parent)
586 self.dir_entries = {'': self.invisibleRootItem()}
587 self.dir_rows = {}
589 def clear(self):
590 QtGui.QStandardItemModel.clear(self)
591 self.dir_rows = {}
592 self.dir_entries = {'': self.invisibleRootItem()}
594 def add_files(self, files):
595 """Add a list of files"""
596 add_file = self.add_file
597 for f in files:
598 add_file(f)
600 def add_file(self, path):
601 """Add a file to the model."""
602 dirname = utils.dirname(path)
603 dir_entries = self.dir_entries
604 try:
605 parent = dir_entries[dirname]
606 except KeyError:
607 parent = dir_entries[dirname] = self.create_dir_entry(dirname)
609 row_items = self.create_row(path, False)
610 parent.appendRow(row_items)
612 def add_directory(self, parent, path):
613 """Add a directory entry to the model."""
614 # Create model items
615 row_items = self.create_row(path, True)
617 # Insert directories before file paths
618 try:
619 row = self.dir_rows[parent]
620 except KeyError:
621 row = self.dir_rows[parent] = 0
623 parent.insertRow(row, row_items)
624 self.dir_rows[parent] += 1
625 self.dir_entries[path] = row_items[0]
627 return row_items[0]
629 def create_row(self, path, is_dir):
630 """Return a list of items representing a row."""
631 return [GitTreeItem(path, is_dir)]
633 def create_dir_entry(self, dirname):
635 Create a directory entry for the model.
637 This ensures that directories are always listed before files.
640 entries = dirname.split('/')
641 curdir = []
642 parent = self.invisibleRootItem()
643 curdir_append = curdir.append
644 self_add_directory = self.add_directory
645 dir_entries = self.dir_entries
646 for entry in entries:
647 curdir_append(entry)
648 path = '/'.join(curdir)
649 try:
650 parent = dir_entries[path]
651 except KeyError:
652 grandparent = parent
653 parent = self_add_directory(grandparent, path)
654 dir_entries[path] = parent
655 return parent
658 class GitTreeModel(GitFileTreeModel):
659 def __init__(self, ref, parent):
660 GitFileTreeModel.__init__(self, parent)
661 self.ref = ref
662 self._initialize()
664 def _initialize(self):
665 """Iterate over git-ls-tree and create GitTreeItems."""
666 status, out, err = git.ls_tree('--full-tree', '-r', '-t', '-z',
667 self.ref)
668 if status != 0:
669 Interaction.log_status(status, out, err)
670 return
672 if not out:
673 return
675 for line in out[:-1].split('\0'):
676 # .....6 ...4 ......................................40
677 # 040000 tree c127cde9a0c644a3a8fef449a244f47d5272dfa6 relative
678 # 100644 blob 139e42bf4acaa4927ec9be1ec55a252b97d3f1e2 relative/path
679 objtype = line[7]
680 relpath = line[6 + 1 + 4 + 1 + 40 + 1:]
681 if objtype == 't':
682 parent = self.dir_entries[utils.dirname(relpath)]
683 self.add_directory(parent, relpath)
684 elif objtype == 'b':
685 self.add_file(relpath)
688 class GitTreeItem(QtGui.QStandardItem):
690 Represents a cell in a treeview.
692 Many GitRepoItems could map to a single repository path,
693 but this tree only has a single column.
694 Each GitRepoItem manages a different cell in the tree view.
697 def __init__(self, path, is_dir):
698 QtGui.QStandardItem.__init__(self)
699 self.is_dir = is_dir
700 self.path = path
701 self.setEditable(False)
702 self.setDragEnabled(False)
703 self.setText(utils.basename(path))
704 if is_dir:
705 self.setIcon(qtutils.dir_icon())
706 else:
707 self.setIcon(qtutils.file_icon())