1 """Save settings, bookmarks, etc."""
2 from __future__
import absolute_import
, division
, print_function
, unicode_literals
10 from . import resources
14 """Transform None and non-dicts into dicts"""
15 if isinstance(obj
, dict):
23 """Transform None and non-lists into lists"""
24 if isinstance(obj
, list):
26 elif isinstance(obj
, tuple):
35 with core
.open_read(path
) as f
:
36 return mkdict(json
.load(f
))
37 except (ValueError, TypeError, OSError, IOError): # bad path or json
41 def write_json(values
, path
):
42 """Write the specified values dict to a JSON file at the specified path"""
44 parent
= os
.path
.dirname(path
)
45 if not core
.isdir(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
)
55 def rename_path(old
, new
):
56 """Rename a filename. Catch exceptions and return False on error."""
59 except (IOError, OSError):
60 sys
.stderr
.write('git-cola: error renaming "%s" to "%s"\n' % (old
, new
))
65 def remove_path(path
):
66 """Remove a filename. Report errors to stderr."""
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"""
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
:
99 self
.bookmarks
.remove(bookmark
)
103 def remove_missing_recent(self
):
104 """Remove "recent" repositories that no longer exist"""
106 for recent
in self
.recent
:
107 if not self
.verify(recent
['path']):
108 missing_recent
.append(recent
)
110 for recent
in missing_recent
:
112 self
.recent
.remove(recent
)
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
}
126 self
.bookmarks
.remove(bookmark
)
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
)
137 index
= [normalize(recent
['path']) for recent
in self
.recent
].index(path
)
138 entry
= self
.recent
.pop(index
)
139 except (IndexError, ValueError):
141 'name': os
.path
.basename(path
),
144 self
.recent
.insert(0, entry
)
145 if len(self
.recent
) > max_recent
:
148 def remove_recent(self
, path
):
149 """Removes an item from the recent items list"""
150 normalize
= display
.normalize_path
151 path
= normalize(path
)
153 index
= [normalize(recent
.get('path', '')) for recent
in self
.recent
].index(
159 self
.recent
.pop(index
)
163 def rename_recent(self
, path
, name
, new_name
):
164 return rename_entry(self
.recent
, path
, name
, new_name
)
167 return self
.config_path
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
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
):
187 # Rename the current settings to a .bak file.
188 if core
.exists(path
) and not rename_path(path
, path_bak
):
190 # Rename the new settings from .tmp to the settings file.
191 if not rename_path(path_tmp
, path
):
193 # Flush the data to disk.
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.
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
))
225 # This is either a new installation or the settings were lost.
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()
233 def read(verify
=git
.is_git_worktree
):
234 """Load settings from disk"""
235 settings
= Settings(verify
=verify
)
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):
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):
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):
260 if core
.exists(path
):
261 return read_json(path
)
262 # We couldn't find ~/.config/git-cola, try ~/.cola
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
:
269 values
[key
] = json_values
[key
]
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'])
280 def save_gui_state(self
, gui
):
281 """Saves settings for a cola view"""
283 self
.gui_state
[name
] = mkdict(gui
.export_state())
286 def get_gui_state(self
, gui
):
287 """Returns the saved state for a gui"""
289 state
= mkdict(self
.gui_state
[gui
.name()])
291 state
= self
.gui_state
[gui
.name()] = {}
295 def rename_entry(entries
, path
, name
, new_name
):
296 normalize
= display
.normalize_path
297 path
= normalize(path
)
298 entry
= {'name': name
, 'path': path
}
300 index
= entries
.index(entry
)
304 if all(item
['name'] != new_name
for item
in entries
):
305 entries
[index
]['name'] = new_name
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
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
})
336 def session_path(self
):
337 """The session-specific session file"""
338 return os
.path
.join(self
._sessions
_dir
, self
.session_id
)
341 base_path
= super(Session
, self
).path()
345 path
= self
.session_path()
346 if not os
.path
.exists(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
373 path
= self
.session_path()
374 if core
.exists(path
):
377 except (OSError, ValueError):
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
)