├── .gitignore
├── Default.sublime-keymap
├── LICENSE
├── LocalHistory.py
├── README.md
├── commands
└── Default.sublime-commands
├── docs
├── context-menu.png
└── tools-menu.png
├── menus
├── Context.sublime-menu
└── Main.sublime-menu
└── settings
└── LocalHistory.sublime-settings
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | *.pyc
--------------------------------------------------------------------------------
/Default.sublime-keymap:
--------------------------------------------------------------------------------
1 | [
2 | { "keys": ["ctrl+alt+enter"], "command": "history_replace_diff", "context":[{"key": "replace_diff"}]},
3 | ]
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 lytup
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of
6 | this software and associated documentation files (the "Software"), to deal in
7 | the Software without restriction, including without limitation the rights to
8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9 | the Software, and to permit persons to whom the Software is furnished to do so,
10 | subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
--------------------------------------------------------------------------------
/LocalHistory.py:
--------------------------------------------------------------------------------
1 | import sys
2 | import os
3 | import re
4 | import time
5 | import platform
6 | import datetime
7 | import difflib
8 | import filecmp
9 | import shutil
10 | from threading import Thread
11 | import subprocess
12 | import sublime
13 | import sublime_plugin
14 |
15 | PY2 = sys.version_info < (3, 0)
16 |
17 | if PY2:
18 | from math import log
19 | else:
20 | from math import log2
21 |
22 | NO_SELECTION = -1
23 | settings = None
24 |
25 | def status_msg(msg):
26 | sublime.status_message('Local History: ' + msg)
27 |
28 | def readable_file_size(size):
29 | suffixes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'EB', 'ZB']
30 | if PY2:
31 | order = int(log(size, 2) / 10) if size else 0
32 | else:
33 | order = int(log2(size) / 10) if size else 0
34 | return '{:.4g} {}'.format(size / (1 << (order * 10)), suffixes[order])
35 |
36 | def get_history_root():
37 | path_default_not_portable = os.path.join(os.path.abspath(os.path.expanduser('~')), '.sublime', 'Local History')
38 | path_not_portable = settings.get('history_path', path_default_not_portable)
39 | return os.path.join(os.path.dirname(sublime.packages_path()), '.sublime', 'Local History') if settings.get('portable', True) else path_not_portable
40 |
41 | def get_history_subdir(file_path):
42 | history_root = get_history_root()
43 |
44 | file_dir = os.path.dirname(file_path)
45 | if platform.system() == 'Windows':
46 | if file_dir.find(os.sep) == 0:
47 | file_dir = file_dir[2:]
48 | if file_dir.find(':') == 1:
49 | file_dir = file_dir.replace(':', '', 1)
50 | else:
51 | file_dir = file_dir[1:]
52 |
53 | return os.path.join(history_root, file_dir)
54 |
55 | def get_history_files(file_name, history_dir):
56 | file_root, file_extension = os.path.splitext(file_name)
57 | history_files = [os.path.join(dirpath, f)
58 | for dirpath, dirnames, files in os.walk(history_dir)
59 | for f in files if f.startswith(file_root) and f.endswith(file_extension)]
60 | history_files.sort(key=lambda f: os.path.getmtime(os.path.join(history_dir, f)), reverse=True)
61 |
62 | return history_files
63 |
64 | def filtered_history_files(files):
65 | '''Only show file name in quick panel, not path'''
66 | if not settings.get('show_full_path', True):
67 | return [os.path.split(f)[1] for f in files]
68 | else:
69 | return files
70 |
71 | def check_sbs_compare():
72 | prefs = sublime.load_settings("Preferences.sublime-settings")
73 | pcsets = sublime.load_settings("Package Control.sublime-settings")
74 | installed = "Compare Side-By-Side" in pcsets.get('installed_packages')
75 | ignored = "Compare Side-By-Side" in prefs.get('ignored_packages')
76 | if installed and not ignored:
77 | return True
78 | else:
79 | return False
80 |
81 | def plugin_loaded():
82 | global settings
83 |
84 | settings = sublime.load_settings('LocalHistory.sublime-settings')
85 | settings.add_on_change('reload', sublime.load_settings('LocalHistory.sublime-settings'))
86 |
87 | status_msg('Target directory: "' + get_history_root() + '"')
88 | HistoryListener.listening = False
89 |
90 | if sublime.version().startswith('2'):
91 | plugin_loaded()
92 |
93 | def auto_diff_pane(view, index, history_dir, history_files):
94 | win = view.window()
95 | from_file = os.path.join(history_dir, history_files[index])
96 | from_file = from_file, os.path.basename(from_file)
97 | file_name = os.path.basename(view.file_name())
98 | to_file = view.file_name(), file_name
99 | group = win.get_view_index(view)[0]
100 | # view is not in first group
101 | if group:
102 | win.focus_group(0)
103 | # view is in first group
104 | elif win.num_groups() > 1:
105 | layout = win.get_layout()
106 | # only add to other group if pane is big enough
107 | if layout['cols'][2] - layout['cols'][1] > 0.35:
108 | win.focus_group(1)
109 | # create a pane in the middle
110 | else:
111 | middle_col = layout['cols'][1]
112 | layout['cols'].insert(1, middle_col)
113 | layout['cols'][1] = middle_col/2
114 | x1, y1, x2, y2 = layout['cells'][0]
115 | new_cell = [x1+1, y1, x2+1, y2]
116 | layout['cells'].insert(1, new_cell)
117 | new_cells = layout['cells'][:2]
118 | old_cells = [[x1+1, y1, x2+1, y2] for i, [x1, y1, x2, y2] in enumerate(layout['cells']) if i > 1]
119 | new_cells.extend(old_cells)
120 | layout['cells'] = new_cells
121 | win.run_command('set_layout', layout)
122 | for g, cell in enumerate(layout['cells']):
123 | if g > 0:
124 | for view in win.views_in_group(g):
125 | pos = win.get_view_index(view)[1]
126 | win.set_view_index(view, g+1, pos)
127 | win.focus_group(1)
128 | else:
129 | win.run_command(
130 | "set_layout",
131 | {
132 | "cols": [0.0, 0.5, 1.0],
133 | "rows": [0.0, 1.0],
134 | "cells": [[0, 0, 1, 1], [1, 0, 2, 1]]
135 | }
136 | )
137 | view.run_command('show_diff', {'from_file': from_file, 'to_file': to_file})
138 | # focus back to view
139 | win.focus_group(group)
140 |
141 | def rename_tab(view, lh_view, pre, ext, snap=False):
142 | def delay():
143 | lh_file = os.path.basename(lh_view.file_name())
144 | name = pre+"-" if not snap else pre
145 | name = lh_file.replace(name, "")
146 | name = name.replace(ext, "")
147 | lh_view.set_syntax_file(view.settings().get("syntax"))
148 | lh_view.set_name(name)
149 | sublime.set_timeout_async(delay)
150 |
151 | class HistorySave(sublime_plugin.EventListener):
152 |
153 | def on_load(self, view):
154 | if not PY2 or not settings.get('history_on_load', True):
155 | return
156 |
157 | t = Thread(target=self.process_history, args=(view.file_name(),))
158 | t.start()
159 |
160 | def on_load_async(self, view):
161 | if settings.get('history_on_load', True):
162 | t = Thread(target=self.process_history, args=(view.file_name(),))
163 | t.start()
164 |
165 | def on_close(self, view):
166 | if settings.get('history_on_close', True):
167 | t = Thread(target=self.process_history, args=(view.file_name(),))
168 | t.start()
169 |
170 | def on_post_save(self, view):
171 | if not PY2 or settings.get('history_on_close', True):
172 | return
173 |
174 | t = Thread(target=self.process_history, args=(view.file_name(),))
175 | t.start()
176 |
177 | def on_post_save_async(self, view):
178 | if not settings.get('history_on_close', True):
179 | t = Thread(target=self.process_history, args=(view.file_name(),))
180 | t.start()
181 |
182 | def on_deactivated(self, view):
183 | if (view.is_dirty() and settings.get('history_on_focus_lost', False)):
184 | t = Thread(target=self.process_history, args=(view.file_name(),))
185 | t.start()
186 |
187 | def process_history(self, file_path):
188 | if file_path == None:
189 | status_msg('File not saved, path does not exist.')
190 | return
191 |
192 | if not os.path.isfile(file_path):
193 | status_msg('File not saved, might be part of a package.')
194 | return
195 |
196 | size_limit = settings.get('file_size_limit', 4194304)
197 | history_retention = settings.get('history_retention', 0)
198 | skip_recently_saved = settings.get('skip_if_saved_within_minutes')
199 |
200 | if PY2:
201 | file_path = file_path.encode('utf-8')
202 | if os.path.getsize(file_path) > size_limit:
203 | status_msg('File not saved, exceeded %s limit.' % readable_file_size(size_limit))
204 | return
205 |
206 | file_name = os.path.basename(file_path)
207 | history_dir = get_history_subdir(file_path)
208 | if not os.path.exists(history_dir):
209 | os.makedirs(history_dir)
210 |
211 | history_files = get_history_files(file_name, history_dir)
212 |
213 | if history_files:
214 | if filecmp.cmp(file_path, os.path.join(history_dir, history_files[0])):
215 | status_msg('File not saved, no changes for "' + file_name + '".')
216 | return
217 | elif skip_recently_saved:
218 | current_time = time.time()
219 | last_modified = os.path.getmtime(history_files[0])
220 | if current_time - last_modified < skip_recently_saved*60:
221 | status_msg('File not saved, recent backup for "' + file_name + '" exists.')
222 | return
223 |
224 | file_root, file_extension = os.path.splitext(file_name)
225 | shutil.copyfile(file_path, os.path.join(history_dir, '{0}-{1}{2}'.format(file_root, datetime.datetime.now().strftime(settings.get('format_timestamp', '%Y%m%d%H%M%S')), file_extension)))
226 |
227 | status_msg('File saved, updated Local History for "' + file_name + '".')
228 |
229 | if history_retention == 0:
230 | return
231 |
232 | max_valid_archive_date = datetime.date.today() - datetime.timedelta(days=history_retention)
233 | for file in history_files:
234 | file = os.path.join(history_dir, file)
235 | if datetime.date.fromtimestamp(os.path.getmtime(file)) < max_valid_archive_date:
236 | os.remove(file)
237 |
238 | class HistorySaveNow(sublime_plugin.TextCommand):
239 |
240 | def run(self, edit):
241 | t = Thread(target=HistorySave().process_history, args=(self.view.file_name(),))
242 | t.start()
243 |
244 | class HistoryBrowse(sublime_plugin.TextCommand):
245 |
246 | def run(self, edit):
247 | target_dir = get_history_subdir(self.view.file_name())
248 | target_dir = target_dir.replace('\\', os.sep).replace('/', os.sep)
249 | system = platform.system()
250 |
251 | if system == 'Darwin':
252 | subprocess.call(['open', target_dir])
253 | elif system == 'Linux':
254 | subprocess.call('xdg-open %s' % target_dir, shell=True)
255 | elif system == 'Windows':
256 | subprocess.call('explorer %s' % target_dir, shell=True)
257 |
258 | class HistoryOpen(sublime_plugin.TextCommand):
259 |
260 | def run(self, edit, autodiff=False):
261 |
262 | if not self.view.file_name():
263 | status_msg("not a valid file.")
264 | return
265 |
266 | file_name = os.path.basename(self.view.file_name())
267 | history_dir = get_history_subdir(self.view.file_name())
268 | pre, ext = os.path.splitext(file_name)
269 |
270 | history_files = get_history_files(file_name, history_dir)
271 | if not history_files:
272 | status_msg('Local History not found for "' + file_name + '".')
273 | return
274 |
275 | filtered_files = filtered_history_files(history_files)
276 |
277 | def on_done(index):
278 | if index is NO_SELECTION:
279 | return
280 |
281 | lh_view = self.view.window().open_file(os.path.join(history_dir, history_files[index]))
282 | sublime.set_timeout_async(lambda: lh_view.set_scratch(True))
283 | if settings.get('rename_tab'):
284 | rename_tab(self.view, lh_view, pre, ext)
285 |
286 | if settings.get('auto_diff') or autodiff:
287 |
288 | auto_diff_pane(self.view, index, history_dir, history_files)
289 |
290 | self.view.window().show_quick_panel(filtered_files, on_done)
291 |
292 | class HistoryCompare(sublime_plugin.TextCommand):
293 |
294 | def run(self, edit, snapshots=False, sbs=False):
295 |
296 | if not self.view.file_name():
297 | status_msg("not a valid file.")
298 | return
299 |
300 | file_name = os.path.basename(self.view.file_name())
301 | history_dir = get_history_subdir(self.view.file_name())
302 |
303 | history_files = get_history_files(file_name, history_dir)
304 | history_files = history_files[1:]
305 |
306 | if history_files:
307 | filtered_files = filtered_history_files(history_files)
308 | else:
309 | status_msg('Local History not found for "' + file_name + '".')
310 | return
311 |
312 | def on_done(index):
313 | if index is NO_SELECTION:
314 | return
315 |
316 | if self.view.is_dirty() and settings.get('auto_save_before_diff', True):
317 | self.view.run_command('save')
318 |
319 | from_file = os.path.join(history_dir, history_files[index])
320 | from_file = from_file, os.path.basename(from_file)
321 | to_file = self.view.file_name(), file_name
322 | if sbs:
323 | HistorySbsCompare.vars = self.view, from_file[0], to_file[0]
324 | self.view.window().run_command("history_sbs_compare")
325 | else:
326 | self.view.run_command('show_diff', {'from_file': from_file, 'to_file': to_file})
327 |
328 | self.view.window().show_quick_panel(filtered_files, on_done)
329 |
330 | class HistoryReplace(sublime_plugin.TextCommand):
331 |
332 | def run(self, edit):
333 |
334 | if not self.view.file_name():
335 | status_msg("not a valid file.")
336 | return
337 |
338 | file_name = os.path.basename(self.view.file_name())
339 | history_dir = get_history_subdir(self.view.file_name())
340 |
341 | history_files = get_history_files(file_name, history_dir)
342 | history_files = history_files[1:]
343 |
344 | if history_files:
345 | filtered_files = filtered_history_files(history_files)
346 | else:
347 | status_msg('Local History not found for "' + file_name + '".')
348 | return
349 |
350 | def on_done(index):
351 | if index is NO_SELECTION:
352 | return
353 |
354 | # send vars to the listener for the diff/replace view
355 | from_file = os.path.join(history_dir, history_files[index])
356 | from_file = from_file, os.path.basename(from_file)
357 | to_file = self.view.file_name(), file_name
358 | HistoryReplaceDiff.from_file = from_file
359 | HistoryReplaceDiff.to_file = to_file
360 | HistoryListener.listening = True
361 | self.view.run_command('show_diff', {'from_file': from_file, 'to_file': to_file, 'replace': True})
362 |
363 | self.view.window().show_quick_panel(filtered_files, on_done)
364 |
365 | class HistoryIncrementalDiff(sublime_plugin.TextCommand):
366 |
367 | def run(self, edit):
368 | file_name = os.path.basename(self.view.file_name())
369 | history_dir = get_history_subdir(self.view.file_name())
370 |
371 | history_files = get_history_files(file_name, history_dir)
372 | if len(history_files) < 2:
373 | status_msg('Incremental diff not found for "' + file_name + '".')
374 | return
375 |
376 | filtered_files = filtered_history_files(history_files)
377 |
378 | def on_done(index):
379 | if index is NO_SELECTION:
380 | return
381 |
382 | if index == len(history_files) - 1:
383 | status_msg('Incremental diff not found for "' + file_name + '".')
384 | return
385 |
386 | from_file = os.path.join(history_dir, history_files[index + 1])
387 | to_file = os.path.join(history_dir, history_files[index])
388 | self.view.run_command('show_diff', {'from_file': from_file, 'to_file': to_file})
389 |
390 | self.view.window().show_quick_panel(filtered_files, on_done)
391 |
392 | class ShowDiff(sublime_plugin.TextCommand):
393 |
394 | header = "\n-\n- PRESS CTRL+ALT+ENTER TO ACCEPT AND REPLACE\n-\n\n"
395 |
396 | def run(self, edit, replace=False, **kwargs):
397 | from_file = kwargs['from_file'][0]
398 | to_file = kwargs['to_file'][0]
399 | if PY2:
400 | from_file = from_file.encode('utf-8')
401 | with open(from_file, 'r') as f:
402 | from_content = f.readlines()
403 | else:
404 | with open(from_file, 'r', encoding='utf-8') as f:
405 | from_content = f.readlines()
406 |
407 | if PY2:
408 | to_file = to_file.encode('utf-8')
409 | with open(to_file, 'r') as f:
410 | to_content = f.readlines()
411 | else:
412 | with open(to_file, 'r', encoding='utf-8') as f:
413 | to_content = f.readlines()
414 |
415 | diff = difflib.unified_diff(from_content, to_content, from_file, to_file)
416 | diff = ''.join(diff)
417 | if PY2:
418 | diff = diff.decode('utf-8')
419 | panel = sublime.active_window().new_file()
420 | panel.set_name("## LH: Diff ##")
421 | panel.set_scratch(True)
422 | panel.set_syntax_file('Packages/Diff/Diff.sublime-syntax')
423 | if replace and diff:
424 | HistoryListener.diff_view = panel
425 | panel.insert(edit, 0, self.header+diff)
426 | elif diff:
427 | panel.insert(edit, 0, diff)
428 | else:
429 | f1, f2 = os.path.split(from_file)[1], os.path.split(to_file)[1]
430 | panel.insert(edit, 0, "\n--- "+f1+"\n+++ "+f2+"\n\nNo differences\n\n\n")
431 | panel.set_read_only(True)
432 |
433 | class HistoryDeleteAll(sublime_plugin.TextCommand):
434 |
435 | def run(self, edit):
436 | if not sublime.ok_cancel_dialog('Are you sure you want to delete the Local History for all files?'):
437 | return
438 |
439 | shutil.rmtree(get_history_root())
440 | status_msg('The Local History has been deleted for all files.')
441 |
442 | class HistoryCreateSnapshot(sublime_plugin.TextCommand):
443 |
444 | def on_done(self, string):
445 | self.string = string
446 | self.view.window().run_command('history_create_snapshot', {"callback": True})
447 |
448 | def run(self, edit, callback=None):
449 |
450 | if not callback:
451 | v = self.view
452 |
453 | file_name = os.path.basename(v.file_name())
454 | self.pre, self.ext = os.path.splitext(file_name)
455 | c = "Enter a name for this snapshot: "
456 | s = ""
457 |
458 | v.window().show_input_panel(c, s, self.on_done, None, None)
459 |
460 | else:
461 | v = self.view
462 | file_name = self.pre + " # " + self.string + self.ext
463 | history_dir = get_history_subdir(v.file_name())
464 | shutil.copyfile(v.file_name(), os.path.join(history_dir, file_name))
465 | status_msg('File snapshot saved under "' + file_name + '".')
466 |
467 | class HistoryOpenSnapshot(sublime_plugin.TextCommand):
468 |
469 | def run(self, edit, open=True, compare=False, replace=False, sbs=False, delete=False, autodiff=False):
470 |
471 | # ---------------
472 |
473 | def Compare(index):
474 | if self.view.is_dirty():
475 | self.view.run_command('save')
476 |
477 | from_file = os.path.join(history_dir, history_files[index])
478 | from_file = from_file, os.path.basename(from_file)
479 | to_file = self.view.file_name(), os.path.basename(self.view.file_name())
480 |
481 | if sbs:
482 | HistorySbsCompare.vars = self.view, from_file[0], to_file[0]
483 | self.view.window().run_command("history_sbs_compare")
484 |
485 | elif replace:
486 | # send vars to the listener for the diff/replace view
487 | HistoryReplaceDiff.from_file = from_file
488 | HistoryReplaceDiff.to_file = to_file
489 | HistoryListener.listening = True
490 | self.view.run_command('show_diff', {'from_file': from_file, 'to_file': to_file, 'replace': True})
491 | else:
492 | self.view.run_command('show_diff', {'from_file': from_file, 'to_file': to_file})
493 |
494 | # ---------------
495 |
496 | if not self.view.file_name():
497 | status_msg("not a valid file.")
498 | return
499 |
500 | file_name = os.path.basename(self.view.file_name())
501 | history_dir = get_history_subdir(self.view.file_name())
502 | pre, ext = os.path.splitext(file_name)
503 |
504 | history_files = get_history_files(file_name, history_dir)
505 | fpat = pre+r" # .+"
506 | history_files = [re.search(fpat, file).group(0) for file in history_files
507 | if re.search(fpat, file)]
508 | if not history_files:
509 | status_msg('No snapshots found for "' + file_name + '".')
510 | return
511 |
512 | def rename(file):
513 | pre, ext = os.path.splitext(file_name)
514 | base, msg = file.split(" # ", 1)
515 | msg = msg.replace(ext, "")
516 | return [base+ext, msg]
517 |
518 | show_files = [rename(file) for file in history_files]
519 |
520 | def on_done(index):
521 | if index is NO_SELECTION:
522 | return
523 |
524 | if compare or sbs or replace:
525 | Compare(index)
526 | elif delete:
527 | os.remove(os.path.join(history_dir, history_files[index]))
528 | status_msg("The snapshot "+history_files[index]+" has been deleted.")
529 | else:
530 | lh_view = self.view.window().open_file(os.path.join(history_dir, history_files[index]))
531 | sublime.set_timeout_async(lambda: lh_view.set_scratch(True))
532 | if settings.get('rename_tab'):
533 | rename_tab(self.view, lh_view, pre, ext, snap=True)
534 | if settings.get('auto_diff') or autodiff:
535 | auto_diff_pane(self.view, index, history_dir, history_files)
536 |
537 | self.view.window().show_quick_panel(show_files, on_done)
538 |
539 |
540 | class HistoryDelete(sublime_plugin.TextCommand):
541 |
542 | def interval(self, edit, m, mode):
543 |
544 | choice = (
545 | ["Older than one year", mode],
546 | ["Older than six months", mode],
547 | ["Older than one month", mode],
548 | ["Older than one week", mode]
549 | )
550 |
551 | def on_done(index):
552 | if index is NO_SELECTION:
553 | return
554 | if index == 0:
555 | self.run(edit, ask=False, dir=m, before_last="year")
556 | elif index == 1:
557 | self.run(edit, ask=False, dir=m, before_last="months6")
558 | elif index == 2:
559 | self.run(edit, ask=False, dir=m, before_last="month")
560 | elif index == 3:
561 | self.run(edit, ask=False, dir=m, before_last="week")
562 |
563 | self.view.window().show_quick_panel(choice, on_done)
564 |
565 | def run(self, edit, ask=True, before_last=None, dir=False):
566 |
567 | if ask:
568 |
569 | i1 = "For all files, snapshots excluded"
570 | i2 = "Current folder only, snapshots excluded"
571 |
572 | choice = (
573 | ["Time interval", i1],
574 | ["Time interval", i2],
575 | ["All", "All files for all folders, no exceptions"]
576 | )
577 |
578 | def on_done(index):
579 | if index is NO_SELECTION:
580 | return
581 |
582 | if index == 0:
583 | self.interval(edit, False, i1)
584 | elif index == 1:
585 | self.interval(edit, True, i2)
586 | elif index == 2:
587 | self.view.window().run_command('history_delete_all')
588 |
589 | self.view.window().show_quick_panel(choice, on_done)
590 | return
591 |
592 | # ---------------
593 |
594 | # today
595 | current = time.time()
596 |
597 | folder = get_history_subdir(self.view.file_name()) if dir else get_history_root()
598 | base_name = os.path.splitext(os.path.split(self.view.file_name())[1])[0]
599 |
600 | for root, dirs, files in os.walk(folder):
601 | for f in files:
602 | file = os.path.join(root, f)
603 | if not os.path.isfile(file):
604 | continue
605 |
606 | # skip snapshots
607 | if re.match(base_name+" # ", f):
608 | continue
609 |
610 | # file last modified
611 | last_mod = os.path.getmtime(file)
612 |
613 | if before_last == "year":
614 | if current - last_mod > 31536000:
615 | os.remove(file)
616 |
617 | elif before_last == "months6":
618 | if current - last_mod > 15811200:
619 | os.remove(file)
620 |
621 | elif before_last == "month":
622 | if current - last_mod > 2635200:
623 | os.remove(file)
624 |
625 | elif before_last == "week":
626 | if current - last_mod > 604800:
627 | os.remove(file)
628 |
629 | if before_last == "year":
630 | status_msg('deleted files older than one year.')
631 |
632 | elif before_last == "months6":
633 | status_msg('deleted files older than six months.')
634 |
635 | elif before_last == "month":
636 | status_msg('deleted files older than one month.')
637 |
638 | elif before_last == "week":
639 | status_msg('deleted files older than one week.')
640 |
641 | class HistorySbsCompare(sublime_plugin.ApplicationCommand):
642 |
643 | def run(self, callback=False):
644 | global sbsW, sbsF, sbsVI
645 |
646 | if callback:
647 | view = sbsW.find_open_file(sbsF)
648 | sbsW.set_view_index(view, sbsVI[0], sbsVI[1])
649 |
650 | sbsV, sbsF1, sbsF2 = self.vars
651 | sbsW = sbsV.window()
652 | sbsVI = sbsW.get_view_index(sbsV)
653 | sbsW.run_command("sbs_compare_files", {"A": sbsF1, "B": sbsF2})
654 |
655 | # file has been closed, open it again
656 | sublime.set_timeout_async(lambda: sbsW.open_file(sbsF2), 1000)
657 | sublime.set_timeout_async(lambda: sbsW.run_command(
658 | "history_sbs_compare", {"callback": True}), 2000)
659 |
660 | def is_visible(self):
661 | return check_sbs_compare()
662 |
663 | class HistoryMenu(sublime_plugin.TextCommand):
664 |
665 | def compare(self):
666 |
667 | choice = [
668 | ["Diff with history"],
669 | ["Diff wih snapshot"],
670 | ["Diff & Replace with history"],
671 | ["Diff & Replace wih snapshot"],
672 | ["Compare Side-By-Side with history"],
673 | ["Compare Side-By-Side wih snapshot"],
674 | ]
675 |
676 | def on_done(index):
677 | if index is NO_SELECTION:
678 | return
679 | if index == 0:
680 | self.view.window().run_command('history_compare')
681 | elif index == 1:
682 | self.view.window().run_command('history_open_snapshot', {"compare": True})
683 | if index == 2:
684 | self.view.window().run_command('history_replace')
685 | elif index == 3:
686 | self.view.window().run_command('history_open_snapshot', {"replace": True})
687 | elif index == 4:
688 | self.view.window().run_command('history_compare', {"sbs": True})
689 | elif index == 5:
690 | self.view.window().run_command('history_open_snapshot', {"sbs": True})
691 | sbs = check_sbs_compare()
692 | choice = choice if sbs else choice[:4]
693 | self.view.window().show_quick_panel(choice, on_done)
694 |
695 | def snapshots(self):
696 |
697 | choice = [
698 | ["Open"],
699 | ["Create"],
700 | ["Delete"],
701 | ["Compare"],
702 | ["Compare & Replace"],
703 | ["Compare Side-By-Side wih snapshot"],
704 | ]
705 |
706 | def on_done(index):
707 | if index is NO_SELECTION:
708 | return
709 | elif index == 0:
710 | self.view.window().run_command('history_open_snapshot')
711 | elif index == 1:
712 | self.view.window().run_command('history_create_snapshot')
713 | elif index == 2:
714 | self.view.window().run_command('history_open_snapshot', {"delete": True})
715 | elif index == 3:
716 | self.view.window().run_command('history_open_snapshot', {"compare": True})
717 | elif index == 4:
718 | self.view.window().run_command('history_open_snapshot', {"replace": True})
719 | elif index == 5 and sbs:
720 | self.view.window().run_command('history_open_snapshot', {"sbs": True})
721 |
722 | sbs = check_sbs_compare()
723 | choice = choice if sbs else choice[:5]
724 | self.view.window().show_quick_panel(choice, on_done)
725 |
726 | def run(self, edit, compare=False, snapshots=False):
727 |
728 | choice = (
729 | ["Open history"],
730 | ["Compare & Replace"],
731 | ["Snapshots"],
732 | ["Browse in Explorer"],
733 | ["Delete history"]
734 | )
735 |
736 | if compare:
737 | self.compare()
738 | return
739 | elif snapshots:
740 | self.snapshots()
741 | return
742 |
743 | def on_done(index):
744 | if index is NO_SELECTION:
745 | return
746 | elif index == 0:
747 | self.view.window().run_command('history_open')
748 | elif index == 1:
749 | self.view.window().run_command('history_menu', {"compare": True})
750 | elif index == 2:
751 | self.view.window().run_command('history_menu', {"snapshots": True})
752 | elif index == 3:
753 | self.view.window().run_command('history_browse')
754 | elif index == 4:
755 | self.view.window().run_command('history_delete')
756 |
757 | self.view.window().show_quick_panel(choice, on_done)
758 |
759 | class HistoryReplaceDiff(sublime_plugin.TextCommand):
760 | from_file, to_file = None, None
761 |
762 | def run(self, edit):
763 | HistoryListener.listening = False
764 | from_file, to_file = HistoryReplaceDiff.from_file, HistoryReplaceDiff.to_file
765 | shutil.copyfile(from_file[0], to_file[0])
766 | status_msg('"'+to_file[1]+'"'+' replaced with "' + from_file[1] + '".')
767 | self.view.window().run_command('close_file')
768 |
769 |
770 | class HistoryListener(sublime_plugin.EventListener):
771 | listening = False
772 |
773 | def on_query_context(self, view, key, operator, operand, match_all):
774 |
775 | if HistoryListener.listening:
776 | if key == "replace_diff":
777 | if view == HistoryListener.diff_view:
778 | return True
779 | else:
780 | HistoryListener.listening = False
781 | return None
782 |
783 | def on_close(self, view):
784 | HistoryListener.listening = False
785 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Local History
2 |
3 | [](https://github.com/vishr/local-history/tree/master/LICENSE.md)
4 | [](https://packagecontrol.io/packages/Local%20History)
5 | [](https://packagecontrol.io/packages/Local%20History)
6 |
7 |
8 | A [Sublime Text](https://www.sublimetext.com) package for maintaining a local history of files.
9 |
10 | ## Benefits
11 |
12 | * Every time you modify a file, a copy of the old contents is kept in the local history when you:
13 | * open the file.
14 | * close the file.
15 | * and/or loose focus.
16 | * Available functions are:
17 | * file comparison of the open file and any of its older versions from the history.
18 | * incremental diff view.
19 | * Functions are available via:
20 | * the right-click context menu.
21 | * the `Local History: ...` commands from the command palette.
22 | * `Local History` helps you out when you change or delete a file by accident.
23 | * `Local History` can help you out when your workspace has a catastrophic problem or if you get disk errors that corrupt your workspace files.
24 | * File revisions are stored in separate files (with full path):
25 | * see the [Local History path](#local-history-path) section below
26 |
27 | ## Installation
28 |
29 | * Via [Package Control](https://www.packagecontrol.io):
30 | * [Install Package Control](https://www.packagecontrol.io/installation)
31 | * Open the command palette (CtrlShift ⇧P)
32 | * Choose `Package Control: Install Package`
33 | * Search for `Local History` and select to install.
34 | * Clone the repo: `git clone git://github.com/vishr/local-history.git "Local History"` into your [Sublime Text](https://www.sublimetext.com) Packages directory.
35 | * via HTTPS: `https://github.com/vishr/local-history.git`
36 | * via SSH: `git@github.com:vishr/local-history.git`
37 | * current snapshot of master
38 | * [current snapshot of master as *.zip](https://github.com/vishr/local-history/archive/master.zip)
39 | * Download the zip-file, unpack it and then re-zip the contents of the `Local History` subdirectory. Rename `Local History.zip` to `Local History.sublime-package` and move it to your `Installed Packages` subdirectory of your [Sublime Text](https://www.sublimetext.com) installation. On Linux this is `~/.config/sublime-text-2/` or `~/.config/sublime-text-3/`.
40 | * [current snapshot of master as *.tar.gz](https://github.com/vishr/local-history/archive/master.tar.gz)
41 |
42 | ### Settings
43 |
44 | #### Default settings
45 |
46 | ```js
47 | "history_retention": 0, // number of days to keep files, 0 to disable deletion
48 | "format_timestamp": "%Y%m%d%H%M%S", // file_name-XXXXXXXX.file_extension
49 | "history_on_close": true, // only save LocalHistory after closing a file, not when original was saved
50 | "history_on_focus_lost": false,
51 | "history_on_load": true,
52 | // "history_path": "",
53 | "portable": true,
54 | "file_size_limit": 4194304 // 4 MB
55 | ```
56 |
57 | #### Local History path
58 |
59 | [Local History](https://github.com/vishr/local-history)'s target directory for file revisions can be set as follows:
60 |
61 | * For `"portable": true`, [Local History](https://github.com/vishr/local-history) will save to `Sublime Text/Data/.sublime/Local History/...` wherever [Sublime Text](https://www.sublimetext.com) is installed.
62 | * Setting `"portable": false` will change the target folder to the `~/.sublime/Local History/...` subfolder of your user directory.
63 | * If `"portable": false` changing `"history_path": "..."` will give you the option to change the target directory to a custom path.
64 |
65 | ## Usage
66 |
67 |
68 |
69 | * Functions are available via:
70 | * the right-click context menu.
71 | * the `Local History: ...` commands from the command palette.
72 |
73 |
74 |
75 | * To permanently delete all history files, choose `Tools > Local History > Delete Local History > Permanently delete all`
76 |
--------------------------------------------------------------------------------
/commands/Default.sublime-commands:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "caption": "Local History: Browse in Explorer",
4 | "command": "history_browse"
5 | },
6 | {
7 | "caption": "Local History: Compare & Replace",
8 | "command": "history_menu",
9 | "args": {"compare": true}
10 | },
11 | {
12 | "caption": "Local History: Open",
13 | "command": "history_open"
14 | },
15 | {
16 | "caption": "Local History: Save Now",
17 | "command": "history_save_now"
18 | },
19 | {
20 | "caption": "Local History: Snapshots",
21 | "command": "history_menu",
22 | "args": {"snapshots": true}
23 | },
24 | {
25 | "caption": "Local History: Create Snapshot",
26 | "command": "history_create_snapshot"
27 | },
28 | {
29 | "caption": "Local History: Menu",
30 | "command": "history_menu"
31 | },
32 | {
33 | "caption": "Local History: Delete Options",
34 | "command": "history_delete"
35 | }
36 | ]
37 |
--------------------------------------------------------------------------------
/docs/context-menu.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vishr/local-history/d58b374915bfdb99545442644e354f38adb8fefb/docs/context-menu.png
--------------------------------------------------------------------------------
/docs/tools-menu.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vishr/local-history/d58b374915bfdb99545442644e354f38adb8fefb/docs/tools-menu.png
--------------------------------------------------------------------------------
/menus/Context.sublime-menu:
--------------------------------------------------------------------------------
1 | [
2 | // {
3 | // "caption": "-", "id":"Local History"
4 | // },
5 | {
6 | "caption": "Local History",
7 | "children":
8 | [
9 | {
10 | "caption": "Open",
11 | "command": "history_open"
12 | },
13 | {
14 | "caption": "Snapshots",
15 | "children":
16 | [
17 | {
18 | "caption": "Open",
19 | "command": "history_open_snapshot"
20 | },
21 | {
22 | "caption": "Create",
23 | "command": "history_create_snapshot"
24 | },
25 | {
26 | "caption": "Diff",
27 | "command": "history_open_snapshot",
28 | "args": {"compare": true}
29 | },
30 | {
31 | "caption": "Diff & Replace with snapshot",
32 | "command": "history_open_snapshot",
33 | "args": {"replace": true}
34 | },
35 | {
36 | "caption": "Compare side-by-side with snapshot",
37 | "command": "history_open_snapshot",
38 | "args": {"sbs": true}
39 | }
40 | ]
41 | },
42 | {
43 | "caption": "Compare with History",
44 | "children":
45 | [
46 | {
47 | "caption": "Diff",
48 | "command": "history_compare"
49 | },
50 | {
51 | "caption": "Diff & Replace with history",
52 | "command": "history_replace"
53 | },
54 | {
55 | "caption": "Compare side-by-side with history",
56 | "command": "history_compare",
57 | "args": {"sbs": true}
58 | },
59 | ]
60 | },
61 | {
62 | "caption": "Browse in Explorer",
63 | "command": "history_browse"
64 | }
65 | ]
66 | },
67 | {
68 | "caption": "-"
69 | }
70 | ]
71 |
--------------------------------------------------------------------------------
/menus/Main.sublime-menu:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "caption": "Preferences",
4 | "mnemonic": "n",
5 | "id": "preferences",
6 | "children":
7 | [
8 | {
9 | "caption": "Package Settings",
10 | "mnemonic": "P",
11 | "id": "package-settings",
12 | "children":
13 | [
14 | {
15 | "caption": "Local History",
16 | "children":
17 | [
18 | {
19 | "command": "open_file",
20 | "args": { "file": "${packages}/Local History/settings/LocalHistory.sublime-settings" },
21 | "caption": "Settings – Default"
22 | },
23 | {
24 | "command": "open_file",
25 | "args": { "file": "${packages}/User/LocalHistory.sublime-settings" },
26 | "caption": "Settings – User"
27 | },
28 | { "caption": "-" },
29 | {
30 | "command": "history_delete_all",
31 | "caption": "Permanently delete Local History"
32 | }
33 | ]
34 | }
35 | ]
36 | }
37 | ]
38 | }
39 | ]
40 |
--------------------------------------------------------------------------------
/settings/LocalHistory.sublime-settings:
--------------------------------------------------------------------------------
1 | {
2 | "history_retention": 0, // number of days to keep files, 0 to disable deletion
3 | "format_timestamp": "%Y%m%d%H%M%S",// file_name-XXXXXXXX.file_extension
4 | "history_on_close": true, // only save LocalHistory after closing a file, not when original was saved
5 | "history_on_focus_lost": false,
6 | "history_on_load": true,
7 | "portable": true, // save to 'Sublime Text/Data/.sublime/Local History/...' instead of '~/.sublime/Local History/...'
8 | // "history_path": "", // redirect '~/.sublime/Local History/...' to some other place if "portable": false
9 | "file_size_limit": 4194304, // 4 MB
10 |
11 | "skip_if_saved_within_minutes": 0, // only save if most recent save is older than this (in minutes), 0 to disable
12 | "show_full_path": false,
13 | "auto_diff": false, // automatically opens a diff view when opening a file from history
14 | "rename_tab": false, // rename the tab to only include the timestamp, or the message in case of snapshots
15 | "auto_save_before_diff": true
16 | }
17 |
--------------------------------------------------------------------------------