fetch: add support for the traditional FETCH_HEAD behavior
[git-cola.git] / cola / models / browse.py
blobd0cc4dadeb374415b4776bc119238b635eca4236
1 import time
3 from qtpy import QtGui
4 from qtpy.QtCore import Qt
5 from qtpy.QtCore import Signal
7 from .. import gitcmds
8 from .. import core
9 from .. import icons
10 from .. import utils
11 from .. import qtutils
12 from ..git import STDOUT
13 from ..i18n import N_
16 class Columns:
17 """Defines columns in the worktree browser"""
19 NAME = 0
20 STATUS = 1
21 MESSAGE = 2
22 AUTHOR = 3
23 AGE = 4
25 ALL = (NAME, STATUS, MESSAGE, AUTHOR, AGE)
26 ATTRS = ('name', 'status', 'message', 'author', 'age')
27 TEXT = []
29 @classmethod
30 def init(cls):
31 cls.TEXT.extend(
32 [N_('Name'), N_('Status'), N_('Message'), N_('Author'), N_('Age')]
35 @classmethod
36 def text_values(cls):
37 if not cls.TEXT:
38 cls.init()
39 return cls.TEXT
41 @classmethod
42 def text(cls, column):
43 try:
44 value = cls.TEXT[column]
45 except IndexError:
46 # Defer translation until runtime
47 cls.init()
48 value = cls.TEXT[column]
49 return value
51 @classmethod
52 def attr(cls, column):
53 """Return the attribute for the column"""
54 return cls.ATTRS[column]
57 class GitRepoModel(QtGui.QStandardItemModel):
58 """Provides an interface into a git repository for browsing purposes."""
60 restore = Signal()
62 def __init__(self, context, parent):
63 QtGui.QStandardItemModel.__init__(self, parent)
64 self.setColumnCount(len(Columns.ALL))
66 self.context = context
67 self.model = context.model
68 self.entries = {}
69 cfg = context.cfg
70 self.turbo = cfg.get('cola.turbo', False)
71 self.default_author = cfg.get('user.name', N_('Author'))
72 self._interesting_paths = set()
73 self._interesting_files = set()
74 self._runtask = qtutils.RunTask(parent=parent)
76 self.model.updated.connect(self.refresh, type=Qt.QueuedConnection)
78 self.file_icon = icons.file_text()
79 self.dir_icon = icons.directory()
81 def mimeData(self, indexes):
82 paths = qtutils.paths_from_indexes(
83 self, indexes, item_type=GitRepoNameItem.TYPE
85 return qtutils.mimedata_from_paths(self.context, paths)
87 def mimeTypes(self):
88 return qtutils.path_mimetypes()
90 def clear(self):
91 self.entries.clear()
92 super().clear()
94 def hasChildren(self, index):
95 if index.isValid():
96 item = self.itemFromIndex(index)
97 result = item.hasChildren()
98 else:
99 result = True
100 return result
102 def get(self, path, default=None):
103 if not path:
104 item = self.invisibleRootItem()
105 else:
106 item = self.entries.get(path, default)
107 return item
109 def create_row(self, path, create=True, is_dir=False):
110 try:
111 row = self.entries[path]
112 except KeyError:
113 if create:
114 column = create_column
115 row = self.entries[path] = [
116 column(c, path, is_dir) for c in Columns.ALL
118 else:
119 row = None
120 return row
122 def populate(self, item):
123 self.populate_dir(item, item.path + '/')
125 def add_directory(self, parent, path):
126 """Add a directory entry to the model."""
127 # First, try returning an existing item
128 current_item = self.get(path)
129 if current_item is not None:
130 return current_item[0]
132 # Create model items
133 row_items = self.create_row(path, is_dir=True)
135 # Use a standard directory icon
136 name_item = row_items[0]
137 name_item.setIcon(self.dir_icon)
138 parent.appendRow(row_items)
140 return name_item
142 def add_file(self, parent, path):
143 """Add a file entry to the model."""
145 file_entry = self.get(path)
146 if file_entry is not None:
147 return file_entry
149 # Create model items
150 row_items = self.create_row(path)
151 name_item = row_items[0]
153 # Use a standard file icon for the name field
154 name_item.setIcon(self.file_icon)
156 # Add file paths at the end of the list
157 parent.appendRow(row_items)
159 return name_item
161 def populate_dir(self, parent, path):
162 """Populate a subtree"""
163 context = self.context
164 dirs, paths = gitcmds.listdir(context, path)
166 # Insert directories before file paths
167 for dirname in dirs:
168 dir_parent = parent
169 if '/' in dirname:
170 dir_parent = self.add_parent_directories(parent, dirname)
171 self.add_directory(dir_parent, dirname)
172 self.update_entry(dirname)
174 for filename in paths:
175 file_parent = parent
176 if '/' in filename:
177 file_parent = self.add_parent_directories(parent, filename)
178 self.add_file(file_parent, filename)
179 self.update_entry(filename)
181 def add_parent_directories(self, parent, dirname):
182 """Ensure that all parent directory entries exist"""
183 sub_parent = parent
184 parent_dir = utils.dirname(dirname)
185 for path in utils.pathset(parent_dir):
186 sub_parent = self.add_directory(sub_parent, path)
187 return sub_parent
189 def path_is_interesting(self, path):
190 """Return True if path has a status."""
191 return path in self._interesting_paths
193 def get_paths(self, files=None):
194 """Return paths of interest; e.g. paths with a status."""
195 if files is None:
196 files = self.get_files()
197 return utils.add_parents(files)
199 def get_files(self):
200 model = self.model
201 return set(model.staged + model.unstaged)
203 def refresh(self):
204 old_files = self._interesting_files
205 old_paths = self._interesting_paths
206 new_files = self.get_files()
207 new_paths = self.get_paths(files=new_files)
209 if new_files != old_files or not old_paths:
210 self.clear()
211 self._initialize()
212 self.restore.emit()
214 # Existing items
215 for path in sorted(new_paths.union(old_paths)):
216 self.update_entry(path)
218 self._interesting_files = new_files
219 self._interesting_paths = new_paths
221 def _initialize(self):
222 self.setHorizontalHeaderLabels(Columns.text_values())
223 self.entries = {}
224 self._interesting_files = files = self.get_files()
225 self._interesting_paths = self.get_paths(files=files)
227 root = self.invisibleRootItem()
228 self.populate_dir(root, './')
230 def update_entry(self, path):
231 if self.turbo or path not in self.entries:
232 return # entry doesn't currently exist
233 context = self.context
234 task = GitRepoInfoTask(context, path, self.default_author)
235 task.connect(self.apply_data)
236 self._runtask.start(task)
238 def apply_data(self, data):
239 entry = self.get(data[0])
240 if entry:
241 entry[1].set_status(data[1])
242 entry[2].setText(data[2])
243 entry[3].setText(data[3])
244 entry[4].setText(data[4])
247 def create_column(col, path, is_dir):
248 """Creates a StandardItem for use in a treeview cell."""
249 # GitRepoNameItem is the only one that returns a custom type()
250 # and is used to infer selections.
251 if col == Columns.NAME:
252 item = GitRepoNameItem(path, is_dir)
253 else:
254 item = GitRepoItem(path)
255 return item
258 class GitRepoInfoTask(qtutils.Task):
259 """Handles expensive git lookups for a path."""
261 def __init__(self, context, path, default_author):
262 qtutils.Task.__init__(self)
263 self.context = context
264 self.path = path
265 self._default_author = default_author
266 self._data = {}
268 def data(self, key):
269 """Return git data for a path
271 Supported keys are 'date', 'message', and 'author'
274 git = self.context.git
275 if not self._data:
276 log_line = git.log(
277 '-1',
278 '--',
279 self.path,
280 no_color=True,
281 pretty=r'format:%ar%x01%s%x01%an',
282 _readonly=True,
283 )[STDOUT]
284 if log_line:
285 date, message, author = log_line.split(chr(0x01), 2)
286 self._data['date'] = date
287 self._data['message'] = message
288 self._data['author'] = author
289 else:
290 self._data['date'] = self.date()
291 self._data['message'] = '-'
292 self._data['author'] = self._default_author
294 return self._data[key]
296 def date(self):
297 """Returns a relative date for a file path
299 This is typically used for new entries that do not have
300 'git log' information.
303 try:
304 st = core.stat(self.path)
305 except OSError:
306 return N_('%d minutes ago') % 0
307 elapsed = time.time() - st.st_mtime
308 minutes = int(elapsed / 60)
309 if minutes < 60:
310 return N_('%d minutes ago') % minutes
311 hours = int(elapsed / 60 / 60)
312 if hours < 24:
313 return N_('%d hours ago') % hours
314 return N_('%d days ago') % int(elapsed / 60 / 60 / 24)
316 def status(self):
317 """Return the status for the entry's path."""
318 model = self.context.model
319 unmerged = utils.add_parents(model.unmerged)
320 modified = utils.add_parents(model.modified)
321 staged = utils.add_parents(model.staged)
322 untracked = utils.add_parents(model.untracked)
323 upstream_changed = utils.add_parents(model.upstream_changed)
325 path = self.path
326 if path in unmerged:
327 status = (icons.modified_name(), N_('Unmerged'))
328 elif path in modified and self.path in staged:
329 status = (icons.partial_name(), N_('Partially Staged'))
330 elif path in modified:
331 status = (icons.modified_name(), N_('Modified'))
332 elif path in staged:
333 status = (icons.staged_name(), N_('Staged'))
334 elif path in upstream_changed:
335 status = (icons.upstream_name(), N_('Changed Upstream'))
336 elif path in untracked:
337 status = (None, '?')
338 else:
339 status = (None, '')
340 return status
342 def task(self):
343 """Perform expensive lookups and post corresponding events."""
344 data = (
345 self.path,
346 self.status(),
347 self.data('message'),
348 self.data('author'),
349 self.data('date'),
351 return data
354 class GitRepoItem(QtGui.QStandardItem):
355 """Represents a cell in a treeview.
357 Many GitRepoItems map to a single repository path.
358 Each GitRepoItem manages a different cell in the tree view.
359 One is created for each column -- Name, Status, Age, etc.
363 def __init__(self, path):
364 QtGui.QStandardItem.__init__(self)
365 self.path = path
366 self.cached = False
367 self.setDragEnabled(False)
368 self.setEditable(False)
370 def set_status(self, data):
371 icon, txt = data
372 if icon:
373 self.setIcon(QtGui.QIcon(icon))
374 else:
375 self.setIcon(QtGui.QIcon())
376 self.setText(txt)
379 class GitRepoNameItem(GitRepoItem):
380 """Subclass GitRepoItem to provide a custom type()."""
382 TYPE = qtutils.standard_item_type_value(1)
384 def __init__(self, path, is_dir):
385 GitRepoItem.__init__(self, path)
386 self.is_dir = is_dir
387 self.setDragEnabled(True)
388 self.setText(utils.basename(path))
390 def type(self):
392 Indicate that this item is of a special user-defined type.
394 'name' is the only column that registers a user-defined type.
395 This is done to allow filtering out other columns when determining
396 which paths are selected.
399 return self.TYPE
401 def hasChildren(self):
402 return self.is_dir