3 from PyQt4
import QtGui
4 from PyQt4
import QtCore
5 from PyQt4
.QtCore
import Qt
6 from PyQt4
.QtCore
import SIGNAL
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
)
38 def worktree_browser(update
=True):
39 """Launch a new worktree browser session."""
40 view
= worktree_browser_widget(None, update
=update
)
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
)
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
)
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
73 msg
= N_('Repository: %s') % curdir
75 msg
+= N_('Branch: %s') % branch
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
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
=\
107 N_('View History...'),
108 N_('View history for selected path(s).'),
112 self
._create
_action
(N_('Stage Selected'),
113 N_('Stage selected path(s) for commit.'),
116 self
.action_unstage
=\
118 N_('Unstage Selected'),
119 N_('Remove selected path(s) from the staging area.'),
120 self
.unstage_selected
,
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
,
138 self
.action_revert
=\
139 self
._create
_action
(N_('Revert Uncommitted Changes...'),
140 N_('Revert changes to selected path(s).'),
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
)
178 menu
.addAction(self
.action_history
)
179 menu
.addAction(self
.action_difftool
)
180 menu
.addAction(self
.action_difftool_predecessor
)
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()
192 def sync_selection(self
):
193 """Push selection into the selection model."""
198 state
= State(staged
, unmerged
, modified
, untracked
)
200 paths
= self
.selected_paths()
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
))
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
:
214 elif path
in model_modified
:
215 modified
.append(path
)
218 # Push the new selection into the model.
219 selection_model().set_selection(state
)
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
)
233 def setModel(self
, model
):
234 """Set the concrete QAbstractItemModel instance."""
235 QtGui
.QTreeView
.setModel(self
, model
)
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."""
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."""
259 selection
= self
.selected_paths()
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."""
267 selection
= self
.selected_paths()
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."""
277 selection
= self
.selected_paths()
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
)
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
)
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'),
327 icon
=qtutils
.icon('undo.svg')):
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():
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()
346 self
.connect(view
, SIGNAL('history(QStringList)'),
348 self
.connect(view
, SIGNAL('expanded(QModelIndex)'),
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
)
362 if path
in self
.updated
:
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
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)
380 difftool
.launch([commit
, '--'] + paths
)
383 class BrowseModel(object):
384 def __init__(self
, ref
):
390 class SaveBlob(BaseCommand
):
391 def __init__(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
,
405 destination
=model
.filename
))
406 Interaction
.log_status(status
, msg
, '')
408 Interaction
.information(
410 N_('File saved to "%s"') % model
.filename
)
414 class BrowseDialog(QtGui
.QDialog
):
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)
430 if dlg
.exec_() != dlg
.Accepted
:
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)
445 if dlg
.exec_() != dlg
.Accepted
:
447 return model
.filename
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
)
458 dlg
.setWindowTitle(title
)
459 dlg
.resize(parent
.width()*3/4, 333)
462 if dlg
.exec_() != dlg
.Accepted
:
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
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)
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
)
497 self
.connect(self
.tree
, SIGNAL('path_chosen'), self
.path_chosen
)
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
)
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"""
517 model
.filename
= path
521 def save_path(self
, path
):
522 """Choose an output filename based on the selected path"""
523 self
.path_chosen(path
, close
=False)
525 filename
= qtutils
.save_as(model
.filename
)
528 model
.filename
= filename
529 cmds
.do(SaveBlob
, model
)
533 """Save the currently selected file"""
534 filenames
= self
.tree
.selected_files()
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 &)'),
553 def double_clicked(self
, index
):
554 item
= self
.model().itemFromIndex(index
)
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"""
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()}
590 QtGui
.QStandardItemModel
.clear(self
)
592 self
.dir_entries
= {'': self
.invisibleRootItem()}
594 def add_files(self
, files
):
595 """Add a list of files"""
596 add_file
= self
.add_file
600 def add_file(self
, path
):
601 """Add a file to the model."""
602 dirname
= utils
.dirname(path
)
603 dir_entries
= self
.dir_entries
605 parent
= dir_entries
[dirname
]
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."""
615 row_items
= self
.create_row(path
, True)
617 # Insert directories before file paths
619 row
= self
.dir_rows
[parent
]
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]
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('/')
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
:
648 path
= '/'.join(curdir
)
650 parent
= dir_entries
[path
]
653 parent
= self_add_directory(grandparent
, path
)
654 dir_entries
[path
] = parent
658 class GitTreeModel(GitFileTreeModel
):
659 def __init__(self
, ref
, parent
):
660 GitFileTreeModel
.__init
__(self
, parent
)
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',
669 Interaction
.log_status(status
, out
, err
)
675 for line
in out
[:-1].split('\0'):
676 # .....6 ...4 ......................................40
677 # 040000 tree c127cde9a0c644a3a8fef449a244f47d5272dfa6 relative
678 # 100644 blob 139e42bf4acaa4927ec9be1ec55a252b97d3f1e2 relative/path
680 relpath
= line
[6 + 1 + 4 + 1 + 40 + 1:]
682 parent
= self
.dir_entries
[utils
.dirname(relpath
)]
683 self
.add_directory(parent
, relpath
)
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
)
701 self
.setEditable(False)
702 self
.setDragEnabled(False)
703 self
.setText(utils
.basename(path
))
705 self
.setIcon(qtutils
.dir_icon())
707 self
.setIcon(qtutils
.file_icon())