maint: prefer functions over methods
[git-cola.git] / cola / models / browse.py
blob3b8ece97f0a2c61ec99379b0d97cb92ca5c38fa9
1 from __future__ import division, absolute_import, unicode_literals
2 import time
4 from qtpy import QtCore
5 from qtpy import QtGui
6 from qtpy import QtWidgets
7 from qtpy.QtCore import Qt
8 from qtpy.QtCore import Signal
10 from .. import gitcmds
11 from .. import core
12 from .. import icons
13 from .. import utils
14 from .. import qtutils
15 from ..git import STDOUT
16 from ..i18n import N_
19 class Columns(object):
20 """Defines columns in the worktree browser"""
22 NAME = 0
23 STATUS = 1
24 MESSAGE = 2
25 AUTHOR = 3
26 AGE = 4
28 ALL = (NAME, STATUS, MESSAGE, AUTHOR, AGE)
29 ATTRS = ('name', 'status', 'message', 'author', 'age')
30 TEXT = []
32 @classmethod
33 def init(cls):
34 cls.TEXT.extend([
35 N_('Name'),
36 N_('Status'),
37 N_('Message'),
38 N_('Author'),
39 N_('Age'),
42 @classmethod
43 def text_values(cls):
44 if not cls.TEXT:
45 cls.init()
46 return cls.TEXT
48 @classmethod
49 def text(cls, column):
50 try:
51 value = cls.TEXT[column]
52 except IndexError:
53 # Defer translation until runtime
54 cls.init()
55 value = cls.TEXT[column]
56 return value
58 @classmethod
59 def attr(cls, column):
60 """Return the attribute for the column"""
61 return cls.ATTRS[column]
64 class GitRepoModel(QtGui.QStandardItemModel):
65 """Provides an interface into a git repository for browsing purposes."""
67 model_updated = Signal()
68 restore = Signal()
70 def __init__(self, context, parent):
71 QtGui.QStandardItemModel.__init__(self, parent)
72 self.setColumnCount(len(Columns.ALL))
74 self.context = context
75 self.model = model = context.model
76 self.entries = {}
77 cfg = context.cfg
78 self.turbo = cfg.get('cola.turbo', False)
79 self.default_author = cfg.get('user.name', N_('Author'))
80 self._parent = parent
81 self._interesting_paths = set()
82 self._interesting_files = set()
83 self._runtask = qtutils.RunTask(parent=parent)
85 self.model_updated.connect(self.refresh, type=Qt.QueuedConnection)
87 model = context.model
88 model.add_observer(model.message_updated, self._model_updated)
90 self.file_icon = icons.file_text()
91 self.dir_icon = icons.directory()
93 def mimeData(self, indexes):
94 context = self.context
95 paths = qtutils.paths_from_indexes(self, indexes,
96 item_type=GitRepoNameItem.TYPE)
97 return qtutils.mimedata_from_paths(context, paths)
99 def mimeTypes(self):
100 return qtutils.path_mimetypes()
102 def clear(self):
103 self.entries.clear()
104 super(GitRepoModel, self).clear()
106 def hasChildren(self, index):
107 if index.isValid():
108 item = self.itemFromIndex(index)
109 result = item.hasChildren()
110 else:
111 result = True
112 return result
114 def get(self, path, default=None):
115 if not path:
116 item = self.invisibleRootItem()
117 else:
118 item = self.entries.get(path, default)
119 return item
121 def create_row(self, path, create=True, is_dir=False):
122 try:
123 row = self.entries[path]
124 except KeyError:
125 if create:
126 column = create_column
127 row = self.entries[path] = [
128 column(c, path, is_dir) for c in Columns.ALL]
129 else:
130 row = None
131 return row
133 def populate(self, item):
134 self.populate_dir(item, item.path + '/')
136 def add_directory(self, parent, path):
137 """Add a directory entry to the model."""
138 # First, try returning an existing item
139 current_item = self.get(path)
140 if current_item is not None:
141 return current_item[0]
143 # Create model items
144 row_items = self.create_row(path, is_dir=True)
146 # Use a standard directory icon
147 name_item = row_items[0]
148 name_item.setIcon(self.dir_icon)
149 parent.appendRow(row_items)
151 return name_item
153 def add_file(self, parent, path):
154 """Add a file entry to the model."""
156 file_entry = self.get(path)
157 if file_entry is not None:
158 return file_entry
160 # Create model items
161 row_items = self.create_row(path)
162 name_item = row_items[0]
164 # Use a standard file icon for the name field
165 name_item.setIcon(self.file_icon)
167 # Add file paths at the end of the list
168 parent.appendRow(row_items)
170 return name_item
172 def populate_dir(self, parent, path):
173 """Populate a subtree"""
174 context = self.context
175 dirs, paths = gitcmds.listdir(context, path)
177 # Insert directories before file paths
178 for dirname in dirs:
179 dir_parent = parent
180 if '/' in dirname:
181 dir_parent = self.add_parent_directories(parent, dirname)
182 self.add_directory(dir_parent, dirname)
183 self.update_entry(dirname)
185 for filename in paths:
186 file_parent = parent
187 if '/' in filename:
188 file_parent = self.add_parent_directories(parent, filename)
189 self.add_file(file_parent, filename)
190 self.update_entry(filename)
192 def add_parent_directories(self, parent, dirname):
193 """Ensure that all parent directory entries exist"""
194 sub_parent = parent
195 parent_dir = utils.dirname(dirname)
196 for path in utils.pathset(parent_dir):
197 sub_parent = self.add_directory(sub_parent, path)
198 return sub_parent
200 def path_is_interesting(self, path):
201 """Return True if path has a status."""
202 return path in self._interesting_paths
204 def get_paths(self, files=None):
205 """Return paths of interest; e.g. paths with a status."""
206 if files is None:
207 files = self.get_files()
208 return utils.add_parents(files)
210 def get_files(self):
211 model = self.model
212 return set(model.staged + model.unstaged)
214 def _model_updated(self):
215 """Observes model changes and updates paths accordingly."""
216 self.model_updated.emit()
218 def refresh(self):
219 old_files = self._interesting_files
220 old_paths = self._interesting_paths
221 new_files = self.get_files()
222 new_paths = self.get_paths(files=new_files)
224 if new_files != old_files or not old_paths:
225 self.clear()
226 self._initialize()
227 self.restore.emit()
229 # Existing items
230 for path in sorted(new_paths.union(old_paths)):
231 self.update_entry(path)
233 self._interesting_files = new_files
234 self._interesting_paths = new_paths
236 def _initialize(self):
237 self.setHorizontalHeaderLabels(Columns.text_values())
238 self.entries = {}
239 self._interesting_files = files = self.get_files()
240 self._interesting_paths = self.get_paths(files=files)
242 root = self.invisibleRootItem()
243 self.populate_dir(root, './')
245 def update_entry(self, path):
246 if self.turbo or path not in self.entries:
247 return # entry doesn't currently exist
248 context = self.context
249 task = GitRepoInfoTask(
250 context, self._parent, path, self.default_author)
251 self._runtask.start(task)
254 def create_column(col, path, is_dir):
255 """Creates a StandardItem for use in a treeview cell."""
256 # GitRepoNameItem is the only one that returns a custom type()
257 # and is used to infer selections.
258 if col == Columns.NAME:
259 item = GitRepoNameItem(path, is_dir)
260 else:
261 item = GitRepoItem(path)
262 return item
265 class GitRepoInfoTask(qtutils.Task):
266 """Handles expensive git lookups for a path."""
268 def __init__(self, context, parent, path, default_author):
269 qtutils.Task.__init__(self, parent)
270 self.context = context
271 self.path = path
272 self._parent = parent
273 self._default_author = default_author
274 self._data = {}
276 def data(self, key):
277 """Return git data for a path
279 Supported keys are 'date', 'message', and 'author'
282 git = self.context.git
283 if not self._data:
284 log_line = git.log(
285 '-1', '--', self.path, no_color=True,
286 pretty=r'format:%ar%x01%s%x01%an', _readonly=True)[STDOUT]
287 if log_line:
288 log_line = log_line
289 date, message, author = log_line.split(chr(0x01), 2)
290 self._data['date'] = date
291 self._data['message'] = message
292 self._data['author'] = author
293 else:
294 self._data['date'] = self.date()
295 self._data['message'] = '-'
296 self._data['author'] = self._default_author
298 return self._data[key]
300 def date(self):
301 """Returns a relative date for a file path
303 This is typically used for new entries that do not have
304 'git log' information.
307 try:
308 st = core.stat(self.path)
309 except OSError:
310 return N_('%d minutes ago') % 0
311 elapsed = time.time() - st.st_mtime
312 minutes = int(elapsed / 60)
313 if minutes < 60:
314 return N_('%d minutes ago') % minutes
315 hours = int(elapsed / 60 / 60)
316 if hours < 24:
317 return N_('%d hours ago') % hours
318 return N_('%d days ago') % int(elapsed / 60 / 60 / 24)
320 def status(self):
321 """Return the status for the entry's path."""
322 model = self.context.model
323 unmerged = utils.add_parents(model.unmerged)
324 modified = utils.add_parents(model.modified)
325 staged = utils.add_parents(model.staged)
326 untracked = utils.add_parents(model.untracked)
327 upstream_changed = utils.add_parents(model.upstream_changed)
329 path = self.path
330 if path in unmerged:
331 status = (icons.modified_name(), N_('Unmerged'))
332 elif path in modified and self.path in staged:
333 status = (icons.partial_name(), N_('Partially Staged'))
334 elif path in modified:
335 status = (icons.modified_name(), N_('Modified'))
336 elif path in staged:
337 status = (icons.staged_name(), N_('Staged'))
338 elif path in upstream_changed:
339 status = (icons.upstream_name(), N_('Changed Upstream'))
340 elif path in untracked:
341 status = (None, '?')
342 else:
343 status = (None, '')
344 return status
346 def task(self):
347 """Perform expensive lookups and post corresponding events."""
348 data = (
349 self.path,
350 self.status(),
351 self.data('message'),
352 self.data('author'),
353 self.data('date'),
355 app = QtWidgets.QApplication.instance()
356 try:
357 app.postEvent(self._parent, GitRepoInfoEvent(data))
358 except RuntimeError:
359 pass # The app exited before this task finished
362 class GitRepoInfoEvent(QtCore.QEvent):
363 """Transport mechanism for communicating from a GitRepoInfoTask."""
364 # Custom event type
365 TYPE = QtCore.QEvent.Type(QtCore.QEvent.registerEventType())
367 def __init__(self, data):
368 QtCore.QEvent.__init__(self, self.TYPE)
369 self.data = data
371 def type(self):
372 return self.TYPE
375 class GitRepoItem(QtGui.QStandardItem):
376 """Represents a cell in a treeview.
378 Many GitRepoItems map to a single repository path.
379 Each GitRepoItem manages a different cell in the tree view.
380 One is created for each column -- Name, Status, Age, etc.
383 def __init__(self, path):
384 QtGui.QStandardItem.__init__(self)
385 self.path = path
386 self.cached = False
387 self.setDragEnabled(False)
388 self.setEditable(False)
390 def set_status(self, data):
391 icon, txt = data
392 if icon:
393 self.setIcon(QtGui.QIcon(icon))
394 else:
395 self.setIcon(QtGui.QIcon())
396 self.setText(txt)
399 class GitRepoNameItem(GitRepoItem):
400 """Subclass GitRepoItem to provide a custom type()."""
401 TYPE = QtGui.QStandardItem.ItemType(QtGui.QStandardItem.UserType + 1)
403 def __init__(self, path, is_dir):
404 GitRepoItem.__init__(self, path)
405 self.is_dir = is_dir
406 self.setDragEnabled(True)
407 self.setText(utils.basename(path))
409 def type(self):
411 Indicate that this item is of a special user-defined type.
413 'name' is the only column that registers a user-defined type.
414 This is done to allow filtering out other columns when determining
415 which paths are selected.
418 return self.TYPE
420 def hasChildren(self):
421 return self.is_dir