1 from __future__
import division
, absolute_import
, unicode_literals
6 from PyQt4
import QtCore
7 from PyQt4
import QtGui
8 from PyQt4
.QtCore
import Qt
9 from PyQt4
.QtCore
import SIGNAL
11 from cola
import gitcfg
12 from cola
import gitcmds
14 from cola
import utils
15 from cola
import qtutils
16 from cola
import version
17 from cola
import resources
18 from cola
.git
import STDOUT
19 from cola
.i18n
import N_
20 from cola
.models
import main
23 # Custom event type for GitRepoInfoEvents
24 INFO_EVENT_TYPE
= QtCore
.QEvent
.User
+ 42
27 class Columns(object):
28 """Defines columns in the worktree browser"""
35 ALL
= (NAME
, STATUS
, MESSAGE
, AUTHOR
, AGE
)
38 def text(cls
, column
):
39 if column
== cls
.NAME
:
41 elif column
== cls
.STATUS
:
43 elif column
== cls
.MESSAGE
:
45 elif column
== cls
.AUTHOR
:
47 elif column
== cls
.AGE
:
50 raise NotImplementedError('Mapping required for "%s"' % column
)
53 class GitRepoEntryStore(object):
59 """Return the shared GitRepoEntry for a path."""
63 e
= cls
.entries
[path
] = GitRepoEntry(path
)
67 def remove(cls
, path
):
75 """Return the item's path"""
78 except AttributeError:
79 # the root QStandardItem does not have a 'path' attribute
84 class GitRepoModel(QtGui
.QStandardItemModel
):
85 """Provides an interface into a git repository for browsing purposes."""
87 def __init__(self
, parent
):
88 QtGui
.QStandardItemModel
.__init
__(self
, parent
)
91 self
._interesting
_paths
= set()
92 self
._interesting
_files
= set()
93 self
._known
_paths
= set()
95 self
._dir
_rows
= collections
.defaultdict(int)
97 self
.connect(self
, SIGNAL('updated()'),
98 self
.refresh
, Qt
.QueuedConnection
)
101 model
.add_observer(model
.message_updated
, self
._model
_updated
)
103 self
.file_icon
= qtutils
.file_icon()
104 self
.dir_icon
= qtutils
.dir_icon()
106 def mimeData(self
, indexes
):
107 paths
= qtutils
.paths_from_indexes(self
, indexes
,
108 item_type
=GitRepoNameItem
.TYPE
)
109 return qtutils
.mimedata_from_paths(paths
)
112 return qtutils
.path_mimetypes()
115 super(GitRepoModel
, self
).clear()
118 def row(self
, path
, create
=True):
120 row
= self
.entries
[path
]
123 row
= self
.entries
[path
] = [self
.create_column(c
, path
)
124 for c
in Columns
.ALL
]
129 def create_column(self
, col
, path
):
130 """Creates a StandardItem for use in a treeview cell."""
131 # GitRepoNameItem is the only one that returns a custom type(),
132 # so we use to infer selections.
133 if col
== Columns
.NAME
:
134 item
= GitRepoNameItem(path
)
136 item
= GitRepoItem(col
, path
)
139 def _add_file(self
, parent
, path
, insert
=False):
140 """Add a file entry to the model."""
142 self
._known
_paths
.add(path
)
145 row_items
= self
.row(path
)
147 # Use a standard file icon for the name field
148 row_items
[0].setIcon(self
.file_icon
)
151 # Add file paths at the end of the list
152 parent
.appendRow(row_items
)
153 self
.entry(path
).update_name()
155 # Entries exist so try to find an a good insertion point
157 for idx
in range(parent
.rowCount()):
158 child
= parent
.child(idx
, 0)
159 if child
.rowCount() > 0:
161 if path
< child
.path
:
162 parent
.insertRow(idx
, row_items
)
166 # No adequate place found so simply append
168 parent
.appendRow(row_items
)
169 self
.entry(path
).update_name()
171 def add_directory(self
, parent
, path
):
172 """Add a directory entry to the model."""
175 row_items
= self
.row(path
)
177 # Use a standard directory icon
178 row_items
[0].setIcon(self
.dir_icon
)
180 # Insert directories before file paths
181 # TODO: have self._dir_rows's keys based on something less flaky than
182 # QStandardItem instances.
183 parent_path
= _item_path(parent
)
184 row
= self
._dir
_rows
[parent_path
]
185 parent
.insertRow(row
, row_items
)
186 self
._dir
_rows
[parent_path
] += 1
188 # Update the 'name' column for this entry
189 self
.entry(path
).update_name()
190 self
._known
_paths
.add(path
)
194 def path_is_interesting(self
, path
):
195 """Return True if path has a status."""
196 return path
in self
._interesting
_paths
198 def get_paths(self
, files
=None):
199 """Return paths of interest; e.g. paths with a status."""
201 files
= self
.get_files()
202 return utils
.add_parents(files
)
206 return set(model
.staged
+ model
.unstaged
)
208 def _model_updated(self
):
209 """Observes model changes and updates paths accordingly."""
210 self
.emit(SIGNAL('updated()'))
213 old_files
= self
._interesting
_files
214 old_paths
= self
._interesting
_paths
215 new_files
= self
.get_files()
216 new_paths
= self
.get_paths(files
=new_files
)
218 if new_files
!= old_files
or not old_paths
:
221 self
.emit(SIGNAL('restore()'))
224 for path
in sorted(new_paths
.union(old_paths
)):
225 self
.entry(path
).update()
227 self
._interesting
_files
= new_files
228 self
._interesting
_paths
= new_paths
230 def _initialize(self
):
232 self
.setColumnCount(len(Columns
.ALL
))
233 for idx
, header
in enumerate(Columns
.ALL
):
234 text
= Columns
.text(header
)
235 self
.setHeaderData(idx
, Qt
.Horizontal
, QtCore
.QVariant(text
))
238 self
._dir
_rows
= collections
.defaultdict(int)
239 self
._known
_paths
= set()
240 self
._dir
_entries
= {'': self
.invisibleRootItem()}
241 self
._interesting
_files
= files
= self
.get_files()
242 self
._interesting
_paths
= self
.get_paths(files
=files
)
243 for path
in gitcmds
.all_files():
246 def add_file(self
, path
, insert
=False):
247 """Add a file to the model."""
248 dirname
= utils
.dirname(path
)
249 if dirname
in self
._dir
_entries
:
250 parent
= self
._dir
_entries
[dirname
]
252 parent
= self
._create
_dir
_entry
(dirname
, self
._dir
_entries
)
253 self
._dir
_entries
[dirname
] = parent
254 self
._add
_file
(parent
, path
, insert
=insert
)
256 def _create_dir_entry(self
, dirname
, direntries
):
258 Create a directory entry for the model.
260 This ensures that directories are always listed before files.
263 entries
= dirname
.split('/')
265 parent
= self
.invisibleRootItem()
266 curdir_append
= curdir
.append
267 self_add_directory
= self
.add_directory
268 for entry
in entries
:
270 path
= '/'.join(curdir
)
272 parent
= direntries
[path
]
275 parent
= self_add_directory(grandparent
, path
)
276 direntries
[path
] = parent
279 def entry(self
, path
):
280 """Return the GitRepoEntry for a path."""
281 return GitRepoEntryStore
.entry(path
)
284 class TaskRunner(object):
285 """Manages QRunnable tasks to avoid python's garbage collector
287 When PyQt stops referencing a QRunnable Python cleans it up which leads to
288 segfaults, e.g. the dreaded "C++ object has gone away".
290 This class keeps track of tasks and cleans up references to them as they
298 if cls
.singleton
is None:
299 cls
.singleton
= TaskRunner()
304 self
.threadpool
= QtCore
.QThreadPool
.globalInstance()
305 self
.notifier
= QtCore
.QObject()
306 self
.notifier
.connect(self
.notifier
, SIGNAL('task_done(PyQt_PyObject)'),
307 self
.task_done
, Qt
.QueuedConnection
)
311 self
.threadpool
.start(task
)
313 def task_done(self
, task
):
314 if task
in self
.tasks
:
315 self
.tasks
.remove(task
)
317 def cleanup_task(self
, task
):
318 self
.notifier
.emit(SIGNAL('task_done(PyQt_PyObject)'), task
)
321 class GitRepoEntry(QtCore
.QObject
):
323 Provides asynchronous lookup of repository data for a path.
325 Emits signal names matching those defined in Columns.
328 def __init__(self
, path
):
329 QtCore
.QObject
.__init
__(self
)
332 def update_name(self
):
333 """Emits a signal corresponding to the entry's name."""
334 # 'name' is cheap to calculate so simply emit a signal
335 self
.emit(SIGNAL(Columns
.NAME
), utils
.basename(self
.path
))
336 if '/' not in self
.path
:
340 """Starts a GitRepoInfoTask to calculate info for entries."""
341 # GitRepoInfoTask handles expensive lookups
342 task
= GitRepoInfoTask(self
.path
)
343 TaskRunner
.current().run(task
)
346 """Receive GitRepoInfoEvents and emit corresponding Qt signals."""
347 if e
.type() == INFO_EVENT_TYPE
:
349 self
.emit(SIGNAL(e
.signal
), *e
.data
)
351 return QtCore
.QObject
.event(self
, e
)
354 # Support older versions of PyQt
355 if version
.check('pyqt_qrunnable', QtCore
.PYQT_VERSION_STR
):
356 QRunnable
= QtCore
.QRunnable
358 class QRunnable(object):
362 class GitRepoInfoTask(QRunnable
):
363 """Handles expensive git lookups for a path."""
365 def __init__(self
, path
):
366 QRunnable
.__init
__(self
)
367 self
.setAutoDelete(False)
369 self
._cfg
= gitcfg
.current()
374 Return git data for a path.
376 Supported keys are 'date', 'message', and 'author'
380 log_line
= main
.model().git
.log('-1', '--', self
.path
,
382 pretty
=r
'format:%ar%x01%s%x01%an',
387 date
, message
, author
= log_line
.split(chr(0x01), 2)
388 self
._data
['date'] = date
389 self
._data
['message'] = message
390 self
._data
['author'] = author
392 self
._data
['date'] = self
.date()
393 self
._data
['message'] = '-'
394 self
._data
['author'] = self
._cfg
.get('user.name', 'unknown')
395 return self
._data
[key
]
398 """Calculate the name for an entry."""
399 return utils
.basename(self
.path
)
403 Returns a relative date for a file path.
405 This is typically used for new entries that do not have
406 'git log' information.
410 st
= core
.stat(self
.path
)
412 return N_('%d minutes ago') % 0
413 elapsed
= time
.time() - st
.st_mtime
414 minutes
= int(elapsed
/ 60)
416 return N_('%d minutes ago') % minutes
417 hours
= int(elapsed
/ 60 / 60)
419 return N_('%d hours ago') % hours
420 return N_('%d days ago') % int(elapsed
/ 60 / 60 / 24)
423 """Return the status for the entry's path."""
426 unmerged
= utils
.add_parents(model
.unmerged
)
427 modified
= utils
.add_parents(model
.modified
)
428 staged
= utils
.add_parents(model
.staged
)
429 untracked
= utils
.add_parents(model
.untracked
)
430 upstream_changed
= utils
.add_parents(model
.upstream_changed
)
432 if self
.path
in unmerged
:
433 return (resources
.icon('modified.png'), N_('Unmerged'))
434 if self
.path
in modified
and self
.path
in staged
:
435 return (resources
.icon('partial.png'), N_('Partially Staged'))
436 if self
.path
in modified
:
437 return (resources
.icon('modified.png'), N_('Modified'))
438 if self
.path
in staged
:
439 return (resources
.icon('staged.png'), N_('Staged'))
440 if self
.path
in upstream_changed
:
441 return (resources
.icon('upstream.png'), N_('Changed Upstream'))
442 if self
.path
in untracked
:
447 """Perform expensive lookups and post corresponding events."""
448 app
= QtGui
.QApplication
.instance()
449 entry
= GitRepoEntryStore
.entry(self
.path
)
451 GitRepoInfoEvent(Columns
.MESSAGE
, self
.data('message')))
453 GitRepoInfoEvent(Columns
.AGE
, self
.data('date')))
455 GitRepoInfoEvent(Columns
.AUTHOR
, self
.data('author')))
457 GitRepoInfoEvent(Columns
.STATUS
, self
.status()))
459 TaskRunner
.current().cleanup_task(self
)
462 class GitRepoInfoEvent(QtCore
.QEvent
):
463 """Transport mechanism for communicating from a GitRepoInfoTask."""
464 def __init__(self
, signal
, *data
):
465 QtCore
.QEvent
.__init
__(self
, QtCore
.QEvent
.User
+ 1)
470 return INFO_EVENT_TYPE
473 class GitRepoItem(QtGui
.QStandardItem
):
475 Represents a cell in a treeview.
477 Many GitRepoItems map to a single repository path.
478 Each GitRepoItem manages a different cell in the tree view.
479 One is created for each column -- Name, Status, Age, etc.
482 def __init__(self
, column
, path
):
483 QtGui
.QStandardItem
.__init
__(self
)
486 self
.setDragEnabled(False)
487 self
.setEditable(False)
488 entry
= GitRepoEntryStore
.entry(path
)
489 if column
== Columns
.STATUS
:
490 QtCore
.QObject
.connect(entry
, SIGNAL(column
), self
.set_status
,
493 QtCore
.QObject
.connect(entry
, SIGNAL(column
), self
.setText
,
496 def set_status(self
, data
):
499 self
.setIcon(QtGui
.QIcon(icon
))
501 self
.setIcon(QtGui
.QIcon())
505 class GitRepoNameItem(GitRepoItem
):
506 """Subclass GitRepoItem to provide a custom type()."""
507 TYPE
= QtGui
.QStandardItem
.UserType
+ 1
509 def __init__(self
, path
):
510 GitRepoItem
.__init
__(self
, Columns
.NAME
, path
)
511 self
.setDragEnabled(True)
515 Indicate that this item is of a special user-defined type.
517 'name' is the only column that registers a user-defined type.
518 This is done to allow filtering out other columns when determining
519 which paths are selected.
522 return GitRepoNameItem
.TYPE