├── Default.sublime-commands ├── remote_subl.sublime-settings ├── Main.sublime-menu ├── README.md └── remote_subl.py /Default.sublime-commands: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "caption": "Preferences: RemoteSubl Settings", 4 | "command": "edit_settings", 5 | "args": 6 | { 7 | "base_file": "${packages}/RemoteSubl/remote_subl.sublime-settings", 8 | "default": "{\n\t$0\n}\n" 9 | } 10 | } 11 | ] 12 | -------------------------------------------------------------------------------- /remote_subl.sublime-settings: -------------------------------------------------------------------------------- 1 | { 2 | /* 3 | listen host 4 | 5 | WARNING: it's NOT recommended to change this option, 6 | use SSH tunneling instead. 7 | */ 8 | "host": "localhost", 9 | 10 | /* 11 | listen port 12 | */ 13 | "port": 52698, 14 | 15 | /* 16 | display a dialog when connection is lost 17 | */ 18 | "pop_up_when_connection_lost": true, 19 | 20 | /* 21 | color scheme to use for remote windows 22 | 23 | examples: 24 | - null (use the default color scheme) 25 | - "Packages/Color Scheme - Default/Monokai.tmTheme" 26 | */ 27 | "color_scheme": null 28 | } 29 | -------------------------------------------------------------------------------- /Main.sublime-menu: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "caption": "Preferences", 4 | "mnemonic": "n", 5 | "id": "preferences", 6 | "children": [ 7 | { 8 | "caption": "Package Settings", 9 | "mnemonic": "P", 10 | "id": "package-settings", 11 | "children": [ 12 | { 13 | "caption": "RemoteSubl", 14 | "children": [ 15 | { 16 | "caption": "Settings", 17 | "command": "edit_settings", 18 | "args": { 19 | "base_file": "${packages}/RemoteSubl/remote_subl.sublime-settings" 20 | } 21 | } 22 | ] 23 | } 24 | ] 25 | } 26 | ] 27 | } 28 | ] 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RemoteSubl 2 | 3 | RemoteSubl starts as a fork of [rsub](https://github.com/henrikpersson/rsub) to 4 | bring `rmate` feature of TextMate to Sublime Text. It transfers files to be 5 | edited from remote server using SSH port forward and transfers the files back 6 | when they are saved. 7 | 8 | Comparing to rsub, the followings are enhanced: 9 | 10 | - support multiple files via `rmate foo bar`. 11 | - use the same view when opening the same file twice. 12 | - notify when connection lost. 13 | - resume previous connection when it was lost. 14 | - better status bar messages when saving file and when encountering errors. 15 | - bring up Sublime Text on different platforms. 16 | - ability to set a custom color scheme. 17 | 18 | Why a new fork? It seems that the author of rsub is not actively maintaining 19 | that package. 20 | 21 | # Installation 22 | 23 | Before installing on your remote server, RemoteSubl can easily be installed 24 | using [Package Control](https://packagecontrol.io). 25 | 26 | Once you have that completed, open up Sublime Text *(the rest won't work unless 27 | you do)*. 28 | 29 | On the remote server, we need to install 30 | [rmate](https://github.com/aurora/rmate) (this one is the bash version). You 31 | don't have to install it if you have been using `rmate` with TextMate or other 32 | editors. It is just the same executable. If not, it (the bash version) can be 33 | installed by running this script (assuming you have the right permission), 34 | 35 | ```bash 36 | curl -o /usr/local/bin/rmate https://raw.githubusercontent.com/aurora/rmate/master/rmate 37 | sudo chmod +x /usr/local/bin/rmate 38 | ``` 39 | 40 | You can also rename the command to `rsubl` 41 | 42 | ``` 43 | mv /usr/local/bin/rmate /usr/local/bin/rsubl 44 | ``` 45 | 46 | If your remote system does not have `bash` (so what else does it have?), there 47 | are different versions of `rmate` to choose from: 48 | 49 | - The official ruby version: https://github.com/textmate/rmate 50 | - A bash version: https://github.com/aurora/rmate 51 | - A perl version: https://github.com/davidolrik/rmate-perl 52 | - A python version: https://github.com/sclukey/rmate-python 53 | - A nim version: https://github.com/aurora/rmate-nim 54 | - A C version: https://github.com/hanklords/rmate.c 55 | - A node.js version: https://github.com/jrnewell/jmate 56 | 57 | # Usage 58 | 59 | Open an ssh connection to the remote server with remote port forwarded. It can 60 | be done by executing the following command on your local machine: 61 | 62 | ```bash 63 | ssh -R 52698:localhost:52698 user@example.com 64 | ``` 65 | 66 | After running the server, you can just open the file by typing the following 67 | command in your remote system's terminal: 68 | 69 | ``` 70 | rmate test.txt 71 | ``` 72 | 73 | (***NOTE:*** you need to have opened Sublime Text on your local machine. 74 | *If not* you get this error: `connect_to localhost port 52698: failed.` on your 75 | server) 76 | 77 | ... or if you renamed it to `rsubl` then ... 78 | 79 | ``` 80 | rsubl test.txt 81 | ``` 82 | 83 | If everything has been setup correctly, you should be able to see the opening 84 | file in Sublime Text. 85 | 86 | ### SSH config 87 | 88 | It could be tedious to type `-R 52698:localhost:52698` everytime you ssh. To 89 | make your life easier, add the following to `~/.ssh/config`, 90 | 91 | ``` 92 | Host example.com 93 | RemoteForward 52698 localhost:52698 94 | User user 95 | ``` 96 | 97 | From now on, you only have to do `ssh example.com`. 98 | 99 | ### PuTTY config 100 | 101 | Alternatively, if you're using PuTTY as your SSH client, before you connect to 102 | your host: 103 | 104 | 1. Navigate to `Connection` > `SSH` > `Tunnels` in the left-hand navigation pane 105 | 1. In the `Add new forwarded port:` section, add `52698` to `Source port` text field 106 | 1. Then add `localhost:52698` in the `Destination` text field 107 | 1. Select `Remote` checkbox instead of `Local` 108 | 1. Click `Add` to add your forwarding information to the `Forwarded ports:` list 109 | 1. Save your settings if you'd like, and then connect to your remote host 110 | -------------------------------------------------------------------------------- /remote_subl.py: -------------------------------------------------------------------------------- 1 | import sublime 2 | import sublime_plugin 3 | import os 4 | import tempfile 5 | import socket 6 | import subprocess 7 | from time import strftime 8 | from threading import Thread 9 | import socketserver 10 | 11 | 12 | CREATE_TEMP_FILE_ERROR = "Failed to create remote_subl temporary directory! Error: {}" 13 | WRITE_TEMP_FILE_ERROR = "Failed to write to temp file! Error: {}" 14 | CONNECTION_LOST = "Connection to {} is lost." 15 | FILES = {} 16 | LOST_FILES = {} 17 | server = None 18 | 19 | 20 | def subl(*args): 21 | executable_path = sublime.executable_path() 22 | if sublime.platform() == 'osx': 23 | app_path = executable_path[:executable_path.rfind('.app/') + 5] 24 | executable_path = app_path + 'Contents/SharedSupport/bin/subl' 25 | 26 | subprocess.Popen([executable_path] + list(args)) 27 | 28 | def on_activated(): 29 | window = sublime.active_window() 30 | view = window.active_view() 31 | 32 | if sublime.platform() == 'windows': 33 | # fix focus on windows 34 | window.run_command('focus_neighboring_group') 35 | window.focus_view(view) 36 | 37 | sublime_plugin.on_activated(view.id()) 38 | sublime_plugin.on_activated_async(view.id()) 39 | 40 | sublime.set_timeout(on_activated, 300) 41 | 42 | 43 | def say(msg): 44 | print('[remote_subl {}]: {}'.format(strftime("%H:%M:%S"), msg)) 45 | 46 | 47 | class File: 48 | def __init__(self, session): 49 | self.session = session 50 | self.env = {} 51 | self.data = [] # list of bytes 52 | self.current_data_length = 0 53 | self.file_size = None # The total length that we plan to read 54 | self.ready = False 55 | 56 | def append(self, line): 57 | # If appending this line would put us over the stated file size 58 | if self.current_data_length + len(line) > self.file_size: 59 | # Trim 60 | line = line[:(self.file_size - self.current_data_length)] 61 | assert len(line) + self.current_data_length == self.file_size 62 | 63 | self.data.append(line) 64 | self.current_data_length += len(line) 65 | 66 | # If we're done, end 67 | if self.current_data_length == self.file_size: 68 | self.ready = True 69 | 70 | def get_text(self): 71 | return b"".join(self.data) 72 | 73 | def close(self, remove=True): 74 | self.session.send("close") 75 | self.session.send("token: {}".format(self.env['token'])) 76 | self.session.send("") 77 | if remove: 78 | os.unlink(self.temp_path) 79 | os.rmdir(self.temp_dir) 80 | self.session.try_close() 81 | 82 | def save(self): 83 | self.session.send("save") 84 | self.session.send("token: {}".format(self.env['token'])) 85 | temp_file = open(self.temp_path, "rb") 86 | new_file = temp_file.read() 87 | temp_file.close() 88 | self.session.send("data: {:d}".format(len(new_file))) 89 | self.session.send(new_file) 90 | 91 | def get_temp_dir(self): 92 | # First determine if the file has been sent before. 93 | for f in FILES.values(): 94 | if f.env["real-path"] and f.env["real-path"] == self.env["real-path"] and \ 95 | f.host and f.host == self.host: 96 | return f.temp_dir 97 | 98 | for vid, f in LOST_FILES.items(): 99 | if f.env["real-path"] and f.env["real-path"] == self.env["real-path"] and \ 100 | f.host and f.host == self.host: 101 | LOST_FILES.pop(vid) 102 | return f.temp_dir 103 | 104 | # Create a secure temporary directory, both for privacy and to allow 105 | # multiple files with the same basename to be edited at once without 106 | # overwriting each other. 107 | try: 108 | return tempfile.mkdtemp(prefix=(self.host or "remote_subl") + "-") 109 | except OSError as e: 110 | sublime.message_dialog(CREATE_TEMP_FILE_ERROR.format(e)) 111 | 112 | def open(self): 113 | self.temp_dir = self.get_temp_dir() 114 | self.temp_path = os.path.join( 115 | self.temp_dir, 116 | self.base_name) 117 | try: 118 | with open(self.temp_path, "wb+") as temp_file: 119 | temp_file.write(self.get_text()) 120 | temp_file.flush() 121 | except IOError as e: 122 | try: 123 | # Remove the file if it exists. 124 | os.remove(self.temp_path) 125 | os.rmdir(self.temp_dir) 126 | except Exception: 127 | pass 128 | 129 | sublime.message_dialog(WRITE_TEMP_FILE_ERROR.format(e)) 130 | 131 | # create new window if needed 132 | if len(sublime.windows()) == 0 or "new" in self.env: 133 | sublime.run_command("new_window") 134 | 135 | # Open it within sublime 136 | view = sublime.active_window().open_file( 137 | "{0}:{1}:0".format( 138 | self.temp_path, self.env['selection'] if 'selection' in self.env else 0), 139 | sublime.ENCODED_POSITION) 140 | 141 | # Add the file metadata to the view's settings 142 | view.settings().set('remote_subl.host', self.host) 143 | view.settings().set('remote_subl.base_name', self.base_name) 144 | 145 | # if the current view is attached to another file object, 146 | # that file object has to be closed first. 147 | if view.id() in FILES: 148 | file = FILES.pop(view.id()) 149 | try: 150 | # connection may have lost 151 | file.close(remove=False) 152 | except Exception: 153 | pass 154 | 155 | # Add the file to the global list 156 | FILES[view.id()] = self 157 | 158 | # Bring sublime to front by running `subl --command ""` 159 | subl("--command", "") 160 | 161 | # Optionally set the color scheme 162 | settings = sublime.load_settings("remote_subl.sublime-settings") 163 | color_scheme = settings.get("color_scheme", None) 164 | if color_scheme is not None: 165 | subl("--command", 'set_setting {{"setting":"color_scheme","value":"{}"}}'.format(color_scheme)) 166 | 167 | view.run_command("remote_subl_update_status_bar") 168 | 169 | 170 | class Session: 171 | def __init__(self, socket): 172 | self.socket = socket 173 | self.parsing_data = False 174 | self.nconn = 0 175 | self.file = None 176 | 177 | def parse_input(self, input_line): 178 | if not self.parsing_data: 179 | if input_line.strip() == b"open": 180 | self.file = File(self) 181 | self.nconn += 1 182 | return 183 | 184 | if self.parsing_data: 185 | self.file.append(input_line) 186 | if self.file.ready: 187 | self.file.open() 188 | self.parsing_data = False 189 | self.file = None 190 | return 191 | 192 | if not self.file: 193 | return 194 | 195 | # prase settings 196 | input_line = input_line.decode("utf8").strip() 197 | if ":" not in input_line: 198 | # not a setting 199 | return 200 | 201 | k, v = input_line.split(":", 1) 202 | k = k.strip() 203 | v = v.strip() 204 | self.file.env[k] = v 205 | 206 | if k == "data": 207 | self.file.file_size = int(v) 208 | self.parsing_data = True 209 | 210 | if ":" in self.file.env["display-name"]: 211 | host, base_name = self.file.env["display-name"].split(":", 1) 212 | self.file.host = host 213 | self.file.base_name = os.path.basename(base_name) 214 | else: 215 | self.file.host = None 216 | self.file.base_name = os.path.basename(self.file.env["display-name"]) 217 | 218 | if self.file.env["token"] == "-": 219 | # stdin input 220 | self.file.base_name = "untitled" 221 | 222 | def send(self, string): 223 | if not isinstance(string, bytes): 224 | string = string.encode("utf8") 225 | self.socket.send(string + b"\n") 226 | 227 | def try_close(self): 228 | self.nconn -= 1 229 | if self.nconn == 0: 230 | self.socket.shutdown(socket.SHUT_RDWR) 231 | self.socket.close() 232 | 233 | 234 | class RemoteSublEventListener(sublime_plugin.EventListener): 235 | def on_post_save_async(self, view): 236 | base_name = view.settings().get('remote_subl.base_name') 237 | if base_name: 238 | host = view.settings().get('remote_subl.host', "remote server") 239 | try: 240 | file = FILES[view.id()] 241 | file.save() 242 | say('Saved {} to {}.'.format(base_name, host)) 243 | 244 | sublime.set_timeout( 245 | lambda: sublime.status_message("Saved {} to {}.".format( 246 | base_name, host))) 247 | except Exception: 248 | say('Error saving {} to {}.'.format(base_name, host)) 249 | sublime.set_timeout( 250 | lambda: sublime.status_message( 251 | "Error saving {} to {}.".format(base_name, host))) 252 | 253 | def on_close(self, view): 254 | base_name = view.settings().get('remote_subl.base_name') 255 | if base_name: 256 | host = view.settings().get('remote_subl.host', "remote server") 257 | vid = view.id() 258 | if vid in LOST_FILES: 259 | LOST_FILES.pop(vid) 260 | try: 261 | file = FILES.pop(vid) 262 | file.close() 263 | say('Closed {} in {}.'.format(base_name, host)) 264 | except Exception: 265 | say('Error closing {} in {}.'.format(base_name, host)) 266 | 267 | def on_activated(self, view): 268 | base_name = view.settings().get('remote_subl.base_name') 269 | if base_name: 270 | view.run_command("remote_subl_update_status_bar") 271 | 272 | 273 | class RemoteSublUpdateStatusBarCommand(sublime_plugin.TextCommand): 274 | 275 | def run(self, edit): 276 | view = self.view 277 | if view.id() in FILES: 278 | file = FILES[view.id()] 279 | server_name = file.host or "remote server" 280 | self.view.set_status("remotesub_status", "[{}]".format(server_name)) 281 | else: 282 | self.view.set_status("remotesub_status", "[connection lost]") 283 | 284 | 285 | class ConnectionHandler(socketserver.BaseRequestHandler): 286 | def handle(self): 287 | address = str(self.client_address) 288 | 289 | say('New connection from ' + address) 290 | 291 | session = Session(self.request) 292 | self.request.send(b"Sublime Text 3 (remote_subl plugin)\n") 293 | 294 | socket_fd = self.request.makefile("rb") 295 | while True: 296 | line = socket_fd.readline() 297 | if len(line) == 0: 298 | break 299 | session.parse_input(line) 300 | 301 | self.cleanup(session) 302 | say('Connection from {} is closed.'.format(address)) 303 | 304 | def cleanup(self, session): 305 | settings = sublime.load_settings("remote_subl.sublime-settings") 306 | vid_to_pop = [] 307 | for vid, file in FILES.items(): 308 | if file.session == session: 309 | # only show message once 310 | if not vid_to_pop: 311 | if settings.get("pop_up_when_connection_lost", True): 312 | sublime.message_dialog( 313 | CONNECTION_LOST.format(file.host or "remote")) 314 | vid_to_pop.append(vid) 315 | 316 | for vid in vid_to_pop: 317 | LOST_FILES[vid] = FILES.pop(vid) 318 | sublime.View(vid).run_command("remote_subl_update_status_bar") 319 | 320 | 321 | class TCPServer(socketserver.ThreadingTCPServer): 322 | allow_reuse_address = True 323 | 324 | 325 | def plugin_unloaded(): 326 | global server 327 | say('Killing server...') 328 | if server: 329 | server.shutdown() 330 | server.server_close() 331 | 332 | 333 | def plugin_loaded(): 334 | global server 335 | 336 | # Load settings 337 | settings = sublime.load_settings("remote_subl.sublime-settings") 338 | port = settings.get("port", 52698) 339 | host = settings.get("host", "localhost") 340 | 341 | # Start server thread 342 | server = TCPServer((host, port), ConnectionHandler) 343 | Thread(target=server.serve_forever, args=[]).start() 344 | say('Server running on {}:{} ...'.format(host, str(port))) 345 | --------------------------------------------------------------------------------