1 """Save settings, bookmarks, etc."""
9 from . import resources
13 """Transform None and non-dicts into dicts"""
14 if isinstance(obj
, dict):
22 """Transform None and non-lists into lists"""
23 if isinstance(obj
, list):
25 elif isinstance(obj
, tuple):
34 with core
.open_read(path
) as f
:
35 return mkdict(json
.load(f
))
36 except (ValueError, TypeError, OSError): # bad path or json
40 def write_json(values
, path
):
41 """Write the specified values dict to a JSON file at the specified path"""
43 parent
= os
.path
.dirname(path
)
44 if not core
.isdir(parent
):
46 with core
.open_write(path
) as fp
:
47 json
.dump(values
, fp
, indent
=4)
48 except (ValueError, TypeError, OSError):
49 sys
.stderr
.write('git-cola: error writing "%s"\n' % path
)
54 def rename_path(old
, new
):
55 """Rename a filename. Catch exceptions and return False on error."""
59 sys
.stderr
.write(f
'git-cola: error renaming "{old}" to "{new}"\n')
64 def remove_path(path
):
65 """Remove a filename. Report errors to stderr."""
69 sys
.stderr
.write('git-cola: error removing "%s"\n' % path
)
73 config_path
= resources
.config_home('settings')
74 bookmarks
= property(lambda self
: mklist(self
.values
['bookmarks']))
75 gui_state
= property(lambda self
: mkdict(self
.values
['gui_state']))
76 recent
= property(lambda self
: mklist(self
.values
['recent']))
77 copy_formats
= property(lambda self
: mklist(self
.values
['copy_formats']))
79 def __init__(self
, verify
=git
.is_git_worktree
):
80 """Load existing settings if they exist"""
89 def remove_missing_bookmarks(self
):
90 """Remove "favorites" bookmarks that no longer exist"""
91 missing_bookmarks
= []
92 for bookmark
in self
.bookmarks
:
93 if not self
.verify(bookmark
['path']):
94 missing_bookmarks
.append(bookmark
)
96 for bookmark
in missing_bookmarks
:
98 self
.bookmarks
.remove(bookmark
)
102 def remove_missing_recent(self
):
103 """Remove "recent" repositories that no longer exist"""
105 for recent
in self
.recent
:
106 if not self
.verify(recent
['path']):
107 missing_recent
.append(recent
)
109 for recent
in missing_recent
:
111 self
.recent
.remove(recent
)
115 def add_bookmark(self
, path
, name
):
116 """Adds a bookmark to the saved settings"""
117 bookmark
= {'path': display
.normalize_path(path
), 'name': name
}
118 if bookmark
not in self
.bookmarks
:
119 self
.bookmarks
.append(bookmark
)
121 def remove_bookmark(self
, path
, name
):
122 """Remove a bookmark"""
123 bookmark
= {'path': display
.normalize_path(path
), 'name': name
}
125 self
.bookmarks
.remove(bookmark
)
129 def rename_bookmark(self
, path
, name
, new_name
):
130 return rename_entry(self
.bookmarks
, path
, name
, new_name
)
132 def add_recent(self
, path
, max_recent
):
133 normalize
= display
.normalize_path
134 path
= normalize(path
)
136 index
= [normalize(recent
['path']) for recent
in self
.recent
].index(path
)
137 entry
= self
.recent
.pop(index
)
138 except (IndexError, ValueError):
140 'name': os
.path
.basename(path
),
143 self
.recent
.insert(0, entry
)
144 if len(self
.recent
) > max_recent
:
147 def remove_recent(self
, path
):
148 """Removes an item from the recent items list"""
149 normalize
= display
.normalize_path
150 path
= normalize(path
)
152 index
= [normalize(recent
.get('path', '')) for recent
in self
.recent
].index(
158 self
.recent
.pop(index
)
162 def rename_recent(self
, path
, name
, new_name
):
163 return rename_entry(self
.recent
, path
, name
, new_name
)
166 return self
.config_path
169 """Write settings robustly to avoid losing data during a forced shutdown.
171 To save robustly we take these steps:
172 * Write the new settings to a .tmp file.
173 * Rename the current settings to a .bak file.
174 * Rename the new settings from .tmp to the settings file.
175 * Flush the data to disk
176 * Delete the .bak file.
178 Cf. https://github.com/git-cola/git-cola/issues/1241
181 path_tmp
= path
+ '.tmp'
182 path_bak
= path
+ '.bak'
183 # Write the new settings to the .tmp file.
184 if not write_json(self
.values
, path_tmp
):
186 # Rename the current settings to a .bak file.
187 if core
.exists(path
) and not rename_path(path
, path_bak
):
189 # Rename the new settings from .tmp to the settings file.
190 if not rename_path(path_tmp
, path
):
192 # Flush the data to disk.
194 # Delete the .bak file.
195 if core
.exists(path_bak
):
196 remove_path(path_bak
)
198 def load(self
, path
=None):
199 """Load settings robustly.
201 Attempt to load settings from the .bak file if it exists since it indicates
202 that the program terminated before the data was flushed to disk. This can
203 happen when a machine is force-shutdown, for example.
205 This follows the strategy outlined in issue #1241. If the .bak file exists
206 we use it, otherwise we fallback to the actual path or the .tmp path as a
207 final last-ditch attempt to recover settings.
211 path_bak
= path
+ '.bak'
212 path_tmp
= path
+ '.tmp'
214 if core
.exists(path_bak
):
215 self
.values
.update(self
.asdict(path
=path_bak
))
216 elif core
.exists(path
):
217 self
.values
.update(self
.asdict(path
=path
))
218 elif core
.exists(path_tmp
):
219 # This is potentially dangerous, but it's guarded by the fact that the
220 # file must be valid JSON in order otherwise the reader will return an
221 # empty string, thus making this a no-op.
222 self
.values
.update(self
.asdict(path
=path_tmp
))
224 # This is either a new installation or the settings were lost.
226 # We could try to remove the .bak and .tmp files, but it's better to set save()
227 # handle that the next time it succeeds.
228 self
.upgrade_settings()
232 def read(verify
=git
.is_git_worktree
):
233 """Load settings from disk"""
234 settings
= Settings(verify
=verify
)
238 def upgrade_settings(self
):
239 """Upgrade git-cola settings"""
240 # Upgrade bookmarks to the new dict-based bookmarks format.
241 normalize
= display
.normalize_path
242 if self
.bookmarks
and not isinstance(self
.bookmarks
[0], dict):
244 {'name': os
.path
.basename(path
), 'path': normalize(path
)}
245 for path
in self
.bookmarks
247 self
.values
['bookmarks'] = bookmarks
249 if self
.recent
and not isinstance(self
.recent
[0], dict):
251 {'name': os
.path
.basename(path
), 'path': normalize(path
)}
252 for path
in self
.recent
254 self
.values
['recent'] = recent
256 def asdict(self
, path
=None):
259 if core
.exists(path
):
260 return read_json(path
)
261 # We couldn't find ~/.config/git-cola, try ~/.cola
263 path
= os
.path
.join(core
.expanduser('~'), '.cola')
264 if core
.exists(path
):
265 json_values
= read_json(path
)
266 for key
in self
.values
:
268 values
[key
] = json_values
[key
]
271 # Ensure that all stored bookmarks use normalized paths ("/" only).
272 normalize
= display
.normalize_path
273 for entry
in values
.get('bookmarks', []):
274 entry
['path'] = normalize(entry
['path'])
275 for entry
in values
.get('recent', []):
276 entry
['path'] = normalize(entry
['path'])
279 def save_gui_state(self
, gui
):
280 """Saves settings for a cola view"""
282 self
.gui_state
[name
] = mkdict(gui
.export_state())
285 def get_gui_state(self
, gui
):
286 """Returns the saved state for a gui"""
287 return self
.get_gui_state_by_name(gui
.name())
289 def get_gui_state_by_name(self
, gui_name
):
290 """Returns the saved state for a gui by name"""
292 state
= mkdict(self
.gui_state
[gui_name
])
294 state
= self
.gui_state
[gui_name
] = {}
298 def rename_entry(entries
, path
, name
, new_name
):
299 normalize
= display
.normalize_path
300 path
= normalize(path
)
301 entry
= {'name': name
, 'path': path
}
303 index
= entries
.index(entry
)
307 if all(item
['name'] != new_name
for item
in entries
):
308 entries
[index
]['name'] = new_name
315 class Session(Settings
):
316 """Store per-session settings
318 XDG sessions are created by the QApplication::commitData() callback.
319 These sessions are stored once, and loaded once. They are deleted once
320 loaded. The behavior of path() is such that it forgets its session path()
321 and behaves like a return Settings object after the session has been
324 Once the session is loaded, it is removed and further calls to save()
325 will save to the usual $XDG_CONFIG_HOME/git-cola/settings location.
329 _sessions_dir
= resources
.config_home('sessions')
331 repo
= property(lambda self
: self
.values
['local'])
333 def __init__(self
, session_id
, repo
=None):
334 Settings
.__init
__(self
)
335 self
.session_id
= session_id
336 self
.values
.update({'local': repo
})
339 def session_path(self
):
340 """The session-specific session file"""
341 return os
.path
.join(self
._sessions
_dir
, self
.session_id
)
344 base_path
= super().path()
348 path
= self
.session_path()
349 if not os
.path
.exists(path
):
353 def load(self
, path
=None):
354 """Load the session and expire it for future loads
356 The session should be loaded only once. We remove the session file
357 when it's loaded, and set the session to be expired. This results in
358 future calls to load() and save() using the default Settings path
359 rather than the session-specific path.
361 The use case for sessions is when the user logs out with apps running.
362 We will restore their state, and if they then shutdown, it'll be just
363 like a normal shutdown and settings will be stored to
364 ~/.config/git-cola/settings instead of the session path.
366 This is accomplished by "expiring" the session after it has
367 been loaded initially.
370 result
= super().load(path
=path
)
371 # This is the initial load, so expire the session and remove the
372 # session state file. Future calls will be equivalent to
376 path
= self
.session_path()
377 if core
.exists(path
):
380 except (OSError, ValueError):
388 """Reload settings from the base settings path"""
389 # This method does not expire the session.
390 path
= super().path()
391 return super().load(path
=path
)