git-cola v4.3.1
[git-cola.git] / cola / settings.py
blob1f1ad49e36e82788313f5310f3966697337d91b8
1 """Save settings, bookmarks, etc."""
2 from __future__ import absolute_import, division, print_function, unicode_literals
3 import json
4 import os
5 import sys
7 from . import core
8 from . import display
9 from . import git
10 from . import resources
13 def mkdict(obj):
14 """Transform None and non-dicts into dicts"""
15 if isinstance(obj, dict):
16 value = obj
17 else:
18 value = {}
19 return value
22 def mklist(obj):
23 """Transform None and non-lists into lists"""
24 if isinstance(obj, list):
25 value = obj
26 elif isinstance(obj, tuple):
27 value = list(obj)
28 else:
29 value = []
30 return value
33 def read_json(path):
34 try:
35 with core.open_read(path) as f:
36 return mkdict(json.load(f))
37 except (ValueError, TypeError, OSError, IOError): # bad path or json
38 return {}
41 def write_json(values, path):
42 """Write the specified values dict to a JSON file at the specified path"""
43 try:
44 parent = os.path.dirname(path)
45 if not core.isdir(parent):
46 core.makedirs(parent)
47 with core.open_write(path) as fp:
48 json.dump(values, fp, indent=4)
49 except (ValueError, TypeError, OSError, IOError):
50 sys.stderr.write('git-cola: error writing "%s"\n' % path)
51 return False
52 return True
55 def rename_path(old, new):
56 """Rename a filename. Catch exceptions and return False on error."""
57 try:
58 core.rename(old, new)
59 except (IOError, OSError):
60 sys.stderr.write('git-cola: error renaming "%s" to "%s"\n' % (old, new))
61 return False
62 return True
65 def remove_path(path):
66 """Remove a filename. Report errors to stderr."""
67 try:
68 core.remove(path)
69 except (IOError, OSError):
70 sys.stderr.write('git-cola: error removing "%s"\n' % path)
73 class Settings(object):
74 config_path = resources.config_home('settings')
75 bookmarks = property(lambda self: mklist(self.values['bookmarks']))
76 gui_state = property(lambda self: mkdict(self.values['gui_state']))
77 recent = property(lambda self: mklist(self.values['recent']))
78 copy_formats = property(lambda self: mklist(self.values['copy_formats']))
80 def __init__(self, verify=git.is_git_worktree):
81 """Load existing settings if they exist"""
82 self.values = {
83 'bookmarks': [],
84 'gui_state': {},
85 'recent': [],
86 'copy_formats': [],
88 self.verify = verify
90 def remove_missing_bookmarks(self):
91 """Remove "favorites" bookmarks that no longer exist"""
92 missing_bookmarks = []
93 for bookmark in self.bookmarks:
94 if not self.verify(bookmark['path']):
95 missing_bookmarks.append(bookmark)
97 for bookmark in missing_bookmarks:
98 try:
99 self.bookmarks.remove(bookmark)
100 except ValueError:
101 pass
103 def remove_missing_recent(self):
104 """Remove "recent" repositories that no longer exist"""
105 missing_recent = []
106 for recent in self.recent:
107 if not self.verify(recent['path']):
108 missing_recent.append(recent)
110 for recent in missing_recent:
111 try:
112 self.recent.remove(recent)
113 except ValueError:
114 pass
116 def add_bookmark(self, path, name):
117 """Adds a bookmark to the saved settings"""
118 bookmark = {'path': display.normalize_path(path), 'name': name}
119 if bookmark not in self.bookmarks:
120 self.bookmarks.append(bookmark)
122 def remove_bookmark(self, path, name):
123 """Remove a bookmark"""
124 bookmark = {'path': display.normalize_path(path), 'name': name}
125 try:
126 self.bookmarks.remove(bookmark)
127 except ValueError:
128 pass
130 def rename_bookmark(self, path, name, new_name):
131 return rename_entry(self.bookmarks, path, name, new_name)
133 def add_recent(self, path, max_recent):
134 normalize = display.normalize_path
135 path = normalize(path)
136 try:
137 index = [normalize(recent['path']) for recent in self.recent].index(path)
138 entry = self.recent.pop(index)
139 except (IndexError, ValueError):
140 entry = {
141 'name': os.path.basename(path),
142 'path': path,
144 self.recent.insert(0, entry)
145 if len(self.recent) > max_recent:
146 self.recent.pop()
148 def remove_recent(self, path):
149 """Removes an item from the recent items list"""
150 normalize = display.normalize_path
151 path = normalize(path)
152 try:
153 index = [normalize(recent.get('path', '')) for recent in self.recent].index(
154 path
156 except ValueError:
157 return
158 try:
159 self.recent.pop(index)
160 except IndexError:
161 return
163 def rename_recent(self, path, name, new_name):
164 return rename_entry(self.recent, path, name, new_name)
166 def path(self):
167 return self.config_path
169 def save(self):
170 """Write settings robustly to avoid losing data during a forced shutdown.
172 To save robustly we take these steps:
173 * Write the new settings to a .tmp file.
174 * Rename the current settings to a .bak file.
175 * Rename the new settings from .tmp to the settings file.
176 * Flush the data to disk
177 * Delete the .bak file.
179 Cf. https://github.com/git-cola/git-cola/issues/1241
181 path = self.path()
182 path_tmp = path + '.tmp'
183 path_bak = path + '.bak'
184 # Write the new settings to the .tmp file.
185 if not write_json(self.values, path_tmp):
186 return
187 # Rename the current settings to a .bak file.
188 if core.exists(path) and not rename_path(path, path_bak):
189 return
190 # Rename the new settings from .tmp to the settings file.
191 if not rename_path(path_tmp, path):
192 return
193 # Flush the data to disk.
194 core.sync()
195 # Delete the .bak file.
196 if core.exists(path_bak):
197 remove_path(path_bak)
199 def load(self, path=None):
200 """Load settings robustly.
202 Attempt to load settings from the .bak file if it exists since it indicates
203 that the program terminated before the data was flushed to disk. This can
204 happen when a machine is force-shutdown, for example.
206 This follows the strategy outlined in issue #1241. If the .bak file exists
207 we use it, otherwise we fallback to the actual path or the .tmp path as a
208 final last-ditch attempt to recover settings.
211 path = self.path()
212 path_bak = path + '.bak'
213 path_tmp = path + '.tmp'
215 if core.exists(path_bak):
216 self.values.update(self.asdict(path=path_bak))
217 elif core.exists(path):
218 self.values.update(self.asdict(path=path))
219 elif core.exists(path_tmp):
220 # This is potentially dangerous, but it's guarded by the fact that the
221 # file must be valid JSON in order otherwise the reader will return an
222 # empty string, thus making this a no-op.
223 self.values.update(self.asdict(path=path_tmp))
224 else:
225 # This is either a new installation or the settings were lost.
226 pass
227 # We could try to remove the .bak and .tmp files, but it's better to set save()
228 # handle that the next time it succeeds.
229 self.upgrade_settings()
230 return True
232 @staticmethod
233 def read(verify=git.is_git_worktree):
234 """Load settings from disk"""
235 settings = Settings(verify=verify)
236 settings.load()
237 return settings
239 def upgrade_settings(self):
240 """Upgrade git-cola settings"""
241 # Upgrade bookmarks to the new dict-based bookmarks format.
242 normalize = display.normalize_path
243 if self.bookmarks and not isinstance(self.bookmarks[0], dict):
244 bookmarks = [
245 {'name': os.path.basename(path), 'path': normalize(path)}
246 for path in self.bookmarks
248 self.values['bookmarks'] = bookmarks
250 if self.recent and not isinstance(self.recent[0], dict):
251 recent = [
252 {'name': os.path.basename(path), 'path': normalize(path)}
253 for path in self.recent
255 self.values['recent'] = recent
257 def asdict(self, path=None):
258 if not path:
259 path = self.path()
260 if core.exists(path):
261 return read_json(path)
262 # We couldn't find ~/.config/git-cola, try ~/.cola
263 values = {}
264 path = os.path.join(core.expanduser('~'), '.cola')
265 if core.exists(path):
266 json_values = read_json(path)
267 for key in self.values:
268 try:
269 values[key] = json_values[key]
270 except KeyError:
271 pass
272 # Ensure that all stored bookmarks use normalized paths ("/" only).
273 normalize = display.normalize_path
274 for entry in values.get('bookmarks', []):
275 entry['path'] = normalize(entry['path'])
276 for entry in values.get('recent', []):
277 entry['path'] = normalize(entry['path'])
278 return values
280 def save_gui_state(self, gui):
281 """Saves settings for a cola view"""
282 name = gui.name()
283 self.gui_state[name] = mkdict(gui.export_state())
284 self.save()
286 def get_gui_state(self, gui):
287 """Returns the saved state for a gui"""
288 try:
289 state = mkdict(self.gui_state[gui.name()])
290 except KeyError:
291 state = self.gui_state[gui.name()] = {}
292 return state
295 def rename_entry(entries, path, name, new_name):
296 normalize = display.normalize_path
297 path = normalize(path)
298 entry = {'name': name, 'path': path}
299 try:
300 index = entries.index(entry)
301 except ValueError:
302 return False
304 if all(item['name'] != new_name for item in entries):
305 entries[index]['name'] = new_name
306 result = True
307 else:
308 result = False
309 return result
312 class Session(Settings):
313 """Store per-session settings
315 XDG sessions are created by the QApplication::commitData() callback.
316 These sessions are stored once, and loaded once. They are deleted once
317 loaded. The behavior of path() is such that it forgets its session path()
318 and behaves like a return Settings object after the session has been
319 loaded once.
321 Once the session is loaded, it is removed and further calls to save()
322 will save to the usual $XDG_CONFIG_HOME/git-cola/settings location.
326 _sessions_dir = resources.config_home('sessions')
328 repo = property(lambda self: self.values['local'])
330 def __init__(self, session_id, repo=None):
331 Settings.__init__(self)
332 self.session_id = session_id
333 self.values.update({'local': repo})
334 self.expired = False
336 def session_path(self):
337 """The session-specific session file"""
338 return os.path.join(self._sessions_dir, self.session_id)
340 def path(self):
341 base_path = super(Session, self).path()
342 if self.expired:
343 path = base_path
344 else:
345 path = self.session_path()
346 if not os.path.exists(path):
347 path = base_path
348 return path
350 def load(self, path=None):
351 """Load the session and expire it for future loads
353 The session should be loaded only once. We remove the session file
354 when it's loaded, and set the session to be expired. This results in
355 future calls to load() and save() using the default Settings path
356 rather than the session-specific path.
358 The use case for sessions is when the user logs out with apps running.
359 We will restore their state, and if they then shutdown, it'll be just
360 like a normal shutdown and settings will be stored to
361 ~/.config/git-cola/settings instead of the session path.
363 This is accomplished by "expiring" the session after it has
364 been loaded initially.
367 result = super(Session, self).load(path=path)
368 # This is the initial load, so expire the session and remove the
369 # session state file. Future calls will be equivalent to
370 # Settings.load().
371 if not self.expired:
372 self.expired = True
373 path = self.session_path()
374 if core.exists(path):
375 try:
376 os.unlink(path)
377 except (OSError, ValueError):
378 pass
379 return True
380 return False
382 return result
384 def update(self):
385 """Reload settings from the base settings path"""
386 # This method does not expire the session.
387 path = super(Session, self).path()
388 return super(Session, self).load(path=path)