1 from __future__
import division
, absolute_import
, unicode_literals
6 from qtpy
import QtCore
8 from qtpy
import QtWidgets
9 from qtpy
.QtCore
import Qt
10 from qtpy
.QtCore
import Signal
12 from cola
import gitcfg
13 from cola
import gitcmds
15 from cola
import icons
16 from cola
import utils
17 from cola
import qtutils
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
.Type(QtCore
.QEvent
.registerEventType())
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):
58 def entry(cls
, path
, parent
, runtask
):
59 """Return the shared GitRepoEntry for a path."""
63 e
= cls
.entries
[path
] = GitRepoEntry(path
, parent
, runtask
)
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."""
90 def __init__(self
, parent
):
91 QtGui
.QStandardItemModel
.__init
__(self
, parent
)
94 self
._runtask
= qtutils
.RunTask(parent
=parent
)
96 self
._interesting
_paths
= set()
97 self
._interesting
_files
= set()
98 self
._known
_paths
= set()
99 self
._dir
_entries
= {}
100 self
._dir
_rows
= collections
.defaultdict(int)
102 self
.updated
.connect(self
.refresh
, type=Qt
.QueuedConnection
)
105 model
.add_observer(model
.message_updated
, self
._model
_updated
)
107 self
.file_icon
= icons
.file_text()
108 self
.dir_icon
= icons
.directory()
110 def mimeData(self
, indexes
):
111 paths
= qtutils
.paths_from_indexes(self
, indexes
,
112 item_type
=GitRepoNameItem
.TYPE
)
113 return qtutils
.mimedata_from_paths(paths
)
116 return qtutils
.path_mimetypes()
119 super(GitRepoModel
, self
).clear()
122 def row(self
, path
, create
=True):
124 row
= self
.entries
[path
]
127 row
= self
.entries
[path
] = [self
.create_column(c
, path
)
128 for c
in Columns
.ALL
]
133 def create_column(self
, col
, path
):
134 """Creates a StandardItem for use in a treeview cell."""
135 # GitRepoNameItem is the only one that returns a custom type(),
136 # so we use to infer selections.
137 if col
== Columns
.NAME
:
138 item
= GitRepoNameItem(path
, self
._parent
, self
._runtask
)
140 item
= GitRepoItem(col
, path
, self
._parent
, self
._runtask
)
143 def _add_file(self
, parent
, path
, insert
=False):
144 """Add a file entry to the model."""
146 self
._known
_paths
.add(path
)
149 row_items
= self
.row(path
)
151 # Use a standard file icon for the name field
152 row_items
[0].setIcon(self
.file_icon
)
155 # Add file paths at the end of the list
156 parent
.appendRow(row_items
)
157 self
.entry(path
).update_name()
159 # Entries exist so try to find an a good insertion point
161 for idx
in range(parent
.rowCount()):
162 child
= parent
.child(idx
, 0)
163 if child
.rowCount() > 0:
165 if path
< child
.path
:
166 parent
.insertRow(idx
, row_items
)
170 # No adequate place found so simply append
172 parent
.appendRow(row_items
)
173 self
.entry(path
).update_name()
175 def add_directory(self
, parent
, path
):
176 """Add a directory entry to the model."""
179 row_items
= self
.row(path
)
181 # Use a standard directory icon
182 row_items
[0].setIcon(self
.dir_icon
)
184 # Insert directories before file paths
185 # TODO: have self._dir_rows's keys based on something less flaky than
186 # QStandardItem instances.
187 parent_path
= _item_path(parent
)
188 row
= self
._dir
_rows
[parent_path
]
189 parent
.insertRow(row
, row_items
)
190 self
._dir
_rows
[parent_path
] += 1
192 # Update the 'name' column for this entry
193 self
.entry(path
).update_name()
194 self
._known
_paths
.add(path
)
198 def path_is_interesting(self
, path
):
199 """Return True if path has a status."""
200 return path
in self
._interesting
_paths
202 def get_paths(self
, files
=None):
203 """Return paths of interest; e.g. paths with a status."""
205 files
= self
.get_files()
206 return utils
.add_parents(files
)
210 return set(model
.staged
+ model
.unstaged
)
212 def _model_updated(self
):
213 """Observes model changes and updates paths accordingly."""
217 old_files
= self
._interesting
_files
218 old_paths
= self
._interesting
_paths
219 new_files
= self
.get_files()
220 new_paths
= self
.get_paths(files
=new_files
)
222 if new_files
!= old_files
or not old_paths
:
228 for path
in sorted(new_paths
.union(old_paths
)):
229 self
.entry(path
).update()
231 self
._interesting
_files
= new_files
232 self
._interesting
_paths
= new_paths
234 def _initialize(self
):
236 self
.setColumnCount(len(Columns
.ALL
))
237 for idx
, header
in enumerate(Columns
.ALL
):
238 text
= Columns
.text(header
)
239 self
.setHeaderData(idx
, Qt
.Horizontal
, text
)
242 self
._dir
_rows
= collections
.defaultdict(int)
243 self
._known
_paths
= set()
244 self
._dir
_entries
= {'': self
.invisibleRootItem()}
245 self
._interesting
_files
= files
= self
.get_files()
246 self
._interesting
_paths
= self
.get_paths(files
=files
)
247 for path
in gitcmds
.all_files():
250 def add_file(self
, path
, insert
=False):
251 """Add a file to the model."""
252 dirname
= utils
.dirname(path
)
253 if dirname
in self
._dir
_entries
:
254 parent
= self
._dir
_entries
[dirname
]
256 parent
= self
._create
_dir
_entry
(dirname
, self
._dir
_entries
)
257 self
._dir
_entries
[dirname
] = parent
258 self
._add
_file
(parent
, path
, insert
=insert
)
260 def _create_dir_entry(self
, dirname
, direntries
):
262 Create a directory entry for the model.
264 This ensures that directories are always listed before files.
267 entries
= dirname
.split('/')
269 parent
= self
.invisibleRootItem()
270 curdir_append
= curdir
.append
271 self_add_directory
= self
.add_directory
272 for entry
in entries
:
274 path
= '/'.join(curdir
)
276 parent
= direntries
[path
]
279 parent
= self_add_directory(grandparent
, path
)
280 direntries
[path
] = parent
283 def entry(self
, path
):
284 """Return the GitRepoEntry for a path."""
285 return GitRepoEntryStore
.entry(path
, self
._parent
, self
._runtask
)
288 class GitRepoEntry(QtCore
.QObject
):
290 Provides asynchronous lookup of repository data for a path.
292 Emits signal names matching those defined in Columns.
295 name
= Signal(object)
296 status
= Signal(object)
297 author
= Signal(object)
298 message
= Signal(object)
301 def __init__(self
, path
, parent
, runtask
):
302 QtCore
.QObject
.__init
__(self
, parent
)
304 self
.runtask
= runtask
306 def update_name(self
):
307 """Emits a signal corresponding to the entry's name."""
308 # 'name' is cheap to calculate so simply emit a signal
309 self
.name
.emit(utils
.basename(self
.path
))
310 if '/' not in self
.path
:
314 """Starts a GitRepoInfoTask to calculate info for entries."""
315 # GitRepoInfoTask handles expensive lookups
316 task
= GitRepoInfoTask(self
.path
, self
, self
.runtask
)
317 self
.runtask
.start(task
)
320 """Receive GitRepoInfoEvents and emit corresponding Qt signals."""
321 if e
.type() == INFO_EVENT_TYPE
:
323 signal
= getattr(self
, e
.signal
)
326 return QtCore
.QObject
.event(self
, e
)
329 class GitRepoInfoTask(qtutils
.Task
):
330 """Handles expensive git lookups for a path."""
332 def __init__(self
, path
, parent
, runtask
):
333 qtutils
.Task
.__init
__(self
, parent
)
335 self
._parent
= parent
336 self
._runtask
= runtask
337 self
._cfg
= gitcfg
.current()
342 Return git data for a path.
344 Supported keys are 'date', 'message', and 'author'
348 log_line
= main
.model().git
.log('-1', '--', self
.path
,
350 pretty
=r
'format:%ar%x01%s%x01%an',
355 date
, message
, author
= log_line
.split(chr(0x01), 2)
356 self
._data
['date'] = date
357 self
._data
['message'] = message
358 self
._data
['author'] = author
360 self
._data
['date'] = self
.date()
361 self
._data
['message'] = '-'
362 self
._data
['author'] = self
._cfg
.get('user.name', 'unknown')
363 return self
._data
[key
]
366 """Calculate the name for an entry."""
367 return utils
.basename(self
.path
)
371 Returns a relative date for a file path.
373 This is typically used for new entries that do not have
374 'git log' information.
378 st
= core
.stat(self
.path
)
380 return N_('%d minutes ago') % 0
381 elapsed
= time
.time() - st
.st_mtime
382 minutes
= int(elapsed
/ 60)
384 return N_('%d minutes ago') % minutes
385 hours
= int(elapsed
/ 60 / 60)
387 return N_('%d hours ago') % hours
388 return N_('%d days ago') % int(elapsed
/ 60 / 60 / 24)
391 """Return the status for the entry's path."""
394 unmerged
= utils
.add_parents(model
.unmerged
)
395 modified
= utils
.add_parents(model
.modified
)
396 staged
= utils
.add_parents(model
.staged
)
397 untracked
= utils
.add_parents(model
.untracked
)
398 upstream_changed
= utils
.add_parents(model
.upstream_changed
)
400 if self
.path
in unmerged
:
401 status
= (icons
.modified_name(), N_('Unmerged'))
402 elif self
.path
in modified
and self
.path
in staged
:
403 status
= (icons
.partial_name(), N_('Partially Staged'))
404 elif self
.path
in modified
:
405 status
= (icons
.modified_name(), N_('Modified'))
406 elif self
.path
in staged
:
407 status
= (icons
.staged_name(), N_('Staged'))
408 elif self
.path
in upstream_changed
:
409 status
= (icons
.upstream_name(), N_('Changed Upstream'))
410 elif self
.path
in untracked
:
417 """Perform expensive lookups and post corresponding events."""
418 app
= QtWidgets
.QApplication
.instance()
419 entry
= GitRepoEntryStore
.entry(self
.path
, self
._parent
, self
._runtask
)
421 GitRepoInfoEvent(Columns
.MESSAGE
, self
.data('message')))
423 GitRepoInfoEvent(Columns
.AGE
, self
.data('date')))
425 GitRepoInfoEvent(Columns
.AUTHOR
, self
.data('author')))
427 GitRepoInfoEvent(Columns
.STATUS
, self
.status()))
430 class GitRepoInfoEvent(QtCore
.QEvent
):
431 """Transport mechanism for communicating from a GitRepoInfoTask."""
432 def __init__(self
, signal
, *data
):
433 QtCore
.QEvent
.__init
__(self
, INFO_EVENT_TYPE
)
438 return INFO_EVENT_TYPE
441 class GitRepoItem(QtGui
.QStandardItem
):
443 Represents a cell in a treeview.
445 Many GitRepoItems map to a single repository path.
446 Each GitRepoItem manages a different cell in the tree view.
447 One is created for each column -- Name, Status, Age, etc.
450 def __init__(self
, column
, path
, parent
, runtask
):
451 QtGui
.QStandardItem
.__init
__(self
)
453 self
.runtask
= runtask
455 self
.setDragEnabled(False)
456 self
.setEditable(False)
457 entry
= GitRepoEntryStore
.entry(path
, parent
, runtask
)
458 if column
== Columns
.STATUS
:
459 entry
.status
.connect(self
.set_status
, type=Qt
.QueuedConnection
)
461 signal
= getattr(entry
, column
)
462 signal
.connect(self
.setText
, type=Qt
.QueuedConnection
)
464 def set_status(self
, data
):
467 self
.setIcon(QtGui
.QIcon(icon
))
469 self
.setIcon(QtGui
.QIcon())
473 class GitRepoNameItem(GitRepoItem
):
474 """Subclass GitRepoItem to provide a custom type()."""
475 TYPE
= QtGui
.QStandardItem
.ItemType(QtGui
.QStandardItem
.UserType
+ 1)
477 def __init__(self
, path
, parent
, runtask
):
478 GitRepoItem
.__init
__(self
, Columns
.NAME
, path
, parent
, runtask
)
479 self
.setDragEnabled(True)
483 Indicate that this item is of a special user-defined type.
485 'name' is the only column that registers a user-defined type.
486 This is done to allow filtering out other columns when determining
487 which paths are selected.
490 return GitRepoNameItem
.TYPE