├── .gitignore ├── Default (Linux).sublime-keymap ├── Default (OSX).sublime-keymap ├── Default (Windows).sublime-keymap ├── Main.sublime-menu ├── Modific.py ├── Modific.sublime-commands ├── Modific.sublime-settings ├── README.md └── icons ├── changed.png ├── deleted.png └── inserted.png /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | images 3 | !.gitignore 4 | -------------------------------------------------------------------------------- /Default (Linux).sublime-keymap: -------------------------------------------------------------------------------- 1 | [ 2 | { "keys": ["ctrl+alt+c"], "command": "show_original_part" }, 3 | { "keys": ["ctrl+alt+r"], "command": "replace_modified_part" }, 4 | { "keys": ["ctrl+alt+d"], "command": "show_diff" }, 5 | { "keys": ["ctrl+alt+u"], "command": "uncommitted_files" }, 6 | { "keys": ["ctrl+shift+h", "ctrl+shift+l"], "command": "toggle_highlight_changes" }, 7 | 8 | { "keys": ["ctrl+shift+pageup"], "command": "jump_between_changes", "args": {"direction": "prev"} }, 9 | { "keys": ["ctrl+shift+pagedown"], "command": "jump_between_changes", "args": {"direction": "next"} } 10 | ] 11 | -------------------------------------------------------------------------------- /Default (OSX).sublime-keymap: -------------------------------------------------------------------------------- 1 | [ 2 | { "keys": ["ctrl+super+c"], "command": "show_original_part" }, 3 | { "keys": ["ctrl+super+r"], "command": "replace_modified_part" }, 4 | { "keys": ["ctrl+alt+d"], "command": "show_diff" }, 5 | { "keys": ["ctrl+super+u"], "command": "uncommitted_files" }, 6 | { "keys": ["ctrl+shift+h", "ctrl+shift+l"], "command": "toggle_highlight_changes" }, 7 | 8 | { "keys": ["ctrl+shift+pageup"], "command": "jump_between_changes", "args": {"direction": "prev"} }, 9 | { "keys": ["ctrl+shift+pagedown"], "command": "jump_between_changes", "args": {"direction": "next"} } 10 | ] 11 | -------------------------------------------------------------------------------- /Default (Windows).sublime-keymap: -------------------------------------------------------------------------------- 1 | [ 2 | { "keys": ["ctrl+alt+c"], "command": "show_original_part" }, 3 | { "keys": ["ctrl+alt+r"], "command": "replace_modified_part" }, 4 | { "keys": ["ctrl+alt+d"], "command": "show_diff" }, 5 | { "keys": ["ctrl+alt+u"], "command": "uncommitted_files" }, 6 | { "keys": ["ctrl+shift+h", "ctrl+shift+l"], "command": "toggle_highlight_changes" }, 7 | 8 | { "keys": ["ctrl+shift+pageup"], "command": "jump_between_changes", "args": {"direction": "prev"} }, 9 | { "keys": ["ctrl+shift+pagedown"], "command": "jump_between_changes", "args": {"direction": "next"} } 10 | ] 11 | -------------------------------------------------------------------------------- /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": "Modific", 16 | "children": 17 | [ 18 | { 19 | "command": "open_file", 20 | "args": {"file": "${packages}/Modific/README.md"}, 21 | "caption": "README" 22 | }, 23 | { "caption": "-" }, 24 | { 25 | "command": "open_file", 26 | "args": {"file": "${packages}/Modific/Modific.sublime-settings"}, 27 | "caption": "Settings – Default" 28 | }, 29 | { 30 | "command": "open_file", 31 | "args": {"file": "${packages}/User/Modific.sublime-settings"}, 32 | "caption": "Settings – User" 33 | }, 34 | { "caption": "-" }, 35 | { 36 | "command": "open_file", 37 | "args": { 38 | "file": "${packages}/Modific/Default (OSX).sublime-keymap", 39 | "platform": "OSX" 40 | }, 41 | "caption": "Key Bindings – Default" 42 | }, 43 | { 44 | "command": "open_file", 45 | "args": { 46 | "file": "${packages}/Modific/Default (Linux).sublime-keymap", 47 | "platform": "Linux" 48 | }, 49 | "caption": "Key Bindings – Default" 50 | }, 51 | { 52 | "command": "open_file", 53 | "args": { 54 | "file": "${packages}/Modific/Default (Windows).sublime-keymap", 55 | "platform": "Windows" 56 | }, 57 | "caption": "Key Bindings – Default" 58 | }, 59 | { 60 | "command": "open_file", 61 | "args": { 62 | "file": "${packages}/User/Default (OSX).sublime-keymap", 63 | "platform": "OSX" 64 | }, 65 | "caption": "Key Bindings – User" 66 | }, 67 | { 68 | "command": "open_file", 69 | "args": { 70 | "file": "${packages}/User/Default (Linux).sublime-keymap", 71 | "platform": "Linux" 72 | }, 73 | "caption": "Key Bindings – User" 74 | }, 75 | { 76 | "command": "open_file", 77 | "args": { 78 | "file": "${packages}/User/Default (Windows).sublime-keymap", 79 | "platform": "Windows" 80 | }, 81 | "caption": "Key Bindings – User" 82 | }, 83 | { "caption": "-" } 84 | ] 85 | } 86 | ] 87 | } 88 | ] 89 | } 90 | ] 91 | -------------------------------------------------------------------------------- /Modific.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import print_function 4 | 5 | import sublime 6 | import sublime_plugin 7 | import os 8 | import threading 9 | import subprocess 10 | import functools 11 | import re 12 | from copy import copy 13 | 14 | IS_ST3 = sublime.version().startswith('3') or sublime.version().startswith('4') 15 | 16 | 17 | def get_settings(): 18 | return sublime.load_settings("Modific.sublime-settings") 19 | 20 | 21 | def get_vcs_settings(): 22 | """ 23 | Returns list of dictionaries 24 | each dict. represents settings for VCS 25 | """ 26 | 27 | default = [ 28 | {"name": "git", "dir": ".git", "cmd": "git"}, 29 | {"name": "svn", "dir": ".svn", "cmd": "svn"}, 30 | {"name": "bzr", "dir": ".bzr", "cmd": "bzr"}, 31 | {"name": "hg", "dir": ".hg", "cmd": "hg"}, 32 | {"name": "tf", "dir": "$tf", "cmd": "C:/Program Files (x86)/Microsoft Visual Studio 11.0/Common7/IDE/TF.exe"} 33 | ] 34 | settings = get_settings().get('vcs', default) 35 | 36 | # re-format settings array if user has old format of settings 37 | if type(settings[0]) == list: 38 | settings = [dict(name=name, cmd=cmd, dir='.'+name) for name, cmd in settings] 39 | 40 | return settings 41 | 42 | 43 | def get_user_command(vcs_name): 44 | """ 45 | Returns command that user specified for vcs_name 46 | """ 47 | 48 | try: 49 | return [vcs['cmd'] for vcs in get_vcs_settings() if vcs.get('name') == vcs_name][0] 50 | except IndexError: 51 | return None 52 | 53 | 54 | def tfs_root(directory): 55 | try: 56 | tf_cmd = get_user_command('tf') or 'tf' 57 | command = [tf_cmd, 'workfold', directory] 58 | p = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, 59 | shell=True, universal_newlines=False) 60 | out, err = p.communicate() 61 | m = re.search(r"^ \$\S+: (\S+)$", out, re.MULTILINE) 62 | if m: 63 | return {'root': m.group(1), 'name': 'tf', 'cmd': tf_cmd} 64 | except: 65 | return None 66 | 67 | 68 | def get_vcs(directory): 69 | """ 70 | Determines root directory for VCS and which of VCS systems should be used for a given directory 71 | 72 | Returns dictionary {name: .., root: .., cmd: .., dir: ..} 73 | """ 74 | 75 | vcs_check = [(lambda vcs: lambda dir: os.path.exists(os.path.join(dir, vcs.get('dir', False))) 76 | and vcs)(vcs) for vcs in get_vcs_settings()] 77 | 78 | start_directory = directory 79 | while directory: 80 | available = list(filter(bool, [check(directory) for check in vcs_check])) 81 | if available: 82 | available[0]['root'] = directory 83 | return available[0] 84 | 85 | parent = os.path.realpath(os.path.join(directory, os.path.pardir)) 86 | if parent == directory: # /.. == / 87 | # try TFS as a last resort 88 | # I'm not sure why we need to do this. Seems like it should find root for TFS in the main loop 89 | return tfs_root(start_directory) 90 | directory = parent 91 | 92 | return None 93 | 94 | 95 | def main_thread(callback, *args, **kwargs): 96 | # sublime.set_timeout gets used to send things onto the main thread 97 | # most sublime.[something] calls need to be on the main thread 98 | sublime.set_timeout(functools.partial(callback, *args, **kwargs), 0) 99 | 100 | 101 | def _make_text_safeish(text, fallback_encoding, method='decode'): 102 | # The unicode decode here is because sublime converts to unicode inside 103 | # insert in such a way that unknown characters will cause errors, which is 104 | # distinctly non-ideal... and there's no way to tell what's coming out of 105 | # git in output. So... 106 | try: 107 | unitext = getattr(text, method)('utf-8') 108 | except (UnicodeEncodeError, UnicodeDecodeError): 109 | unitext = getattr(text, method)(fallback_encoding) 110 | except AttributeError: 111 | # strongly implies we're already unicode, but just in case let's cast 112 | # to string 113 | unitext = str(text) 114 | return unitext 115 | 116 | 117 | def do_when(conditional, callback, *args, **kwargs): 118 | if conditional(): 119 | return callback(*args, **kwargs) 120 | sublime.set_timeout(functools.partial(do_when, conditional, callback, *args, **kwargs), 50) 121 | 122 | 123 | def log(*args, **kwargs): 124 | """ 125 | @param *args: string arguments that should be logged to console 126 | @param debug=True: debug log mode 127 | @param settings=None: instance of sublime.Settings 128 | """ 129 | debug = kwargs.get('debug', True) 130 | settings = kwargs.get('settings', None) 131 | 132 | if not settings: 133 | settings = get_settings() 134 | 135 | if debug and not settings.get('debug', False): 136 | return 137 | 138 | print('Modific:', *args) 139 | 140 | 141 | class CommandThread(threading.Thread): 142 | 143 | def __init__(self, command, on_done, working_dir="", fallback_encoding="", console_encoding="", **kwargs): 144 | threading.Thread.__init__(self) 145 | self.command = command 146 | self.on_done = on_done 147 | self.working_dir = working_dir 148 | if 'stdin' in kwargs: 149 | self.stdin = kwargs['stdin'].encode() 150 | else: 151 | self.stdin = None 152 | self.stdout = kwargs.get('stdout', subprocess.PIPE) 153 | self.console_encoding = console_encoding 154 | self.fallback_encoding = fallback_encoding 155 | self.kwargs = kwargs 156 | 157 | def run(self): 158 | try: 159 | # Per http://bugs.python.org/issue8557 shell=True is required to 160 | # get $PATH on Windows. Yay portable code. 161 | shell = os.name == 'nt' 162 | 163 | if self.console_encoding: 164 | self.command = [s.encode(self.console_encoding) for s in self.command] 165 | 166 | proc = subprocess.Popen(self.command, 167 | stdout=self.stdout, stderr=subprocess.STDOUT, 168 | stdin=subprocess.PIPE, 169 | cwd=self.working_dir if self.working_dir != '' else None, 170 | shell=shell, universal_newlines=False) 171 | output = proc.communicate(self.stdin)[0] 172 | if not output: 173 | output = '' 174 | # if sublime's python gets bumped to 2.7 we can just do: 175 | # output = subprocess.check_output(self.command) 176 | main_thread(self.on_done, 177 | _make_text_safeish(output, self.fallback_encoding), **self.kwargs) 178 | except subprocess.CalledProcessError as e: 179 | main_thread(self.on_done, e.returncode) 180 | except OSError as e: 181 | if e.errno == 2: 182 | main_thread(sublime.error_message, 183 | "'%s' binary could not be found in PATH\n\nConsider using `vcs` property to specify PATH\n\nPATH is: %s" % (self.command[0], os.environ['PATH'])) 184 | else: 185 | raise e 186 | 187 | 188 | class EditViewCommand(sublime_plugin.TextCommand): 189 | 190 | def run(self, edit, command=None, output='', begin=0, region=None): 191 | """ 192 | For some reason Sublime's view.run_command() doesn't allow to pass tuples, 193 | therefore region must be a list 194 | """ 195 | region = sublime.Region(int(region[0]), int(region[1])) if region else None 196 | if command == 'insert': 197 | self.view.insert(edit, int(begin), output) 198 | elif command == 'replace': 199 | self.view.replace(edit, region, output) 200 | elif command == 'erase': 201 | self.view.erase(edit, region) 202 | else: 203 | print('Invalid command: ', command) 204 | raise 205 | 206 | 207 | class VcsCommand(object): 208 | may_change_files = False 209 | 210 | def __init__(self, *args, **kwargs): 211 | self.settings = get_settings() 212 | super(VcsCommand, self).__init__(*args, **kwargs) 213 | 214 | def log(self, *args, **kwargs): 215 | return log(settings=self.settings, *args, **kwargs) 216 | 217 | def run_command(self, command, callback=None, show_status=False, 218 | filter_empty_args=True, **kwargs): 219 | if filter_empty_args: 220 | command = [arg for arg in command if arg] 221 | if 'working_dir' not in kwargs: 222 | kwargs['working_dir'] = self.get_working_dir() 223 | if 'fallback_encoding' not in kwargs and self.active_view() and self.active_view().settings().get('fallback_encoding'): 224 | kwargs['fallback_encoding'] = self.active_view().settings().get('fallback_encoding').rpartition('(')[2].rpartition(')')[0] 225 | kwargs['console_encoding'] = self.settings.get('console_encoding') 226 | 227 | autosave = self.settings.get('autosave', True) 228 | if self.active_view() and self.active_view().is_dirty() and autosave: 229 | self.active_view().run_command('save') 230 | if not callback: 231 | callback = self.generic_done 232 | 233 | log('run command:', ' '.join(command)) 234 | thread = CommandThread(command, callback, **kwargs) 235 | thread.start() 236 | 237 | if show_status: 238 | message = kwargs.get('status_message', False) or ' '.join(command) 239 | sublime.status_message(message + 'wef') 240 | 241 | def generic_done(self, result): 242 | self.log('generic_done', result) 243 | if self.may_change_files and self.active_view() and self.active_view().file_name(): 244 | if self.active_view().is_dirty(): 245 | result = "WARNING: Current view is dirty.\n\n" 246 | else: 247 | # just asking the current file to be re-opened doesn't do anything 248 | print("reverting") 249 | position = self.active_view().viewport_position() 250 | self.active_view().run_command('revert') 251 | do_when(lambda: not self.active_view().is_loading(), 252 | lambda: self.active_view().set_viewport_position(position, False)) 253 | 254 | if not result.strip(): 255 | return 256 | self.panel(result) 257 | 258 | def _output_to_view(self, output_file, output, clear=False, 259 | syntax="Packages/Diff/Diff.tmLanguage"): 260 | output_file.set_syntax_file(syntax) 261 | if clear: 262 | output_file.run_command('edit_view', dict(command='replace', region=[0, self.output_view.size()], output=output)) 263 | else: 264 | output_file.run_command('edit_view', dict(command='insert', output=output)) 265 | 266 | def scratch(self, output, title=False, position=None, **kwargs): 267 | scratch_file = self.get_window().new_file() 268 | if title: 269 | scratch_file.set_name(title) 270 | scratch_file.set_scratch(True) 271 | self._output_to_view(scratch_file, output, **kwargs) 272 | scratch_file.set_read_only(True) 273 | if position: 274 | sublime.set_timeout(lambda: scratch_file.set_viewport_position(position), 0) 275 | return scratch_file 276 | 277 | def panel(self, output, **kwargs): 278 | if not hasattr(self, 'output_view'): 279 | self.output_view = self.get_window().get_output_panel("vcs") 280 | self.output_view.set_read_only(False) 281 | self._output_to_view(self.output_view, output, clear=True, **kwargs) 282 | self.output_view.set_read_only(True) 283 | self.get_window().run_command("show_panel", {"panel": "output.vcs"}) 284 | 285 | def _active_file_name(self): 286 | view = self.active_view() 287 | if view and view.file_name() and len(view.file_name()) > 0: 288 | return view.file_name() 289 | 290 | def active_view(self): 291 | return self.view 292 | 293 | def get_window(self): 294 | if (hasattr(self, 'view') and hasattr(self.view, 'window')): 295 | return self.view.window() 296 | else: 297 | return sublime.active_window() 298 | 299 | def get_working_dir(self): 300 | return os.path.dirname(self._active_file_name()) 301 | 302 | def is_enabled(self): 303 | file_name = self._active_file_name() 304 | if file_name and os.path.exists(file_name): 305 | return bool(get_vcs(self.get_working_dir())) 306 | return False 307 | 308 | 309 | class DiffCommand(VcsCommand): 310 | """ Here you can define diff commands for your VCS 311 | method name pattern: %(vcs_name)s_diff_command 312 | """ 313 | 314 | def run(self, edit): 315 | vcs = get_vcs(self.get_working_dir()) 316 | filepath = self.view.file_name() 317 | filename = os.path.basename(filepath) 318 | max_file_size = self.settings.get('max_file_size', 1024) * 1024 319 | if not os.path.exists(filepath) or os.path.getsize(filepath) > max_file_size: 320 | # skip large files 321 | return 322 | get_command = getattr(self, '{0}_diff_command'.format(vcs['name']), None) 323 | if get_command: 324 | self.run_command(get_command(filename), self.diff_done) 325 | 326 | def diff_done(self, result): 327 | self.log('diff_done', result) 328 | 329 | def git_diff_command(self, file_name): 330 | vcs_options = self.settings.get('vcs_options', {}).get('git') or ['--no-color', '--no-ext-diff'] 331 | return [get_user_command('git') or 'git', 'diff'] + vcs_options + ['--', file_name] 332 | 333 | def svn_diff_command(self, file_name): 334 | params = [get_user_command('svn') or 'svn', 'diff'] 335 | params.extend(self.settings.get('vcs_options', {}).get('svn', [])) 336 | 337 | if '--internal-diff' not in params and self.settings.get('svn_use_internal_diff', True): 338 | params.append('--internal-diff') 339 | 340 | # if file starts with @, use `--revision HEAD` option 341 | # https://github.com/gornostal/Modific/issues/17 342 | if file_name.find('@') != -1: 343 | file_name += '@' 344 | params.extend(['--revision', 'HEAD']) 345 | 346 | params.append(file_name) 347 | return params 348 | 349 | def bzr_diff_command(self, file_name): 350 | vcs_options = self.settings.get('vcs_options', {}).get('bzr', []) 351 | return [get_user_command('bzr') or 'bzr', 'diff'] + vcs_options + [file_name] 352 | 353 | def hg_diff_command(self, file_name): 354 | vcs_options = self.settings.get('vcs_options', {}).get('hg', []) 355 | return [get_user_command('hg') or 'hg', 'diff'] + vcs_options + [file_name] 356 | 357 | def tf_diff_command(self, file_name): 358 | vcs_options = self.settings.get('vcs_options', {}).get('tf') or ['-format:unified'] 359 | return [get_user_command('tf') or 'tf', 'diff'] + vcs_options + [file_name] 360 | 361 | def get_line_ending(self): 362 | return '\n' 363 | 364 | def join_lines(self, lines): 365 | """ 366 | Join lines using os.linesep.join(), unless another method is specified in ST settings 367 | """ 368 | return self.get_line_ending().join(lines) 369 | 370 | 371 | class ShowDiffCommand(DiffCommand, sublime_plugin.TextCommand): 372 | def diff_done(self, result): 373 | self.log('on show_diff', result) 374 | 375 | if not result.strip(): 376 | return 377 | 378 | result = result.replace('\r\n', '\n') 379 | file_name = re.findall(r'([^\\\/]+)$', self.view.file_name()) 380 | scratch = self.scratch(result, title="Diff - " + file_name[0]) 381 | 382 | # Select the line in the diff output where the cursor is located. 383 | point = self.view.sel()[0].b 384 | region = self.view.line(point) 385 | line = self.view.substr(region) 386 | 387 | region = scratch.find(line, 0, sublime.LITERAL) 388 | scratch.show_at_center(region) 389 | scratch.sel().clear() 390 | # Place the cursor at the beginning of the line 391 | scratch.sel().add(scratch.line(region).a) 392 | 393 | 394 | class DiffParser(object): 395 | instance = None 396 | 397 | def __init__(self, diff): 398 | self.diff = diff 399 | self.chunks = None 400 | self.__class__.instance = self 401 | 402 | def _append_to_chunks(self, start, lines): 403 | self.chunks.append({ 404 | "start": start, 405 | "end": start + len(lines), 406 | "lines": lines 407 | }) 408 | 409 | def get_chunks(self): 410 | if self.chunks is None: 411 | self.chunks = [] 412 | diff = self.diff.strip() 413 | if diff: 414 | re_header = re.compile(r'^@@[0-9\-, ]+\+(\d+)', re.S) 415 | current = None 416 | lines = [] 417 | for line in diff.splitlines(): 418 | # ignore lines with '\' at the beginning 419 | if line.startswith('\\'): 420 | continue 421 | 422 | matches = re.findall(re_header, line) 423 | if matches: 424 | if current is not None: 425 | self._append_to_chunks(current, lines) 426 | current = int(matches[0]) 427 | lines = [] 428 | elif current: 429 | lines.append(line) 430 | if current is not None and lines: 431 | self._append_to_chunks(current, lines) 432 | 433 | return self.chunks 434 | 435 | def get_lines_to_hl(self): 436 | inserted = [] 437 | changed = [] 438 | deleted = [] 439 | 440 | for chunk in self.get_chunks(): 441 | current = chunk['start'] 442 | deleted_line = None 443 | for line in chunk['lines']: 444 | if line.startswith('-'): 445 | if (not deleted_line or deleted_line not in deleted): 446 | deleted.append(current) 447 | deleted_line = current 448 | elif line.startswith('+'): 449 | if deleted_line: 450 | deleted.pop() 451 | deleted_line = None 452 | changed.append(current) 453 | elif current - 1 in changed: 454 | changed.append(current) 455 | else: 456 | inserted.append(current) 457 | current += 1 458 | else: 459 | deleted_line = None 460 | current += 1 461 | 462 | return inserted, changed, deleted 463 | 464 | def get_original_part(self, line_num): 465 | """ returns a chunk of code that relates to the given line 466 | and was there before modifications 467 | 468 | return (lines list, start_line int, replace_lines int) 469 | """ 470 | 471 | # for each chunk from diff: 472 | for chunk in self.get_chunks(): 473 | # if line_num is within that chunk 474 | if chunk['start'] <= line_num <= chunk['end']: 475 | ret_lines = [] 476 | current = chunk['start'] # line number that corresponds to current version of file 477 | first = None # number of the first line to change 478 | replace_lines = 0 # number of lines to change 479 | return_this_lines = False # flag shows whether we can return accumulated lines 480 | for line in chunk['lines']: 481 | if line.startswith('-') or line.startswith('+'): 482 | first = first or current 483 | if current == line_num: 484 | return_this_lines = True 485 | if line.startswith('-'): 486 | # if line starts with '-' we have previous version 487 | ret_lines.append(line[1:]) 488 | else: 489 | # if line starts with '+' we only increment numbers 490 | replace_lines += 1 491 | current += 1 492 | elif return_this_lines: 493 | break 494 | else: 495 | # gap between modifications 496 | # reset our variables 497 | current += 1 498 | first = current 499 | replace_lines = 0 500 | ret_lines = [] 501 | if return_this_lines: 502 | return ret_lines, first, replace_lines 503 | 504 | return None, None, None 505 | 506 | 507 | class HlChangesCommand(DiffCommand, sublime_plugin.TextCommand): 508 | def hl_lines(self, lines, hl_key): 509 | if (not len(lines) or not self.settings.get('highlight_changes')): 510 | self.view.erase_regions(hl_key) 511 | return 512 | 513 | icon = self.settings.get('region_icon') or 'modific' 514 | if icon == 'none': 515 | return 516 | 517 | if icon == 'modific': 518 | if IS_ST3: 519 | icon = 'Packages/Modific/icons/' + hl_key + '.png' 520 | else: 521 | icon = '../Modific/icons/' + hl_key 522 | points = [self.view.text_point(l - 1, 0) for l in lines] 523 | regions = [sublime.Region(p, p) for p in points] 524 | self.view.add_regions(hl_key, regions, "markup.%s.diff" % hl_key, icon, sublime.HIDDEN | sublime.DRAW_EMPTY) 525 | 526 | def diff_done(self, diff): 527 | self.log('on hl_changes:', diff) 528 | 529 | if diff and '@@' not in diff: 530 | # probably this is an error message 531 | # if print raise UnicodeEncodeError, try to encode string to utf-8 (issue #35) 532 | try: 533 | print(diff) 534 | except UnicodeEncodeError: 535 | print(diff.encode('utf-8')) 536 | 537 | diff_parser = DiffParser(diff) 538 | (inserted, changed, deleted) = diff_parser.get_lines_to_hl() 539 | 540 | self.log('new lines:', inserted) 541 | self.log('modified lines:', changed) 542 | self.log('deleted lines:', deleted) 543 | 544 | self.hl_lines(inserted, 'inserted') 545 | self.hl_lines(deleted, 'deleted') 546 | self.hl_lines(changed, 'changed') 547 | 548 | 549 | class ShowOriginalPartCommand(DiffCommand, sublime_plugin.TextCommand): 550 | def run(self, edit): 551 | diff_parser = DiffParser.instance 552 | if not diff_parser: 553 | return 554 | 555 | (row, col) = self.view.rowcol(self.view.sel()[0].begin()) 556 | (lines, start, replace_lines) = diff_parser.get_original_part(row + 1) 557 | if lines is not None: 558 | self.panel(self.join_lines(lines)) 559 | 560 | 561 | class ReplaceModifiedPartCommand(DiffCommand, sublime_plugin.TextCommand): 562 | def run(self, edit): 563 | self.view.run_command('save') 564 | 565 | diff_parser = DiffParser.instance 566 | if not diff_parser: 567 | return 568 | 569 | (row, col) = self.view.rowcol(self.view.sel()[0].begin()) 570 | (lines, current, replace_lines) = diff_parser.get_original_part(row + 1) 571 | if self.settings.get('debug'): 572 | print('replace', (lines, current, replace_lines)) 573 | if lines is not None: 574 | begin = self.view.text_point(current - 1, 0) 575 | content = self.join_lines(lines) 576 | if replace_lines: 577 | end = self.view.line(self.view.text_point(replace_lines + current - 2, 0)).end() 578 | region = sublime.Region(begin, end) 579 | if lines: 580 | self.view.run_command('edit_view', dict(command='replace', region=[region.begin(), region.end()], output=content)) 581 | else: 582 | region = self.view.full_line(region) 583 | self.view.run_command('edit_view', dict(command='erase', region=[region.begin(), region.end()])) 584 | else: 585 | self.view.run_command('edit_view', dict(command='insert', begin=begin, 586 | output=content + self.get_line_ending())) 587 | self.view.run_command('save') 588 | 589 | 590 | class HlChangesBackground(sublime_plugin.EventListener): 591 | def on_load(self, view): 592 | if not IS_ST3: 593 | view.run_command('hl_changes') 594 | 595 | def on_load_async(self, view): 596 | view.run_command('hl_changes') 597 | 598 | def on_activated(self, view): 599 | if not IS_ST3: 600 | view.run_command('hl_changes') 601 | 602 | def on_activated_async(self, view): 603 | view.run_command('hl_changes') 604 | 605 | def on_post_save(self, view): 606 | if not IS_ST3: 607 | view.run_command('hl_changes') 608 | 609 | def on_post_save_async(self, view): 610 | view.run_command('hl_changes') 611 | 612 | 613 | class JumpBetweenChangesCommand(DiffCommand, sublime_plugin.TextCommand): 614 | def run(self, edit, direction='next'): 615 | lines = self._get_lines() 616 | if not lines: 617 | return 618 | 619 | if direction == 'prev': 620 | lines.reverse() 621 | 622 | (current_line, col) = self.view.rowcol(self.view.sel()[0].begin()) 623 | current_line += 1 624 | jump_to = None 625 | for line in lines: 626 | if direction == 'next' and current_line < line: 627 | jump_to = line 628 | break 629 | if direction == 'prev' and current_line > line: 630 | jump_to = line 631 | break 632 | 633 | if not jump_to and self.settings.get('jump_between_changes_wraps_around', True): 634 | jump_to = lines[0] 635 | 636 | if jump_to is not None: 637 | self.goto_line(edit, jump_to) 638 | 639 | def goto_line(self, edit, line): 640 | # Convert from 1 based to a 0 based line number 641 | line = int(line) - 1 642 | 643 | # Negative line numbers count from the end of the buffer 644 | if line < 0: 645 | lines, _ = self.view.rowcol(self.view.size()) 646 | line = lines + line + 1 647 | 648 | pt = self.view.text_point(line, 0) 649 | 650 | self.view.sel().clear() 651 | self.view.sel().add(sublime.Region(pt)) 652 | 653 | self.view.show(pt) 654 | 655 | def _get_lines(self): 656 | diff_parser = DiffParser.instance 657 | if not diff_parser: 658 | return 659 | 660 | (inserted, changed, deleted) = diff_parser.get_lines_to_hl() 661 | lines = list(set(inserted + changed + deleted)) 662 | lines.sort() 663 | 664 | prev = None 665 | ret_lines = [] 666 | for line in lines: 667 | if prev != line - 1: 668 | ret_lines.append(line) 669 | prev = line 670 | 671 | return ret_lines 672 | 673 | 674 | class UncommittedFilesCommand(VcsCommand, sublime_plugin.WindowCommand): 675 | def active_view(self): 676 | return self.window.active_view() 677 | 678 | def is_enabled(self): 679 | return bool(self.get_working_dir()) 680 | 681 | def get_working_dir(self): 682 | if self._active_file_name(): 683 | working_dir = super(UncommittedFilesCommand, self).get_working_dir() 684 | if working_dir and get_vcs(working_dir): 685 | return working_dir 686 | 687 | # If the user has opened a vcs folder, use it. 688 | folders = self.window.folders() 689 | for folder in folders: 690 | if folder and os.path.exists(folder) and get_vcs(folder): 691 | return folder 692 | 693 | def run(self): 694 | self.vcs = get_vcs(self.get_working_dir()) 695 | status_command = getattr(self, '{0}_status_command'.format(self.vcs['name']), None) 696 | if status_command: 697 | self.run_command(status_command(), self.status_done, working_dir=self.vcs['root']) 698 | 699 | def git_status_command(self): 700 | return [get_user_command('git') or 'git', 'status', '--porcelain'] 701 | 702 | def svn_status_command(self): 703 | return [get_user_command('svn') or 'svn', 'status', '--quiet'] 704 | 705 | def bzr_status_command(self): 706 | return [get_user_command('bzr') or 'bzr', 'status', '-S', '--no-pending', '-V'] 707 | 708 | def hg_status_command(self): 709 | return [get_user_command('hg') or 'hg', 'status'] 710 | 711 | def tf_status_command(self): 712 | return [get_user_command('tf') or 'tf', 'status'] 713 | 714 | def filter_unified_status(self, result): 715 | return list(filter(lambda x: len(x) > 0 and not x.lstrip().startswith('>'), 716 | result.rstrip().replace('"', '').split('\n'))) 717 | 718 | def git_filter_status(self, result): 719 | return self.filter_unified_status(result) 720 | 721 | def svn_filter_status(self, result): 722 | return self.filter_unified_status(result) 723 | 724 | def bzr_filter_status(self, result): 725 | return self.filter_unified_status(result) 726 | 727 | def hg_filter_status(self, result): 728 | return self.filter_unified_status(result) 729 | 730 | def tf_filter_status(self, result): 731 | filtered = [] 732 | can_add = False 733 | for line in result.split('\n'): 734 | if line.startswith('$'): 735 | can_add = True 736 | continue 737 | if line == '': 738 | can_add = False 739 | continue 740 | if can_add: 741 | filtered.append(line) 742 | 743 | return filtered 744 | 745 | def git_status_file(self, file_name): 746 | # first 2 characters are status codes, the third is a space 747 | return file_name[3:] 748 | 749 | def svn_status_file(self, file_name): 750 | return file_name[8:] 751 | 752 | def bzr_status_file(self, file_name): 753 | return file_name[4:] 754 | 755 | def hg_status_file(self, file_name): 756 | return file_name[2:] 757 | 758 | def tf_status_file(self, file_name): 759 | try: 760 | # assume that file name should always contain colon 761 | return re.findall(r'\s+(\S+:.+)$', file_name)[0] 762 | except: 763 | return None 764 | 765 | def status_done(self, result): 766 | filter_status = getattr(self, '{0}_filter_status'.format(self.vcs['name']), None) 767 | 768 | self.results = [item.replace('\r', '') for item in filter_status(result)] 769 | 770 | if self.results: 771 | self.show_status_list() 772 | else: 773 | sublime.status_message("Nothing to show") 774 | 775 | def show_status_list(self): 776 | options = copy(self.results) 777 | options.insert(0, " - Open All") 778 | if self.settings.get('uncommitted_files_use_monospace_font', True): 779 | self.get_window().show_quick_panel(options, self.panel_done, sublime.MONOSPACE_FONT) 780 | else: 781 | self.get_window().show_quick_panel(options, self.panel_done) 782 | 783 | def panel_done(self, picked): 784 | if picked == 0: 785 | self.open_files(*self.results) 786 | return 787 | elif 0 > picked < len(self.results): 788 | return 789 | picked_file = self.results[picked - 1] 790 | self.open_files(picked_file) 791 | 792 | def open_files(self, *files): 793 | for f in files: 794 | get_file = getattr(self, '{0}_status_file'.format(self.vcs['name']), None) 795 | if get_file: 796 | fname = get_file(f) 797 | if os.path.isfile(os.path.join(self.vcs['root'], fname)): 798 | self.window.open_file(os.path.join(self.vcs['root'], fname)) 799 | else: 800 | sublime.status_message("File '{0}' doesn't exist".format(fname)) 801 | 802 | 803 | class ToggleHighlightChangesCommand(sublime_plugin.TextCommand): 804 | def run(self, edit): 805 | setting_name = "highlight_changes" 806 | settings = get_settings() 807 | is_on = settings.get(setting_name) 808 | 809 | if is_on: 810 | # remove highlighting 811 | [self.view.erase_regions(k) for k in ('inserted', 'changed', 'deleted')] 812 | else: 813 | self.view.run_command('hl_changes') 814 | 815 | settings.set(setting_name, not is_on) 816 | sublime.save_settings("Modific.sublime-settings") 817 | -------------------------------------------------------------------------------- /Modific.sublime-commands: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "caption": "Modific: Show diff", 4 | "command": "show_diff" 5 | }, 6 | { 7 | "caption": "Modific: Show original part", 8 | "command": "show_original_part" 9 | }, 10 | { 11 | "caption": "Modific: Revert modified part", 12 | "command": "replace_modified_part" 13 | }, 14 | { 15 | "caption": "Modific: Show uncommitted files", 16 | "command": "uncommitted_files" 17 | }, 18 | { 19 | "caption": "Modific: Toggle highlight changes", 20 | "command": "toggle_highlight_changes" 21 | } 22 | ] 23 | -------------------------------------------------------------------------------- /Modific.sublime-settings: -------------------------------------------------------------------------------- 1 | // Modific default settings 2 | { 3 | // Highlight changes 4 | "highlight_changes": true, 5 | 6 | // Name of a region icon 7 | // Valid icon names are: modific, dot, circle, bookmark, cross, and none 8 | // If 'none' is used, modific does not add region icons. 9 | // WARNING: if you set value different than 'modific', 10 | // you may experience issues with UI of Sublime. 11 | // See https://github.com/gornostal/Modific/issues/9 12 | "region_icon": "modific", 13 | 14 | // You can use your commands instead of plain "git" or "svn" 15 | // e.g. "/usr/bin/git" or "C:\bin\git.exe" 16 | "vcs": [ 17 | {"name": "git", "dir": ".git", "cmd": "git"}, 18 | {"name": "svn", "dir": ".svn", "cmd": "svn"}, 19 | {"name": "bzr", "dir": ".bzr", "cmd": "bzr"}, 20 | {"name": "hg", "dir": ".hg", "cmd": "hg"}, 21 | {"name": "tf", "dir": "$tf", "cmd": "C:/Program Files (x86)/Microsoft Visual Studio 11.0/Common7/IDE/TF.exe"} 22 | ], 23 | 24 | // default list of options for a diff command for a certain VCS 25 | "vcs_options": { 26 | "git": ["--no-color", "--no-ext-diff"] 27 | }, 28 | 29 | //if you have some weird OS, that has non-unicode console 30 | //place its console encoding here 31 | "console_encoding" : "", 32 | 33 | // if true, plugin prints some debug information to the console window 34 | "debug": false, 35 | 36 | // set to true to enable automatic saving 37 | "autosave": false, 38 | 39 | // Turn this option on if you're using SVN 1.7 or higher 40 | // this instructs Subversion to use its built-in differencing engine 41 | // despite any external differencing mechanism that may be specified for use in the user's runtime configuration. 42 | "svn_use_internal_diff": false, 43 | 44 | // File size limit (in KB) for drawing icons on the gutter 45 | "max_file_size": 1024, 46 | 47 | // Whether the jump_between_changes command should wrap around to the beginning/end. 48 | "jump_between_changes_wraps_around": true, 49 | 50 | // Whether the uncommitted_files command should use a monospace font to dosplay the file list. 51 | "uncommitted_files_use_monospace_font": true 52 | } 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 👉 **This plugin is no longer maintained.** 2 | 3 | The built-in git support in ST is pretty decent. You can still install this plugin, and if it doesn't work feel free to fork it and modify to your needs. 4 | 5 | ----- 6 | 7 | Modific 8 | ========= 9 | 10 | Modific is a ST2(3) plugin for highlighting lines changed since the last commit (you know what I mean if you used Netbeans). 11 | 12 | For now it supports **Git**, **SVN**, **Bazaar**, **Mercurial** and **TFS**. 13 | 14 | 15 | Install 16 | ------- 17 | 18 | The easiest way to install is through **[Package Control](http://wbond.net/sublime\_packages/package\_control)**. 19 | 20 | Once you install Package Control, restart ST3 and bring up the Command Palette (`Ctrl+Shift+P` on Linux/Windows, `Cmd+Shift+P` on OS X). Select "Package Control: Install Package", wait while Package Control fetches the latest package list, then select *Modific* when the list appears. The advantage of using this method is that Package Control will automatically keep *Modific* up to date with the latest version. 21 | 22 | Or you can **download** the latest source from [GitHub](https://github.com/gornostal/Modific/zipball/master) and copy the *Modific* folder to your Sublime Text "Packages" directory. 23 | 24 | Or **clone** the repository to your Sublime Text "Packages" directory: 25 | 26 | git clone git://github.com/gornostal/Modific.git 27 | 28 | 29 | The "Packages" directory is located at: 30 | 31 | * OS X: 32 | 33 | ~/Library/Application Support/Sublime Text 3/Packages/ 34 | 35 | * Linux: 36 | 37 | ~/.config/sublime-text-2/Packages/ 38 | 39 | * Windows: 40 | 41 | %APPDATA%/Roaming/Sublime Text 3/Packages/ 42 | 43 | Please, make sure your VCS (version control system) binaries is in the PATH (**especially if you are on Windows**). 44 | 45 | To do that on Windows, open `Control Panel -> System -> Advanced system settings -> Environment variables -> System Variables`, find PATH, click "Edit" and append `;C:\path\to\VCS\binaries` for every VCS you will use (or make sure it's already there). 46 | 47 | Features / Usage 48 | ---------------- 49 | 50 | **Highlight changes** *(when file is saved)* 51 | [![Highlight changes](http://i.imgur.com/DX8TeJTl.jpg)](http://i.imgur.com/DX8TeJT.jpg) 52 | 53 | **Show diff** `Ctrl+Alt+D` on Linux/Windows and OS X 54 | [![Show diff](http://i.imgur.com/csCw7l.jpg)](http://i.imgur.com/csCw7.jpg) 55 | 56 | **Preview of the commited code for current line** `Ctrl+Alt+C` on Linux/Windows, `Ctrl+Super+C` on OS X 57 | [![Preview](http://i.imgur.com/siVOXl.jpg)](http://i.imgur.com/siVOX.jpg) 58 | 59 | **Revert modification** `Ctrl+Alt+R` on Linux/Windows, `Ctrl+Super+R` on OS X 60 | 61 | This command reverts modifications if your cursor stays on modified line (or if on group of lines, then whole group will be reverted) 62 | 63 | **View uncommitted files in a quick panel** `Ctrl+Alt+U` on Linux/Windows, `Ctrl+Super+U` on OS X 64 | [![Preview](http://i.imgur.com/sldHNl.jpg)](http://i.imgur.com/sldHN.jpg) 65 | 66 | **Go through changed lines** `Ctrl+Shift+Page Up(Down)` 67 | 68 | For those who expected to see a clone of Netbeans feature - unfortunately, with existing Sublime Text API that is impossible :( 69 | 70 | [Discussion on the forum](http://www.sublimetext.com/forum/viewtopic.php?f=5&t=7468) 71 | 72 | **Toggle highlighting on/off** `Ctl+Shift+h, Ctrl+Shift+l` 73 | 74 | Configuring 75 | ----------- 76 | 77 | Open `Prefrences -> Package Settings -> Modific -> Settings - Default` and look for available settings. 78 | 79 | If you want to change something, don't do it in this file. Open `Preferences -> Package Settings -> Modific -> Settings - User` and put there your configuration. 80 | 81 | You can configure is a type of icon (dot, circle or bookmark) and path for your VCS binaries (or leave them as is, if you have them in your PATH). It's also possible to set priority for VCS used (when you have more than one simultaneously) by reordering their definitions. 82 | 83 | If some sacred punishment has been bestowed upon you, and you have no other choice but to use OS, where console has non-UTF8 encoding, you can set console_encoding parameter to the name of your beloved encoding. This parameter is specifically designed for Windows XP users, who have their git repositories in folders with cyrillic path. Since russian XP uses CP1251 as default encoding (including console), VCS diff commands will be encoded appropriately, when using this parameter. 84 | 85 | If you use different than the default theme, you can customize colors of bullets on the gutter by adding [this](https://gist.github.com/3692073) chunk of code to your theme. 86 | 87 | ### SVN users 88 | If you are using SVN 1.7 you may want to turn on option `svn_use_internal_diff`. 89 | This instructs Subversion to use its built-in differencing engine 90 | despite any external differencing mechanism that may be specified for use in the user's runtime configuration. 91 | 92 | ### Line endings 93 | Modific takes into account `default_line_ending` setting that you can change in your "User Settings" (or per project/file basis). 94 | It determines what characters to use to join lines when Modific does "Revert change" action. 95 | Valid values: `system` (OS-dependent), `windows` (CRLF) and `unix` (LF). 96 | 97 | 98 | Thanks to 99 | --------- 100 | 101 | @beefsack for purchasing a license 102 | 103 | License 104 | ------- 105 | Released under the [WTFPLv2](http://sam.zoy.org/wtfpl/COPYING). 106 | -------------------------------------------------------------------------------- /icons/changed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gornostal/Modific/7cacc24bae63ebaf229ce12e8df4c9adc17d3710/icons/changed.png -------------------------------------------------------------------------------- /icons/deleted.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gornostal/Modific/7cacc24bae63ebaf229ce12e8df4c9adc17d3710/icons/deleted.png -------------------------------------------------------------------------------- /icons/inserted.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gornostal/Modific/7cacc24bae63ebaf229ce12e8df4c9adc17d3710/icons/inserted.png --------------------------------------------------------------------------------