├── .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 | [![GitHub license](https://img.shields.io/github/license/vishr/local-history.svg?style=flat-square)](https://github.com/vishr/local-history/tree/master/LICENSE.md) 4 | [![Total downloads via Package Control](https://img.shields.io/packagecontrol/dt/Local%20History.svg?style=flat-square)](https://packagecontrol.io/packages/Local%20History) 5 | [![Monthly downloads via Package Control](https://img.shields.io/packagecontrol/dm/Local%20History.svg?style=flat-square)](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 | Context Menu 68 | 69 | * Functions are available via: 70 | * the right-click context menu. 71 | * the `Local History: ...` commands from the command palette. 72 | 73 | Tools Menu 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 | --------------------------------------------------------------------------------