# source: https://github.com/gornostal/Modific # -*- coding: utf-8 -*- from __future__ import print_function import sublime import sublime_plugin import os import threading import subprocess import functools import re from copy import copy IS_ST3 = sublime.version().startswith('3') or sublime.version().startswith('4') def get_settings(): return sublime.load_settings("Modific.sublime-settings") def get_vcs_settings(): """ Returns list of dictionaries each dict. represents settings for VCS """ default = [ {"name": "git", "dir": ".git", "cmd": "git"}, {"name": "svn", "dir": ".svn", "cmd": "svn"}, {"name": "bzr", "dir": ".bzr", "cmd": "bzr"}, {"name": "hg", "dir": ".hg", "cmd": "hg"}, {"name": "tf", "dir": "$tf", "cmd": "C:/Program Files (x86)/Microsoft Visual Studio 11.0/Common7/IDE/TF.exe"} ] settings = get_settings().get('vcs', default) # re-format settings array if user has old format of settings if type(settings[0]) == list: settings = [dict(name=name, cmd=cmd, dir='.'+name) for name, cmd in settings] return settings def get_user_command(vcs_name): """ Returns command that user specified for vcs_name """ try: return [vcs['cmd'] for vcs in get_vcs_settings() if vcs.get('name') == vcs_name][0] except IndexError: return None def tfs_root(directory): try: tf_cmd = get_user_command('tf') or 'tf' command = [tf_cmd, 'workfold', directory] p = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, universal_newlines=False) out, err = p.communicate() m = re.search(r"^ \$\S+: (\S+)$", out, re.MULTILINE) if m: return {'root': m.group(1), 'name': 'tf', 'cmd': tf_cmd} except: return None def get_vcs(directory): """ Determines root directory for VCS and which of VCS systems should be used for a given directory Returns dictionary {name: .., root: .., cmd: .., dir: ..} """ vcs_check = [(lambda vcs: lambda dir: os.path.exists(os.path.join(dir, vcs.get('dir', False))) and vcs)(vcs) for vcs in get_vcs_settings()] start_directory = directory while directory: available = list(filter(bool, [check(directory) for check in vcs_check])) if available: available[0]['root'] = directory return available[0] parent = os.path.realpath(os.path.join(directory, os.path.pardir)) if parent == directory: # /.. == / # try TFS as a last resort # I'm not sure why we need to do this. Seems like it should find root for TFS in the main loop return tfs_root(start_directory) directory = parent return None def main_thread(callback, *args, **kwargs): # sublime.set_timeout gets used to send things onto the main thread # most sublime.[something] calls need to be on the main thread sublime.set_timeout(functools.partial(callback, *args, **kwargs), 0) def _make_text_safeish(text, fallback_encoding, method='decode'): # The unicode decode here is because sublime converts to unicode inside # insert in such a way that unknown characters will cause errors, which is # distinctly non-ideal... and there's no way to tell what's coming out of # git in output. So... try: unitext = getattr(text, method)('utf-8') except (UnicodeEncodeError, UnicodeDecodeError): unitext = getattr(text, method)(fallback_encoding) except AttributeError: # strongly implies we're already unicode, but just in case let's cast # to string unitext = str(text) return unitext def do_when(conditional, callback, *args, **kwargs): if conditional(): return callback(*args, **kwargs) sublime.set_timeout(functools.partial(do_when, conditional, callback, *args, **kwargs), 50) def log(*args, **kwargs): """ @param *args: string arguments that should be logged to console @param debug=True: debug log mode @param settings=None: instance of sublime.Settings """ debug = kwargs.get('debug', True) settings = kwargs.get('settings', None) if not settings: settings = get_settings() if debug and not settings.get('debug', False): return print('Modific:', *args) class CommandThread(threading.Thread): def __init__(self, command, on_done, working_dir="", fallback_encoding="", console_encoding="", **kwargs): threading.Thread.__init__(self) self.command = command self.on_done = on_done self.working_dir = working_dir if 'stdin' in kwargs: self.stdin = kwargs['stdin'].encode() else: self.stdin = None self.stdout = kwargs.get('stdout', subprocess.PIPE) self.console_encoding = console_encoding self.fallback_encoding = fallback_encoding self.kwargs = kwargs def run(self): try: # Per http://bugs.python.org/issue8557 shell=True is required to # get $PATH on Windows. Yay portable code. shell = os.name == 'nt' if self.console_encoding: self.command = [s.encode(self.console_encoding) for s in self.command] proc = subprocess.Popen(self.command, stdout=self.stdout, stderr=subprocess.STDOUT, stdin=subprocess.PIPE, cwd=self.working_dir if self.working_dir != '' else None, shell=shell, universal_newlines=False) output = proc.communicate(self.stdin)[0] if not output: output = '' # if sublime's python gets bumped to 2.7 we can just do: # output = subprocess.check_output(self.command) main_thread(self.on_done, _make_text_safeish(output, self.fallback_encoding), **self.kwargs) except subprocess.CalledProcessError as e: main_thread(self.on_done, e.returncode) except OSError as e: if e.errno == 2: main_thread(sublime.error_message, "'%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'])) else: raise e class EditViewCommand(sublime_plugin.TextCommand): def run(self, edit, command=None, output='', begin=0, region=None): """ For some reason Sublime's view.run_command() doesn't allow to pass tuples, therefore region must be a list """ region = sublime.Region(int(region[0]), int(region[1])) if region else None if command == 'insert': self.view.insert(edit, int(begin), output) elif command == 'replace': self.view.replace(edit, region, output) elif command == 'erase': self.view.erase(edit, region) else: print('Invalid command: ', command) raise class VcsCommand(object): may_change_files = False def __init__(self, *args, **kwargs): self.settings = get_settings() super(VcsCommand, self).__init__(*args, **kwargs) def log(self, *args, **kwargs): return log(settings=self.settings, *args, **kwargs) def run_command(self, command, callback=None, show_status=False, filter_empty_args=True, **kwargs): if filter_empty_args: command = [arg for arg in command if arg] if 'working_dir' not in kwargs: kwargs['working_dir'] = self.get_working_dir() if 'fallback_encoding' not in kwargs and self.active_view() and self.active_view().settings().get('fallback_encoding'): kwargs['fallback_encoding'] = self.active_view().settings().get('fallback_encoding').rpartition('(')[2].rpartition(')')[0] kwargs['console_encoding'] = self.settings.get('console_encoding') autosave = self.settings.get('autosave', True) if self.active_view() and self.active_view().is_dirty() and autosave: self.active_view().run_command('save') if not callback: callback = self.generic_done log('run command:', ' '.join(command)) thread = CommandThread(command, callback, **kwargs) thread.start() if show_status: message = kwargs.get('status_message', False) or ' '.join(command) sublime.status_message(message + 'wef') def generic_done(self, result): self.log('generic_done', result) if self.may_change_files and self.active_view() and self.active_view().file_name(): if self.active_view().is_dirty(): result = "WARNING: Current view is dirty.\n\n" else: # just asking the current file to be re-opened doesn't do anything print("reverting") position = self.active_view().viewport_position() self.active_view().run_command('revert') do_when(lambda: not self.active_view().is_loading(), lambda: self.active_view().set_viewport_position(position, False)) if not result.strip(): return self.panel(result) def _output_to_view(self, output_file, output, clear=False, syntax="Packages/Diff/Diff.tmLanguage"): output_file.set_syntax_file(syntax) if clear: output_file.run_command('edit_view', dict(command='replace', region=[0, self.output_view.size()], output=output)) else: output_file.run_command('edit_view', dict(command='insert', output=output)) def scratch(self, output, title=False, position=None, **kwargs): scratch_file = self.get_window().new_file() if title: scratch_file.set_name(title) scratch_file.set_scratch(True) self._output_to_view(scratch_file, output, **kwargs) scratch_file.set_read_only(True) if position: sublime.set_timeout(lambda: scratch_file.set_viewport_position(position), 0) return scratch_file def panel(self, output, **kwargs): if not hasattr(self, 'output_view'): self.output_view = self.get_window().get_output_panel("vcs") self.output_view.set_read_only(False) self._output_to_view(self.output_view, output, clear=True, **kwargs) self.output_view.set_read_only(True) self.get_window().run_command("show_panel", {"panel": "output.vcs"}) def _active_file_name(self): view = self.active_view() if view and view.file_name() and len(view.file_name()) > 0: return view.file_name() def active_view(self): return self.view def get_window(self): if (hasattr(self, 'view') and hasattr(self.view, 'window')): return self.view.window() else: return sublime.active_window() def get_working_dir(self): return os.path.dirname(self._active_file_name()) def is_enabled(self): file_name = self._active_file_name() if file_name and os.path.exists(file_name): return bool(get_vcs(self.get_working_dir())) return False class DiffCommand(VcsCommand): """ Here you can define diff commands for your VCS method name pattern: %(vcs_name)s_diff_command """ def run(self, edit): vcs = get_vcs(self.get_working_dir()) filepath = self.view.file_name() filename = os.path.basename(filepath) max_file_size = self.settings.get('max_file_size', 1024) * 1024 if not os.path.exists(filepath) or os.path.getsize(filepath) > max_file_size: # skip large files return get_command = getattr(self, '{0}_diff_command'.format(vcs['name']), None) if get_command: self.run_command(get_command(filename), self.diff_done) def diff_done(self, result): self.log('diff_done', result) def git_diff_command(self, file_name): vcs_options = self.settings.get('vcs_options', {}).get('git') or ['--no-color', '--no-ext-diff'] return [get_user_command('git') or 'git', 'diff'] + vcs_options + ['--', file_name] def svn_diff_command(self, file_name): params = [get_user_command('svn') or 'svn', 'diff'] params.extend(self.settings.get('vcs_options', {}).get('svn', [])) if '--internal-diff' not in params and self.settings.get('svn_use_internal_diff', True): params.append('--internal-diff') # if file starts with @, use `--revision HEAD` option # https://github.com/gornostal/Modific/issues/17 if file_name.find('@') != -1: file_name += '@' params.extend(['--revision', 'HEAD']) params.append(file_name) return params def bzr_diff_command(self, file_name): vcs_options = self.settings.get('vcs_options', {}).get('bzr', []) return [get_user_command('bzr') or 'bzr', 'diff'] + vcs_options + [file_name] def hg_diff_command(self, file_name): vcs_options = self.settings.get('vcs_options', {}).get('hg', []) return [get_user_command('hg') or 'hg', 'diff'] + vcs_options + [file_name] def tf_diff_command(self, file_name): vcs_options = self.settings.get('vcs_options', {}).get('tf') or ['-format:unified'] return [get_user_command('tf') or 'tf', 'diff'] + vcs_options + [file_name] def get_line_ending(self): return '\n' def join_lines(self, lines): """ Join lines using os.linesep.join(), unless another method is specified in ST settings """ return self.get_line_ending().join(lines) class ShowDiffCommand(DiffCommand, sublime_plugin.TextCommand): def diff_done(self, result): self.log('on show_diff', result) if not result.strip(): return result = result.replace('\r\n', '\n') file_name = re.findall(r'([^\\\/]+)$', self.view.file_name()) scratch = self.scratch(result, title="Diff - " + file_name[0]) # Select the line in the diff output where the cursor is located. point = self.view.sel()[0].b region = self.view.line(point) line = self.view.substr(region) region = scratch.find(line, 0, sublime.LITERAL) scratch.show_at_center(region) scratch.sel().clear() # Place the cursor at the beginning of the line scratch.sel().add(scratch.line(region).a) class DiffParser(object): instance = None def __init__(self, diff): self.diff = diff self.chunks = None self.__class__.instance = self def _append_to_chunks(self, start, lines): self.chunks.append({ "start": start, "end": start + len(lines), "lines": lines }) def get_chunks(self): if self.chunks is None: self.chunks = [] diff = self.diff.strip() if diff: re_header = re.compile(r'^@@[0-9\-, ]+\+(\d+)', re.S) current = None lines = [] for line in diff.splitlines(): # ignore lines with '\' at the beginning if line.startswith('\\'): continue matches = re.findall(re_header, line) if matches: if current is not None: self._append_to_chunks(current, lines) current = int(matches[0]) lines = [] elif current: lines.append(line) if current is not None and lines: self._append_to_chunks(current, lines) return self.chunks def get_lines_to_hl(self): inserted = [] changed = [] deleted = [] for chunk in self.get_chunks(): current = chunk['start'] deleted_line = None for line in chunk['lines']: if line.startswith('-'): if (not deleted_line or deleted_line not in deleted): deleted.append(current) deleted_line = current elif line.startswith('+'): if deleted_line: deleted.pop() deleted_line = None changed.append(current) elif current - 1 in changed: changed.append(current) else: inserted.append(current) current += 1 else: deleted_line = None current += 1 return inserted, changed, deleted def get_original_part(self, line_num): """ returns a chunk of code that relates to the given line and was there before modifications return (lines list, start_line int, replace_lines int) """ # for each chunk from diff: for chunk in self.get_chunks(): # if line_num is within that chunk if chunk['start'] <= line_num <= chunk['end']: ret_lines = [] current = chunk['start'] # line number that corresponds to current version of file first = None # number of the first line to change replace_lines = 0 # number of lines to change return_this_lines = False # flag shows whether we can return accumulated lines for line in chunk['lines']: if line.startswith('-') or line.startswith('+'): first = first or current if current == line_num: return_this_lines = True if line.startswith('-'): # if line starts with '-' we have previous version ret_lines.append(line[1:]) else: # if line starts with '+' we only increment numbers replace_lines += 1 current += 1 elif return_this_lines: break else: # gap between modifications # reset our variables current += 1 first = current replace_lines = 0 ret_lines = [] if return_this_lines: return ret_lines, first, replace_lines return None, None, None class HlChangesCommand(DiffCommand, sublime_plugin.TextCommand): def hl_lines(self, lines, hl_key): if (not len(lines) or not self.settings.get('highlight_changes')): self.view.erase_regions(hl_key) return icon = self.settings.get('region_icon') or 'modific' if icon == 'none': return if icon == 'modific': if IS_ST3: icon = 'Packages/Modific/icons/' + hl_key + '.png' else: icon = '../Modific/icons/' + hl_key points = [self.view.text_point(l - 1, 0) for l in lines] regions = [sublime.Region(p, p) for p in points] self.view.add_regions(hl_key, regions, "markup.%s.diff" % hl_key, icon, sublime.HIDDEN | sublime.DRAW_EMPTY) def diff_done(self, diff): self.log('on hl_changes:', diff) if diff and '@@' not in diff: # probably this is an error message # if print raise UnicodeEncodeError, try to encode string to utf-8 (issue #35) try: print(diff) except UnicodeEncodeError: print(diff.encode('utf-8')) diff_parser = DiffParser(diff) (inserted, changed, deleted) = diff_parser.get_lines_to_hl() self.log('new lines:', inserted) self.log('modified lines:', changed) self.log('deleted lines:', deleted) self.hl_lines(inserted, 'inserted') self.hl_lines(deleted, 'deleted') self.hl_lines(changed, 'changed') class ShowOriginalPartCommand(DiffCommand, sublime_plugin.TextCommand): def run(self, edit): diff_parser = DiffParser.instance if not diff_parser: return (row, col) = self.view.rowcol(self.view.sel()[0].begin()) (lines, start, replace_lines) = diff_parser.get_original_part(row + 1) if lines is not None: self.panel(self.join_lines(lines)) class ReplaceModifiedPartCommand(DiffCommand, sublime_plugin.TextCommand): def run(self, edit): self.view.run_command('save') diff_parser = DiffParser.instance if not diff_parser: return (row, col) = self.view.rowcol(self.view.sel()[0].begin()) (lines, current, replace_lines) = diff_parser.get_original_part(row + 1) if self.settings.get('debug'): print('replace', (lines, current, replace_lines)) if lines is not None: begin = self.view.text_point(current - 1, 0) content = self.join_lines(lines) if replace_lines: end = self.view.line(self.view.text_point(replace_lines + current - 2, 0)).end() region = sublime.Region(begin, end) if lines: self.view.run_command('edit_view', dict(command='replace', region=[region.begin(), region.end()], output=content)) else: region = self.view.full_line(region) self.view.run_command('edit_view', dict(command='erase', region=[region.begin(), region.end()])) else: self.view.run_command('edit_view', dict(command='insert', begin=begin, output=content + self.get_line_ending())) self.view.run_command('save') class HlChangesBackground(sublime_plugin.EventListener): def on_load(self, view): if not IS_ST3: view.run_command('hl_changes') def on_load_async(self, view): view.run_command('hl_changes') def on_activated(self, view): if not IS_ST3: view.run_command('hl_changes') def on_activated_async(self, view): view.run_command('hl_changes') def on_post_save(self, view): if not IS_ST3: view.run_command('hl_changes') def on_post_save_async(self, view): view.run_command('hl_changes') class JumpBetweenChangesCommand(DiffCommand, sublime_plugin.TextCommand): def run(self, edit, direction='next'): lines = self._get_lines() if not lines: return if direction == 'prev': lines.reverse() (current_line, col) = self.view.rowcol(self.view.sel()[0].begin()) current_line += 1 jump_to = None for line in lines: if direction == 'next' and current_line < line: jump_to = line break if direction == 'prev' and current_line > line: jump_to = line break if not jump_to and self.settings.get('jump_between_changes_wraps_around', True): jump_to = lines[0] if jump_to is not None: self.goto_line(edit, jump_to) def goto_line(self, edit, line): # Convert from 1 based to a 0 based line number line = int(line) - 1 # Negative line numbers count from the end of the buffer if line < 0: lines, _ = self.view.rowcol(self.view.size()) line = lines + line + 1 pt = self.view.text_point(line, 0) self.view.sel().clear() self.view.sel().add(sublime.Region(pt)) self.view.show(pt) def _get_lines(self): diff_parser = DiffParser.instance if not diff_parser: return (inserted, changed, deleted) = diff_parser.get_lines_to_hl() lines = list(set(inserted + changed + deleted)) lines.sort() prev = None ret_lines = [] for line in lines: if prev != line - 1: ret_lines.append(line) prev = line return ret_lines class UncommittedFilesCommand(VcsCommand, sublime_plugin.WindowCommand): def active_view(self): return self.window.active_view() def is_enabled(self): return bool(self.get_working_dir()) def get_working_dir(self): if self._active_file_name(): working_dir = super(UncommittedFilesCommand, self).get_working_dir() if working_dir and get_vcs(working_dir): return working_dir # If the user has opened a vcs folder, use it. folders = self.window.folders() for folder in folders: if folder and os.path.exists(folder) and get_vcs(folder): return folder def run(self): self.vcs = get_vcs(self.get_working_dir()) status_command = getattr(self, '{0}_status_command'.format(self.vcs['name']), None) if status_command: self.run_command(status_command(), self.status_done, working_dir=self.vcs['root']) def git_status_command(self): return [get_user_command('git') or 'git', 'status', '--porcelain'] def svn_status_command(self): return [get_user_command('svn') or 'svn', 'status', '--quiet'] def bzr_status_command(self): return [get_user_command('bzr') or 'bzr', 'status', '-S', '--no-pending', '-V'] def hg_status_command(self): return [get_user_command('hg') or 'hg', 'status'] def tf_status_command(self): return [get_user_command('tf') or 'tf', 'status'] def filter_unified_status(self, result): return list(filter(lambda x: len(x) > 0 and not x.lstrip().startswith('>'), result.rstrip().replace('"', '').split('\n'))) def git_filter_status(self, result): return self.filter_unified_status(result) def svn_filter_status(self, result): return self.filter_unified_status(result) def bzr_filter_status(self, result): return self.filter_unified_status(result) def hg_filter_status(self, result): return self.filter_unified_status(result) def tf_filter_status(self, result): filtered = [] can_add = False for line in result.split('\n'): if line.startswith('$'): can_add = True continue if line == '': can_add = False continue if can_add: filtered.append(line) return filtered def git_status_file(self, file_name): # first 2 characters are status codes, the third is a space return file_name[3:] def svn_status_file(self, file_name): return file_name[8:] def bzr_status_file(self, file_name): return file_name[4:] def hg_status_file(self, file_name): return file_name[2:] def tf_status_file(self, file_name): try: # assume that file name should always contain colon return re.findall(r'\s+(\S+:.+)$', file_name)[0] except: return None def status_done(self, result): filter_status = getattr(self, '{0}_filter_status'.format(self.vcs['name']), None) self.results = [item.replace('\r', '') for item in filter_status(result)] if self.results: self.show_status_list() else: sublime.status_message("Nothing to show") def show_status_list(self): options = copy(self.results) options.insert(0, " - Open All") if self.settings.get('uncommitted_files_use_monospace_font', True): self.get_window().show_quick_panel(options, self.panel_done, sublime.MONOSPACE_FONT) else: self.get_window().show_quick_panel(options, self.panel_done) def panel_done(self, picked): if picked == 0: self.open_files(*self.results) return elif 0 > picked < len(self.results): return picked_file = self.results[picked - 1] self.open_files(picked_file) def open_files(self, *files): for f in files: get_file = getattr(self, '{0}_status_file'.format(self.vcs['name']), None) if get_file: fname = get_file(f) if os.path.isfile(os.path.join(self.vcs['root'], fname)): self.window.open_file(os.path.join(self.vcs['root'], fname)) else: sublime.status_message("File '{0}' doesn't exist".format(fname)) class ToggleHighlightChangesCommand(sublime_plugin.TextCommand): def run(self, edit): setting_name = "highlight_changes" settings = get_settings() is_on = settings.get(setting_name) if is_on: # remove highlighting [self.view.erase_regions(k) for k in ('inserted', 'changed', 'deleted')] else: self.view.run_command('hl_changes') settings.set(setting_name, not is_on) sublime.save_settings("Modific.sublime-settings")