├── .gitignore ├── README.md ├── screenshot.png ├── ssh_hosts.example ├── sshgo.py └── sshgo3.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | 21 | # Installer logs 22 | pip-log.txt 23 | 24 | # Unit test / coverage reports 25 | .coverage 26 | .tox 27 | nosetests.xml 28 | 29 | # Translations 30 | *.mo 31 | 32 | # Mr Developer 33 | .mr.developer.cfg 34 | .project 35 | .pydevproject 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | sshgo 2 | ===== 3 | 4 | script for managing ssh hosts list 5 | 6 | ## screenshot 7 | ![screenshot](https://raw.githubusercontent.com/emptyhua/sshgo/master/screenshot.png) 8 | 9 | ## ~/.ssh_hosts example 10 | 11 | #add `-` before the line can close node 12 | -Home 13 | root@192.168.1.106 14 | Work 15 | root@comp1 -p 9999 #CentOS 5 X64 16 | root@comp2 -p 9999 #CentOS 6 X64 17 | root@comp3 -p 9999 #Debian 6 X64 18 | VHost 19 | VMWare 20 | test@vm1 21 | test@vm2 22 | test@vm3 23 | test@vm4 24 | -VirtualBox: 25 | test@vbox1 26 | test@vbox2 27 | test@vbox3 28 | test@vbox4 29 | MacOS 30 | hi@mymac 31 | 32 | ## Keyboard shortcuts 33 | * exit: q 34 | * scroll up: k 35 | * scroll down: j 36 | * page up: u 37 | * page down: d 38 | * select host: space 39 | * search mode: / 40 | * exit from search result: q 41 | * expand tree node: o 42 | * collapse tree node: c 43 | * expand all nodes: O 44 | * collapse all nodes: C 45 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emptyhua/sshgo/7619eac0314dd8498d9d2c3e3e0a14287a2ecaf7/screenshot.png -------------------------------------------------------------------------------- /ssh_hosts.example: -------------------------------------------------------------------------------- 1 | -Home 2 | root@192.168.1.106 3 | Work 4 | root@comp1 -p 9999 #CentOS 5 X64 5 | root@comp2 -p 9999 #CentOS 6 X64 6 | root@comp3 -p 9999 #Debian 6 X64 7 | VHost 8 | VMWare 9 | test@vm1 10 | test@vm2 11 | test@vm3 12 | test@vm4 13 | -VirtualBox 14 | test@vbox1 15 | test@vbox2 16 | test@vbox3 17 | test@vbox4 18 | MacOS 19 | hi@mymac 20 | -------------------------------------------------------------------------------- /sshgo.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import os,sys,re 5 | import curses 6 | import locale 7 | import math 8 | from optparse import OptionParser 9 | 10 | locale.setlocale(locale.LC_ALL, '') 11 | 12 | def _assert(exp, err): 13 | if not exp: 14 | print >>sys.stderr,err 15 | sys.exit(1) 16 | 17 | def _dedup(ls): 18 | table = {} 19 | for item in ls: 20 | table[item] = None 21 | ls_dedup = table.keys() 22 | ls_dedup.sort() 23 | return ls_dedup 24 | 25 | def _get_known_hosts(): 26 | fn = os.path.expanduser('~/.ssh/known_hosts') 27 | hosts = [] 28 | try: 29 | for line in open(fn, 'r'): 30 | tmp = line.split(' ') 31 | if not len(tmp) or not len(tmp[0]): 32 | continue 33 | host = tmp[0].split(',')[0] 34 | if host.find('[') != -1: 35 | m = re.match(r'\[([^\]]+)\]:(\d+)', host) 36 | if m is not None: 37 | host = '%s -p %s' % m.groups() 38 | hosts.append(host) 39 | except IOError: 40 | return hosts 41 | return _dedup(hosts) 42 | 43 | class SSHGO: 44 | 45 | UP = -1 46 | DOWN = 1 47 | 48 | KEY_O = 79 49 | KEY_R = 82 50 | KEY_G = 71 51 | KEY_o = 111 52 | KEY_r = 114 53 | KEY_g = 103 54 | KEY_c = 99 55 | KEY_C = 67 56 | KEY_m = 109 57 | KEY_M = 77 58 | KEY_d = 0x64 59 | KEY_u = 0x75 60 | KEY_SPACE = 32 61 | KEY_ENTER = 10 62 | KEY_q = 113 63 | KEY_ESC = 27 64 | 65 | KEY_j = 106 66 | KEY_k = 107 67 | 68 | KEY_SPLASH = 47 69 | 70 | screen = None 71 | 72 | def _parse_tree_from_config_file(self, config_file): 73 | tree = {'line_number':None,'expanded':True,'line':None,'sub_lines':[]} 74 | 75 | def find_parent_line(new_node): 76 | line_number = new_node['line_number'] 77 | level = new_node['level'] 78 | 79 | if level == 0: 80 | return tree 81 | 82 | stack = tree['sub_lines'] + [] 83 | parent = None 84 | while len(stack): 85 | node = stack.pop() 86 | if node['line_number'] < line_number and node['level'] == level - 1: 87 | if parent is None: 88 | parent = node 89 | elif node['line_number'] > parent['line_number']: 90 | parent = node 91 | if len(node['sub_lines']) and node['level'] < level: 92 | stack = stack + node['sub_lines'] 93 | continue 94 | 95 | return parent 96 | 97 | tree_level = None 98 | nodes_pool = [] 99 | line_number = 0; 100 | 101 | 102 | for line in open(config_file, 'r'): 103 | line_number += 1 104 | line_con = line.strip() 105 | if line_con == '' or line_con[0] == '#': 106 | continue 107 | expand = True 108 | if line_con[0] == '-': 109 | line_con = line_con[1:] 110 | expand = False 111 | indent = re.findall(r'^[\t ]*(?=[^\t ])', line)[0] 112 | line_level = indent.count(' ') + indent.count('\t') 113 | if tree_level == None: 114 | _assert(line_level == 0, 'invalid indent,line:' + str(line_number)) 115 | else: 116 | _assert(line_level <= tree_level 117 | or line_level == tree_level + 1, 'invalid indent,line:' + str(line_number)) 118 | tree_level = line_level 119 | 120 | new_node = {'level':tree_level,'expanded':expand,'line_number':line_number,'line':line_con,'sub_lines':[]} 121 | nodes_pool.append(new_node) 122 | parent = find_parent_line(new_node) 123 | parent['sub_lines'].append(new_node) 124 | 125 | return tree, nodes_pool 126 | 127 | 128 | def __init__(self, config_file): 129 | 130 | self.hosts_tree, self.hosts_pool = self._parse_tree_from_config_file(config_file) 131 | 132 | known_host_list = _get_known_hosts() 133 | 134 | if len(known_host_list): 135 | append_line_number = self.hosts_pool[-1]['line_number'] + 1 136 | 137 | known_hosts = {'sub_lines':[], 138 | 'line_number':append_line_number, 139 | 'line':'known hosts', 140 | 'expanded':False, 141 | 'level':0 142 | } 143 | 144 | self.hosts_tree['sub_lines'].append(known_hosts) 145 | self.hosts_pool.append(known_hosts) 146 | 147 | for host in known_host_list: 148 | append_line_number += 1 149 | new_node = { 150 | 'sub_lines':[], 151 | 'line_number':append_line_number, 152 | 'line':host, 153 | 'expanded':True, 154 | 'level':1 155 | } 156 | known_hosts['sub_lines'].append(new_node) 157 | self.hosts_pool.append(new_node) 158 | 159 | 160 | self.screen = curses.initscr() 161 | curses.noecho() 162 | curses.cbreak() 163 | curses.curs_set(0) 164 | self.screen.keypad(1) 165 | self.screen.border(0) 166 | 167 | self.top_line_number = 0 168 | self.highlight_line_number = 0 169 | self.search_keyword = None 170 | 171 | curses.start_color() 172 | curses.use_default_colors() 173 | 174 | #highlight 175 | curses.init_pair(2, curses.COLOR_WHITE, curses.COLOR_BLUE) 176 | self.COLOR_HIGHLIGHT = 2 177 | #red 178 | curses.init_pair(3, curses.COLOR_RED, -1) 179 | self.COLOR_RED = 3 180 | 181 | #red highlight 182 | curses.init_pair(4, curses.COLOR_RED, curses.COLOR_BLUE) 183 | self.COLOR_RED_HIGH = 4 184 | 185 | #white bg 186 | curses.init_pair(5, curses.COLOR_BLACK, curses.COLOR_WHITE) 187 | self.COLOR_WBG = 5 188 | 189 | #black bg 190 | curses.init_pair(6, curses.COLOR_BLACK, curses.COLOR_BLACK) 191 | self.COLOR_BBG = 6 192 | 193 | self.run() 194 | 195 | def run(self): 196 | while True: 197 | self.render_screen() 198 | c = self.screen.getch() 199 | if c == curses.KEY_UP or c == self.KEY_k: 200 | self.updown(-1) 201 | elif c == curses.KEY_DOWN or c == self.KEY_j: 202 | self.updown(1) 203 | elif c == self.KEY_u: 204 | for i in range(0, curses.tigetnum('lines')): 205 | self.updown(-1) 206 | elif c == self.KEY_d: 207 | for i in range(0, curses.tigetnum('lines')): 208 | self.updown(1) 209 | elif c == self.KEY_ENTER or c == self.KEY_SPACE: 210 | self.toggle_node() 211 | elif c == self.KEY_ESC or c == self.KEY_q: 212 | self.exit() 213 | elif c == self.KEY_O or c == self.KEY_M: 214 | self.open_all() 215 | elif c == self.KEY_o or c == self.KEY_m: 216 | self.open_node() 217 | elif c == self.KEY_C or c == self.KEY_R: 218 | self.close_all() 219 | elif c == self.KEY_c or c == self.KEY_r: 220 | self.close_node() 221 | elif c == self.KEY_g: 222 | self.page_top() 223 | elif c == self.KEY_G: 224 | self.page_bottom() 225 | elif c == self.KEY_SPLASH: 226 | self.enter_search_mode() 227 | 228 | def exit(self): 229 | if self.search_keyword is not None: 230 | self.search_keyword = None 231 | else: 232 | sys.exit(0) 233 | 234 | def enter_search_mode(self): 235 | screen_cols = curses.tigetnum('cols') 236 | self.screen.addstr(0, 0, '/' + ' ' * screen_cols) 237 | curses.echo() 238 | curses.curs_set(1) 239 | self.search_keyword = self.screen.getstr(0, 1) 240 | curses.noecho() 241 | curses.curs_set(0) 242 | 243 | def _get_visible_lines_for_render(self): 244 | lines = [] 245 | stack = self.hosts_tree['sub_lines'] + [] 246 | while len(stack): 247 | node = stack.pop() 248 | lines.append(node) 249 | if node['expanded'] and len(node['sub_lines']): 250 | stack = stack + node['sub_lines'] 251 | 252 | lines.sort(key=lambda n:n['line_number'], reverse=False) 253 | return lines 254 | 255 | def _search_node(self): 256 | rt = [] 257 | try: 258 | kre = re.compile(self.search_keyword, re.I) 259 | except: 260 | return rt 261 | for node in self.hosts_pool: 262 | if len(node['sub_lines']) == 0 and kre.search(node['line']) is not None: 263 | rt.append(node) 264 | return rt 265 | 266 | def get_lines(self): 267 | if self.search_keyword is not None: 268 | return self._search_node() 269 | else: 270 | return self._get_visible_lines_for_render() 271 | 272 | def page_top(self): 273 | self.top_line_number = 0 274 | self.highlight_line_number = 0 275 | 276 | def page_bottom(self): 277 | screen_lines = curses.tigetnum('lines') 278 | visible_hosts = self.get_lines() 279 | self.top_line_number = max(len(visible_hosts) - screen_lines, 0) 280 | self.highlight_line_number = min(screen_lines, len(visible_hosts)) - 1 281 | 282 | def open_node(self): 283 | visible_hosts = self.get_lines() 284 | linenum = self.top_line_number + self.highlight_line_number 285 | node = visible_hosts[linenum] 286 | if not len(node['sub_lines']): 287 | return 288 | stack = [node] 289 | while len(stack): 290 | node = stack.pop() 291 | node['expanded'] = True 292 | if len(node['sub_lines']): 293 | stack = stack + node['sub_lines'] 294 | 295 | def close_node(self): 296 | visible_hosts = self.get_lines() 297 | linenum = self.top_line_number + self.highlight_line_number 298 | node = visible_hosts[linenum] 299 | if not len(node['sub_lines']): 300 | return 301 | stack = [node] 302 | while len(stack): 303 | node = stack.pop() 304 | node['expanded'] = False 305 | if len(node['sub_lines']): 306 | stack = stack + node['sub_lines'] 307 | 308 | 309 | def open_all(self): 310 | for node in self.hosts_pool: 311 | if len(node['sub_lines']): 312 | node['expanded'] = True 313 | 314 | def close_all(self): 315 | for node in self.hosts_pool: 316 | if len(node['sub_lines']): 317 | node['expanded'] = False 318 | 319 | def toggle_node(self): 320 | visible_hosts = self.get_lines() 321 | linenum = self.top_line_number + self.highlight_line_number 322 | node = visible_hosts[linenum] 323 | if len(node['sub_lines']): 324 | node['expanded'] = not node['expanded'] 325 | else: 326 | self.restore_screen() 327 | ssh = 'ssh' 328 | if os.popen('which zssh 2> /dev/null').read().strip() != '': 329 | ssh = 'zssh' 330 | cmd = node['line'].split('#')[0] 331 | os.execvp(ssh, [ssh] + re.split(r'[ ]+', cmd)) 332 | 333 | def render_screen(self): 334 | # clear screen 335 | self.screen.clear() 336 | 337 | # now paint the rows 338 | screen_lines = curses.tigetnum('lines') 339 | screen_cols = curses.tigetnum('cols') 340 | 341 | if self.highlight_line_number >= screen_lines: 342 | self.highlight_line_number = screen_lines - 1 343 | 344 | all_nodes = self.get_lines() 345 | if self.top_line_number >= len(all_nodes): 346 | self.top_line_number = 0 347 | 348 | top = self.top_line_number 349 | bottom = self.top_line_number + screen_lines 350 | nodes = all_nodes[top:bottom] 351 | 352 | if not len(nodes): 353 | self.screen.refresh() 354 | return 355 | 356 | if self.highlight_line_number >= len(nodes): 357 | self.highlight_line_number = len(nodes) - 1 358 | 359 | if self.top_line_number >= len(all_nodes): 360 | self.top_line_number = 0 361 | 362 | for (index,node,) in enumerate(nodes): 363 | #linenum = self.top_line_number + index 364 | 365 | line = node['line'] 366 | if len(node['sub_lines']): 367 | line += '(%d)' % len(node['sub_lines']) 368 | 369 | prefix = '' 370 | if self.search_keyword is None: 371 | prefix += ' ' * node['level'] 372 | if len(node['sub_lines']): 373 | if node['expanded']: 374 | prefix += '-' 375 | else: 376 | prefix += '+' 377 | else: 378 | prefix += 'o' 379 | prefix += ' ' 380 | 381 | # highlight current line 382 | if index != self.highlight_line_number: 383 | self.screen.addstr(index, 0, prefix, curses.color_pair(self.COLOR_RED)) 384 | self.screen.addstr(index, len(prefix), line) 385 | else: 386 | self.screen.addstr(index, 0, prefix, curses.color_pair(self.COLOR_RED_HIGH)) 387 | self.screen.addstr(index, len(prefix), line, curses.color_pair(self.COLOR_HIGHLIGHT)) 388 | #render scroll bar 389 | for i in xrange(screen_lines): 390 | self.screen.addstr(i, screen_cols - 2, '|', curses.color_pair(self.COLOR_WBG)) 391 | 392 | scroll_top = int(math.ceil((self.top_line_number + 1.0) / max(len(all_nodes), screen_lines) * screen_lines - 1)) 393 | scroll_height = int(math.ceil((len(nodes) + 0.0) / len(all_nodes) * screen_lines)) 394 | highlight_pos = int(math.ceil(scroll_height * ((self.highlight_line_number + 1.0)/min(screen_lines, len(nodes))))) 395 | 396 | self.screen.addstr(scroll_top, screen_cols - 2, '^', curses.color_pair(self.COLOR_WBG)) 397 | self.screen.addstr(min(screen_lines, scroll_top + scroll_height) - 1, screen_cols - 2, 'v', curses.color_pair(self.COLOR_WBG)) 398 | self.screen.addstr(min(screen_lines, scroll_top + highlight_pos) - 1, screen_cols - 2, '+', curses.color_pair(self.COLOR_WBG)) 399 | 400 | 401 | self.screen.refresh() 402 | 403 | # move highlight up/down one line 404 | def updown(self, increment): 405 | visible_hosts = self.get_lines() 406 | visible_lines_count = len(visible_hosts) 407 | next_line_number = self.highlight_line_number + increment 408 | 409 | # paging 410 | if increment < 0 and self.highlight_line_number == 0 and self.top_line_number != 0: 411 | self.top_line_number += self.UP 412 | return 413 | elif increment > 0 and next_line_number == curses.tigetnum('lines') and (self.top_line_number+curses.tigetnum('lines')) != visible_lines_count: 414 | self.top_line_number += self.DOWN 415 | return 416 | 417 | # scroll highlight line 418 | if increment < 0 and (self.top_line_number != 0 or self.highlight_line_number != 0): 419 | self.highlight_line_number = next_line_number 420 | elif increment > 0 and (self.top_line_number+self.highlight_line_number+1) != visible_lines_count and self.highlight_line_number != curses.tigetnum('lines'): 421 | self.highlight_line_number = next_line_number 422 | 423 | def restore_screen(self): 424 | curses.initscr() 425 | curses.nocbreak() 426 | curses.echo() 427 | curses.endwin() 428 | 429 | # catch any weird termination situations 430 | def __del__(self): 431 | self.restore_screen() 432 | 433 | 434 | if __name__ == '__main__': 435 | parser = OptionParser() 436 | parser.add_option('-c', '--config', help='use specified config file instead of ~/.ssh_hosts') 437 | options, args = parser.parse_args(sys.argv) 438 | host_file = os.path.expanduser('~/.ssh_hosts') 439 | 440 | if options.config is not None: 441 | host_file = options.config 442 | if not os.path.exists(host_file): 443 | print >>sys.stderr,'~/.ssh_hosts is not found, create it' 444 | fp = open(host_file, 'w') 445 | fp.close() 446 | 447 | sshgo = SSHGO(host_file) 448 | -------------------------------------------------------------------------------- /sshgo3.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import os,sys,re 5 | import curses 6 | import locale 7 | import math 8 | import traceback 9 | from optparse import OptionParser 10 | 11 | locale.setlocale(locale.LC_ALL, 'en_US') 12 | 13 | def _assert(exp, err): 14 | if not exp: 15 | print(err, file=sys.stderr) 16 | sys.exit(1) 17 | 18 | 19 | class SSHGO: 20 | 21 | UP = -1 22 | DOWN = 1 23 | 24 | KEY_O = 79 25 | KEY_R = 82 26 | KEY_G = 71 27 | KEY_o = 111 28 | KEY_r = 114 29 | KEY_g = 103 30 | KEY_c = 99 31 | KEY_C = 67 32 | KEY_m = 109 33 | KEY_M = 77 34 | KEY_d = 0x64 35 | KEY_u = 0x75 36 | KEY_SPACE = 32 37 | KEY_ENTER = 10 38 | KEY_q = 113 39 | KEY_ESC = 27 40 | 41 | KEY_j = 106 42 | KEY_k = 107 43 | 44 | KEY_SPLASH = 47 45 | 46 | screen = None 47 | 48 | def _parse_tree_from_config_file(self, config_file): 49 | tree = {'line_number':None,'expanded':True,'line':None,'sub_lines':[]} 50 | 51 | def find_parent_line(new_node): 52 | line_number = new_node['line_number'] 53 | level = new_node['level'] 54 | 55 | if level == 0: 56 | return tree 57 | 58 | stack = tree['sub_lines'] + [] 59 | parent = None 60 | while len(stack): 61 | node = stack.pop() 62 | if node['line_number'] < line_number and node['level'] == level - 1: 63 | if parent is None: 64 | parent = node 65 | elif node['line_number'] > parent['line_number']: 66 | parent = node 67 | if len(node['sub_lines']) and node['level'] < level: 68 | stack = stack + node['sub_lines'] 69 | continue 70 | 71 | return parent 72 | 73 | tree_level = None 74 | nodes_pool = [] 75 | line_number = 0; 76 | 77 | 78 | for line in open(config_file, 'r'): 79 | line_number += 1 80 | line_con = line.strip() 81 | if line_con == '' or line_con[0] == '#': 82 | continue 83 | expand = True 84 | if line_con[:2] == '- ': 85 | line_con = line_con[2:] 86 | expand = False 87 | indent = re.findall(r'^[\t ]*(?=[^\t ])', line)[0] 88 | line_level = indent.count(' ') + indent.count('\t') 89 | if tree_level == None: 90 | _assert(line_level == 0, 'invalid indent,line:' + str(line_number)) 91 | else: 92 | _assert(line_level <= tree_level 93 | or line_level == tree_level + 1, 'invalid indent,line:' + str(line_number)) 94 | tree_level = line_level 95 | 96 | new_node = {'level':tree_level,'expanded':expand,'line_number':line_number,'line':line_con,'sub_lines':[]} 97 | nodes_pool.append(new_node) 98 | parent = find_parent_line(new_node) 99 | parent['sub_lines'].append(new_node) 100 | 101 | return tree, nodes_pool 102 | 103 | 104 | def __init__(self, config_file): 105 | 106 | self.hosts_tree, self.hosts_pool = self._parse_tree_from_config_file(config_file) 107 | 108 | self.screen = curses.initscr() 109 | curses.noecho() 110 | curses.cbreak() 111 | curses.curs_set(0) 112 | self.screen.keypad(1) 113 | self.screen.border(0) 114 | 115 | self.top_line_number = 0 116 | self.highlight_line_number = 0 117 | self.search_keyword = None 118 | 119 | curses.start_color() 120 | curses.use_default_colors() 121 | 122 | #highlight 123 | curses.init_pair(2, curses.COLOR_WHITE, curses.COLOR_BLUE) 124 | self.COLOR_HIGHLIGHT = 2 125 | #red 126 | curses.init_pair(3, curses.COLOR_RED, -1) 127 | self.COLOR_RED = 3 128 | 129 | #red highlight 130 | curses.init_pair(4, curses.COLOR_RED, curses.COLOR_BLUE) 131 | self.COLOR_RED_HIGH = 4 132 | 133 | #white bg 134 | curses.init_pair(5, curses.COLOR_BLACK, curses.COLOR_WHITE) 135 | self.COLOR_WBG = 5 136 | 137 | #black bg 138 | curses.init_pair(6, curses.COLOR_BLACK, curses.COLOR_BLACK) 139 | self.COLOR_BBG = 6 140 | 141 | try: 142 | self.run() 143 | except SystemExit: 144 | self.restore_screen() 145 | pass 146 | except: 147 | self.restore_screen() 148 | traceback.print_exc() 149 | 150 | 151 | def run(self): 152 | while True: 153 | self.render_screen() 154 | c = self.screen.getch() 155 | if c == curses.KEY_UP or c == self.KEY_k: 156 | self.updown(-1) 157 | elif c == curses.KEY_DOWN or c == self.KEY_j: 158 | self.updown(1) 159 | elif c == self.KEY_u: 160 | for i in range(0, curses.tigetnum('lines')): 161 | self.updown(-1) 162 | elif c == self.KEY_d: 163 | for i in range(0, curses.tigetnum('lines')): 164 | self.updown(1) 165 | elif c == self.KEY_ENTER or c == self.KEY_SPACE: 166 | self.toggle_node() 167 | elif c == self.KEY_ESC or c == self.KEY_q: 168 | self.exit() 169 | elif c == self.KEY_O or c == self.KEY_M: 170 | self.open_all() 171 | elif c == self.KEY_o or c == self.KEY_m: 172 | self.open_node() 173 | elif c == self.KEY_C or c == self.KEY_R: 174 | self.close_all() 175 | elif c == self.KEY_c or c == self.KEY_r: 176 | self.close_node() 177 | elif c == self.KEY_g: 178 | self.page_top() 179 | elif c == self.KEY_G: 180 | self.page_bottom() 181 | elif c == self.KEY_SPLASH: 182 | self.enter_search_mode() 183 | 184 | def exit(self): 185 | if self.search_keyword is not None: 186 | self.search_keyword = None 187 | else: 188 | sys.exit(0) 189 | 190 | def enter_search_mode(self): 191 | screen_cols = curses.tigetnum('cols') 192 | self.screen.addstr(0, 0, '/' + ' ' * screen_cols) 193 | curses.echo() 194 | curses.curs_set(1) 195 | self.search_keyword = self.screen.getstr(0, 1).decode('utf-8') 196 | curses.noecho() 197 | curses.curs_set(0) 198 | 199 | def _get_visible_lines_for_render(self): 200 | lines = [] 201 | stack = self.hosts_tree['sub_lines'] + [] 202 | while len(stack): 203 | node = stack.pop() 204 | lines.append(node) 205 | if node['expanded'] and len(node['sub_lines']): 206 | stack = stack + node['sub_lines'] 207 | 208 | lines.sort(key=lambda n:n['line_number'], reverse=False) 209 | return lines 210 | 211 | def _search_node(self): 212 | rt = [] 213 | keyword = self.search_keyword.lower() 214 | for node in self.hosts_pool: 215 | if len(node['sub_lines']) == 0 and keyword in node['line'].lower(): 216 | rt.append(node) 217 | return rt 218 | 219 | def get_lines(self): 220 | if self.search_keyword is not None: 221 | return self._search_node() 222 | else: 223 | return self._get_visible_lines_for_render() 224 | 225 | def page_top(self): 226 | self.top_line_number = 0 227 | self.highlight_line_number = 0 228 | 229 | def page_bottom(self): 230 | screen_lines = curses.tigetnum('lines') 231 | visible_hosts = self.get_lines() 232 | self.top_line_number = max(len(visible_hosts) - screen_lines, 0) 233 | self.highlight_line_number = min(screen_lines, len(visible_hosts)) - 1 234 | 235 | def open_node(self): 236 | visible_hosts = self.get_lines() 237 | linenum = self.top_line_number + self.highlight_line_number 238 | node = visible_hosts[linenum] 239 | if not len(node['sub_lines']): 240 | return 241 | stack = [node] 242 | while len(stack): 243 | node = stack.pop() 244 | node['expanded'] = True 245 | if len(node['sub_lines']): 246 | stack = stack + node['sub_lines'] 247 | 248 | def close_node(self): 249 | visible_hosts = self.get_lines() 250 | linenum = self.top_line_number + self.highlight_line_number 251 | node = visible_hosts[linenum] 252 | if not len(node['sub_lines']): 253 | return 254 | stack = [node] 255 | while len(stack): 256 | node = stack.pop() 257 | node['expanded'] = False 258 | if len(node['sub_lines']): 259 | stack = stack + node['sub_lines'] 260 | 261 | 262 | def open_all(self): 263 | for node in self.hosts_pool: 264 | if len(node['sub_lines']): 265 | node['expanded'] = True 266 | 267 | def close_all(self): 268 | for node in self.hosts_pool: 269 | if len(node['sub_lines']): 270 | node['expanded'] = False 271 | 272 | def toggle_node(self): 273 | visible_hosts = self.get_lines() 274 | linenum = self.top_line_number + self.highlight_line_number 275 | node = visible_hosts[linenum] 276 | if len(node['sub_lines']): 277 | node['expanded'] = not node['expanded'] 278 | else: 279 | self.restore_screen() 280 | ssh = 'ssh' 281 | if os.popen('which zssh 2> /dev/null').read().strip() != '': 282 | ssh = 'zssh' 283 | cmd = node['line'].split('#')[0] 284 | os.execvp(ssh, [ssh] + re.split(r'[ ]+', cmd)) 285 | 286 | def render_screen(self): 287 | # clear screen 288 | self.screen.clear() 289 | 290 | # now paint the rows 291 | screen_lines = curses.tigetnum('lines') 292 | screen_cols = curses.tigetnum('cols') 293 | 294 | if self.highlight_line_number >= screen_lines: 295 | self.highlight_line_number = screen_lines - 1 296 | 297 | all_nodes = self.get_lines() 298 | if self.top_line_number >= len(all_nodes): 299 | self.top_line_number = 0 300 | 301 | top = self.top_line_number 302 | bottom = self.top_line_number + screen_lines 303 | nodes = all_nodes[top:bottom] 304 | 305 | if not len(nodes): 306 | self.screen.refresh() 307 | return 308 | 309 | if self.highlight_line_number >= len(nodes): 310 | self.highlight_line_number = len(nodes) - 1 311 | 312 | if self.top_line_number >= len(all_nodes): 313 | self.top_line_number = 0 314 | 315 | for (index,node,) in enumerate(nodes): 316 | #linenum = self.top_line_number + index 317 | 318 | line = node['line'] 319 | if len(node['sub_lines']): 320 | line += '(%d)' % len(node['sub_lines']) 321 | 322 | prefix = '' 323 | if self.search_keyword is None: 324 | prefix += ' ' * node['level'] 325 | if len(node['sub_lines']): 326 | if node['expanded']: 327 | prefix += '-' 328 | else: 329 | prefix += '+' 330 | else: 331 | prefix += 'o' 332 | prefix += ' ' 333 | 334 | # highlight current line 335 | if index != self.highlight_line_number: 336 | self.screen.addstr(index, 0, prefix, curses.color_pair(self.COLOR_RED)) 337 | self.screen.addstr(index, len(prefix), line) 338 | else: 339 | self.screen.addstr(index, 0, prefix, curses.color_pair(self.COLOR_RED_HIGH)) 340 | self.screen.addstr(index, len(prefix), line, curses.color_pair(self.COLOR_HIGHLIGHT)) 341 | #render scroll bar 342 | for i in range(screen_lines): 343 | self.screen.addstr(i, screen_cols - 2, '|', curses.color_pair(self.COLOR_WBG)) 344 | 345 | scroll_top = int(math.ceil((self.top_line_number + 1.0) / max(len(all_nodes), screen_lines) * screen_lines - 1)) 346 | scroll_height = int(math.ceil((len(nodes) + 0.0) / len(all_nodes) * screen_lines)) 347 | highlight_pos = int(math.ceil(scroll_height * ((self.highlight_line_number + 1.0)/min(screen_lines, len(nodes))))) 348 | 349 | self.screen.addstr(scroll_top, screen_cols - 2, '^', curses.color_pair(self.COLOR_WBG)) 350 | self.screen.addstr(min(screen_lines, scroll_top + scroll_height) - 1, screen_cols - 2, 'v', curses.color_pair(self.COLOR_WBG)) 351 | self.screen.addstr(min(screen_lines, scroll_top + highlight_pos) - 1, screen_cols - 2, '+', curses.color_pair(self.COLOR_WBG)) 352 | 353 | 354 | self.screen.refresh() 355 | 356 | # move highlight up/down one line 357 | def updown(self, increment): 358 | visible_hosts = self.get_lines() 359 | visible_lines_count = len(visible_hosts) 360 | next_line_number = self.highlight_line_number + increment 361 | 362 | # paging 363 | if increment < 0 and self.highlight_line_number == 0 and self.top_line_number != 0: 364 | self.top_line_number += self.UP 365 | return 366 | elif increment > 0 and next_line_number == curses.tigetnum('lines') and (self.top_line_number+curses.tigetnum('lines')) != visible_lines_count: 367 | self.top_line_number += self.DOWN 368 | return 369 | 370 | # scroll highlight line 371 | if increment < 0 and (self.top_line_number != 0 or self.highlight_line_number != 0): 372 | self.highlight_line_number = next_line_number 373 | elif increment > 0 and (self.top_line_number+self.highlight_line_number+1) != visible_lines_count and self.highlight_line_number != curses.tigetnum('lines'): 374 | self.highlight_line_number = next_line_number 375 | 376 | def restore_screen(self): 377 | curses.initscr() 378 | curses.nocbreak() 379 | curses.echo() 380 | curses.endwin() 381 | 382 | if __name__ == '__main__': 383 | parser = OptionParser() 384 | parser.add_option('-c', '--config', help='use specified config file instead of ~/.ssh_hosts') 385 | options, args = parser.parse_args(sys.argv) 386 | host_file = os.path.expanduser('~/.ssh_hosts') 387 | 388 | if options.config is not None: 389 | host_file = options.config 390 | 391 | if not os.path.exists(host_file): 392 | print("%s is not found" % host_file, file=sys.stderr) 393 | sys.exit(1) 394 | 395 | sshgo = SSHGO(host_file) 396 | --------------------------------------------------------------------------------