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
, sync
=True):
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)
49 core
.fsync(fp
.fileno())
50 except (ValueError, TypeError, OSError):
51 sys
.stderr
.write('git-cola: error writing "%s"\n' % path
)
56 def rename_path(old
, new
):
57 """Rename a filename. Catch exceptions and return False on error."""
61 sys
.stderr
.write(f
'git-cola: error renaming "{old}" to "{new}"\n')
66 def remove_path(path
):
67 """Remove a filename. Report errors to stderr."""
71 sys
.stderr
.write('git-cola: error removing "%s"\n' % path
)
75 config_path
= resources
.config_home('settings')
76 bookmarks
= property(lambda self
: mklist(self
.values
['bookmarks']))
77 gui_state
= property(lambda self
: mkdict(self
.values
['gui_state']))
78 recent
= property(lambda self
: mklist(self
.values
['recent']))
79 copy_formats
= property(lambda self
: mklist(self
.values
['copy_formats']))
81 def __init__(self
, verify
=git
.is_git_worktree
):
82 """Load existing settings if they exist"""
91 def remove_missing_bookmarks(self
):
92 """Remove "favorites" bookmarks that no longer exist"""
93 missing_bookmarks
= []
94 for bookmark
in self
.bookmarks
:
95 if not self
.verify(bookmark
['path']):
96 missing_bookmarks
.append(bookmark
)
98 for bookmark
in missing_bookmarks
:
100 self
.bookmarks
.remove(bookmark
)
104 def remove_missing_recent(self
):
105 """Remove "recent" repositories that no longer exist"""
107 for recent
in self
.recent
:
108 if not self
.verify(recent
['path']):
109 missing_recent
.append(recent
)
111 for recent
in missing_recent
:
113 self
.recent
.remove(recent
)
117 def add_bookmark(self
, path
, name
):
118 """Adds a bookmark to the saved settings"""
119 bookmark
= {'path': display
.normalize_path(path
), 'name': name
}
120 if bookmark
not in self
.bookmarks
:
121 self
.bookmarks
.append(bookmark
)
123 def remove_bookmark(self
, path
, name
):
124 """Remove a bookmark"""
125 bookmark
= {'path': display
.normalize_path(path
), 'name': name
}
127 self
.bookmarks
.remove(bookmark
)
131 def rename_bookmark(self
, path
, name
, new_name
):
132 return rename_entry(self
.bookmarks
, path
, name
, new_name
)
134 def add_recent(self
, path
, max_recent
):
135 normalize
= display
.normalize_path
136 path
= normalize(path
)
138 index
= [normalize(recent
['path']) for recent
in self
.recent
].index(path
)
139 entry
= self
.recent
.pop(index
)
140 except (IndexError, ValueError):
142 'name': os
.path
.basename(path
),
145 self
.recent
.insert(0, entry
)
146 if len(self
.recent
) > max_recent
:
149 def remove_recent(self
, path
):
150 """Removes an item from the recent items list"""
151 normalize
= display
.normalize_path
152 path
= normalize(path
)
154 index
= [normalize(recent
.get('path', '')) for recent
in self
.recent
].index(
160 self
.recent
.pop(index
)
164 def rename_recent(self
, path
, name
, new_name
):
165 return rename_entry(self
.recent
, path
, name
, new_name
)
168 return self
.config_path
170 def save(self
, sync
=True):
171 """Write settings robustly to avoid losing data during a forced shutdown.
173 To save robustly we take these steps:
174 * Write the new settings to a .tmp file.
175 * Rename the current settings to a .bak file.
176 * Rename the new settings from .tmp to the settings file.
177 * Flush the data to disk
178 * Delete the .bak file.
180 Cf. https://github.com/git-cola/git-cola/issues/1241
183 path_tmp
= path
+ '.tmp'
184 path_bak
= path
+ '.bak'
185 # Write the new settings to the .tmp file.
186 if not write_json(self
.values
, path_tmp
, sync
=sync
):
188 # Rename the current settings to a .bak file.
189 if core
.exists(path
) and not rename_path(path
, path_bak
):
191 # Rename the new settings from .tmp to the settings file.
192 if not rename_path(path_tmp
, path
):
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
, sync
=True):
280 """Saves settings for a widget"""
282 self
.gui_state
[name
] = mkdict(gui
.export_state())
285 def get_gui_state(self
, gui
):
286 """Returns the saved state for a tool"""
287 return self
.get(gui
.name())
289 def get(self
, gui_name
):
290 """Returns the saved state for a tool by name"""
292 state
= mkdict(self
.gui_state
[gui_name
])
294 state
= self
.gui_state
[gui_name
] = {}
297 def get_value(self
, name
, key
, default
=None):
298 """Return a specific setting value for the specified tool and setting key"""
299 return self
.get(name
).get(key
, default
)
301 def set_value(self
, name
, key
, value
, save
=True, sync
=True):
302 """Store a specific setting value for the specified tool and setting key value"""
303 values
= self
.get(name
)
309 def rename_entry(entries
, path
, name
, new_name
):
310 normalize
= display
.normalize_path
311 path
= normalize(path
)
312 entry
= {'name': name
, 'path': path
}
314 index
= entries
.index(entry
)
318 if all(item
['name'] != new_name
for item
in entries
):
319 entries
[index
]['name'] = new_name
326 class Session(Settings
):
327 """Store per-session settings
329 XDG sessions are created by the QApplication::commitData() callback.
330 These sessions are stored once, and loaded once. They are deleted once
331 loaded. The behavior of path() is such that it forgets its session path()
332 and behaves like a return Settings object after the session has been
335 Once the session is loaded, it is removed and further calls to save()
336 will save to the usual $XDG_CONFIG_HOME/git-cola/settings location.
340 _sessions_dir
= resources
.config_home('sessions')
342 repo
= property(lambda self
: self
.values
['local'])
344 def __init__(self
, session_id
, repo
=None):
345 Settings
.__init
__(self
)
346 self
.session_id
= session_id
347 self
.values
.update({'local': repo
})
350 def session_path(self
):
351 """The session-specific session file"""
352 return os
.path
.join(self
._sessions
_dir
, self
.session_id
)
355 base_path
= super().path()
359 path
= self
.session_path()
360 if not os
.path
.exists(path
):
364 def load(self
, path
=None):
365 """Load the session and expire it for future loads
367 The session should be loaded only once. We remove the session file
368 when it's loaded, and set the session to be expired. This results in
369 future calls to load() and save() using the default Settings path
370 rather than the session-specific path.
372 The use case for sessions is when the user logs out with apps running.
373 We will restore their state, and if they then shutdown, it'll be just
374 like a normal shutdown and settings will be stored to
375 ~/.config/git-cola/settings instead of the session path.
377 This is accomplished by "expiring" the session after it has
378 been loaded initially.
381 result
= super().load(path
=path
)
382 # This is the initial load, so expire the session and remove the
383 # session state file. Future calls will be equivalent to
387 path
= self
.session_path()
388 if core
.exists(path
):
391 except (OSError, ValueError):
399 """Reload settings from the base settings path"""
400 # This method does not expire the session.
401 path
= super().path()
402 return super().load(path
=path
)