termdump 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,474 @@
1
+ require 'fileutils'
2
+ require 'pathname'
3
+ require 'yaml'
4
+
5
+ require 'termdump/process'
6
+ require 'termdump/session'
7
+
8
+ module TermDump
9
+ class SessionSyntaxError < StandardError; end
10
+
11
+ class Main
12
+ BASE_DIR = "#{Dir.home}/.config/termdump/" # only for posix file system
13
+ @@session_dir = BASE_DIR + "session"
14
+ @@config_file = BASE_DIR + "config.yml"
15
+
16
+ def initialize
17
+ @config = read_configure
18
+ end
19
+
20
+ # Read configure from @@config_file if this file exists and return a Hash as result.
21
+ # The configure format is yaml.
22
+ def read_configure
23
+ config = {}
24
+ config = YAML.load(IO.read(@@config_file)) if File.exist? @@config_file
25
+ config
26
+ end
27
+
28
+ # initialize configure and session directory interactively
29
+ def init
30
+ if Dir.exist?(BASE_DIR)
31
+ return puts "The configure has been initialized yet"
32
+ end
33
+ dir = File.dirname(File.realpath(__FILE__)) # for ruby > 2.0.0, use __dir__
34
+ files = File.join(dir, 'terminal', '*.rb')
35
+ support_term = Dir.glob(files).map {|fn| File.basename(fn, '.rb')}
36
+
37
+ puts "Currently support terminals:"
38
+ support_term.each_with_index {|term, i| puts "[#{i}]\t#{term}"}
39
+ print "Select your terminal: "
40
+ choice = $stdin.gets.chomp
41
+ if choice.to_i != 0 || choice == '0'
42
+ choice = choice.to_i
43
+ if 0 <= choice && choice < support_term.size
44
+ print "Will create #{@@config_file} and #{@@session_dir}, go on?[Y/N] "
45
+ answer = $stdin.gets.chomp
46
+ return if answer == 'N' || answer == 'n'
47
+ FileUtils.mkpath @@session_dir
48
+ configure = {
49
+ 'terminal' => support_term[choice]
50
+ }
51
+ IO.write @@config_file, YAML.dump(configure)
52
+ puts "Ok, the configure is initialized now. Happy coding!"
53
+ return
54
+ end
55
+ end
56
+ puts "Incorrect choice received!"
57
+ end
58
+
59
+ # Save the session into session file.
60
+ # If +print_stdout+ is true, print to stdout instead of saving it.
61
+ # If +exclude_current_pty+ is true, save all pty instead of the current one
62
+ def save session_name, print_stdout, exclude_current_pty
63
+ # TODO rewrite it once posixpsutil is mature
64
+ this_pid = ::Process.pid.to_s
65
+ pts = Hash.new {|k, v| []}
66
+ this_terminal_pid = nil
67
+
68
+ IO.popen('ps -eo pid,ppid,stat,tty,command').readlines.each do |entry|
69
+ pid, ppid, stat, tty, command = entry.rstrip.split(' ', 5)
70
+ if tty.start_with?('pts/')
71
+ tty.sub!('pts/', '')
72
+ # order by pid asc
73
+ case pts[tty].size
74
+ when 0
75
+ pts[tty] = [Process.new(pid, ppid, stat, command)]
76
+ else
77
+ session_leader = pts[tty].first
78
+ p = Process.new(pid, ppid, stat, command)
79
+
80
+ # this_terminal_pid -> session_leader_pids ->
81
+ # foreground_pids(exclude this_pid)
82
+ if p.pid == this_pid
83
+ this_terminal_pid = session_leader.ppid
84
+ elsif p.is_child_of(session_leader) && p.is_in_foreground
85
+ pts[tty].push(p)
86
+ end
87
+ end
88
+
89
+ end
90
+ end
91
+
92
+ if exclude_current_pty
93
+ this_ppid = ::Process.ppid.to_s
94
+ pts.reject! do |tty, processes|
95
+ session_leader = processes.first
96
+ session_leader.ppid != this_terminal_pid || session_leader.pid == this_ppid
97
+ end
98
+ else
99
+ pts.reject! {|tty, processes| processes.first.ppid != this_terminal_pid }
100
+ end
101
+ return if pts.empty?
102
+
103
+ # get cwd for each session
104
+ session_cwd = {}
105
+ session_leader_pids = pts.values.map { |processes|
106
+ processes.first.pid
107
+ }.join(' ')
108
+ IO.popen("pwdx #{session_leader_pids}") do |f|
109
+ f.readlines.each do |entry|
110
+ # 1234: /home/xxx/yyy...
111
+ pid, cwd = entry.split(" ", 2)
112
+ pid = pid[0...-1]
113
+ session_cwd[pid] = cwd.rstrip
114
+ end
115
+ end
116
+
117
+ paths = session_cwd.values
118
+ path_prefix = {}
119
+ common_prefix = exact_commom_prefix(paths)
120
+ if common_prefix != ''
121
+ path_prefix['$PROJECT'] = common_prefix
122
+ session_cwd.each_value do |path|
123
+ path.sub!(common_prefix, '${PROJECT}') if path.start_with?(common_prefix)
124
+ end
125
+ end
126
+
127
+ ptree = Hash.new {|k, v| {}}
128
+ pts.each_value do |processes|
129
+ session_leader = processes[0]
130
+ session = ptree[session_leader.ppid]
131
+ processes[0] = session_cwd[session_leader.pid]
132
+ session[session_leader.pid] = processes
133
+ ptree[session_leader.ppid] = session
134
+ end
135
+
136
+ if print_stdout
137
+ print_result ptree, path_prefix
138
+ else
139
+ result = dump ptree, path_prefix
140
+ save_to_file session_name, result
141
+ end
142
+ end
143
+
144
+ # return the common prefix of given paths. If the common prefix is '/' or '~',
145
+ # or there is not common prefix at all, return ''
146
+ def exact_commom_prefix paths
147
+ home = Dir.home
148
+ paths = paths.map do |path|
149
+ new_path = path.sub!(home, '~') if path.start_with?(home)
150
+ # handle '/xxx'
151
+ new_path.nil? ? path.split('/') : new_path.split('/')
152
+ end
153
+ paths.sort! {|x, y| y.size <=> x.size }
154
+ # indicate if we have found common preifx or just ignore all the case
155
+ has_commom_prefix = false
156
+ common_prefix = paths.reduce do |prefix, path|
157
+ common_prefix = prefix
158
+ prefix.each_with_index do |v, i|
159
+ if v != path[i]
160
+ common_prefix = prefix[0...i]
161
+ break
162
+ end
163
+ end
164
+ # common_prefix should longer than '~/' and '/'
165
+ if common_prefix.size > 1
166
+ has_commom_prefix = true
167
+ common_prefix
168
+ else
169
+ # if there is not commom prefix between two path, just ignore it
170
+ prefix
171
+ end
172
+ end
173
+ has_commom_prefix && common_prefix.size > 1 ? common_prefix.join('/') : ''
174
+ end
175
+
176
+ # print the dumped result:
177
+ def print_result ptree, path_prefix
178
+ puts dump(ptree, path_prefix)
179
+ end
180
+
181
+ # dump the process tree in yml with such format:
182
+ # $0:xxx
183
+ # $1:...
184
+ # window0:
185
+ # cwd:
186
+ # tab0:
187
+ # cwd:
188
+ # command:comand
189
+ # window1...
190
+ #
191
+ # return a yml format string
192
+ def dump ptree, path_prefix
193
+ yml_tree = {}
194
+ path_prefix.each_pair do |k, v|
195
+ yml_tree[k] = v
196
+ end
197
+
198
+ win_order = 0
199
+ ptree.each_value do |session|
200
+ order = 0
201
+ tab_tree = {}
202
+ session.each_value do |v|
203
+ if order == 0
204
+ tab_tree['cwd'] = v[0]
205
+ tab_tree["command"] = v[1].command if v.size > 1
206
+ else
207
+ tab_node = {'cwd' => v[0]}
208
+ tab_node["command"] = v[1].command if v.size > 1
209
+ tab_tree["tab#{order-1}"] = tab_node
210
+ end
211
+ order += 1
212
+ end
213
+ yml_tree["window#{win_order}"] = tab_tree
214
+ win_order += 1
215
+ end
216
+ yml_tree.to_yaml
217
+ end
218
+
219
+ # return a Hash with two symbols:
220
+ # :exist => is session already existed
221
+ # :name => the absolute path of session
222
+ def search_session name
223
+ session_name = File.join(@@session_dir, name + ".yml")
224
+ {:exist => File.exist?(session_name), :name => session_name}
225
+ end
226
+
227
+ # save yml format string to a yml file in @@session_dir
228
+ def save_to_file session_name, result
229
+ begin
230
+ if session_name != ''
231
+ status = search_session session_name
232
+ overwrite = false
233
+ if status[:exist]
234
+ print "#{status[:name]} already exists, overwrite?[Y/N]:"
235
+ answer = $stdin.gets.chomp
236
+ overwrite = true if answer == 'Y' || answer == 'y'
237
+ end
238
+ if !status[:exist] || overwrite
239
+ IO.write status[:name], result
240
+ puts "Save session '#{session_name}' successfully"
241
+ return
242
+ end
243
+ end
244
+ print "Enter a new session name:"
245
+ name = $stdin.gets.chomp
246
+ save_to_file name, result
247
+ rescue Errno::ENOENT
248
+ FileUtils.mkpath(@@session_dir) unless File.exist?(@@session_dir)
249
+ save_to_file session_name, result # reentry
250
+ end
251
+ end
252
+
253
+ def list list_action
254
+ begin
255
+ Dir.chdir(@@session_dir)
256
+ sessions = Dir.glob('*.yml')
257
+ if sessions.empty?
258
+ puts "No session exists in #{@@session_dir}"
259
+ else
260
+ puts "order:\tsession name\tctime\t\tatime"
261
+ sessions.sort!{|x, y| File.atime(y) <=> File.atime(x) }
262
+ sessions.each_with_index do |f, i|
263
+ cdate = File.ctime(f).to_date.strftime
264
+ adate = File.atime(f).to_date.strftime
265
+ f.sub!(/\.yml$/, '')
266
+ puts format("[%d]: %15s\t%s\t%s", i, f, cdate, adate)
267
+ end
268
+
269
+ get_input_order = proc do |action, &handler|
270
+ print "Select one session to #{action}:"
271
+ order = $stdin.gets.chomp
272
+ if order.to_i != 0 || order == '0' # can order be an integer?
273
+ order = order.to_i
274
+ if 0 <= order && order < sessions.size
275
+ handler.call sessions[order]
276
+ return
277
+ end
278
+ end
279
+ puts "Received a wrong session order"
280
+ end
281
+
282
+ case list_action
283
+ when :load, :edit, :delete
284
+ get_input_order.call(list_action) { |session|
285
+ send("#{list_action}_session", session) }
286
+ end
287
+ end
288
+ rescue Errno::ENOENT
289
+ FileUtils.mkpath(@@session_dir) unless File.exist?(@@session_dir)
290
+ end
291
+ end
292
+
293
+ def delete_session name
294
+ status = search_session name
295
+ return puts "#{status[:name]} not found" unless status[:exist]
296
+ File.delete status[:name]
297
+ puts "Delete session '#{name}' successfully"
298
+ end
299
+
300
+ def edit_session name
301
+ FileUtils.mkpath(@@session_dir) unless File.exist?(@@session_dir)
302
+ status = search_session name
303
+ return puts "#{status[:name]} not found" unless status[:exist]
304
+ return puts "Set $EDITOR as your favourite editor to edit the session" unless ENV['EDITOR']
305
+ exec ENV['EDITOR'], status[:name]
306
+ end
307
+
308
+ def load_session name
309
+ ptree = load_file name
310
+ if ptree != {}
311
+ begin
312
+ ptree = check ptree
313
+ rescue SessionSyntaxError => e
314
+ puts "Parse session file error: #{e.message}"
315
+ exit 1
316
+ end
317
+ Session.new(@config).replay(ptree)
318
+ end
319
+ end
320
+
321
+ # load the process tree from yml format string
322
+ def load_file name
323
+ status = search_session name
324
+ unless status[:exist]
325
+ puts "#{status[:name]} not found"
326
+ {}
327
+ else
328
+ YAML.load(IO.read(status[:name]))
329
+ end
330
+ end
331
+
332
+ # Raise SessionSyntaxError if their is syntax error in session file.
333
+ # Session file format:
334
+ # $variables...
335
+ # window0:
336
+ # cwd:
337
+ # command:
338
+ # tab0:
339
+ # cwd:
340
+ # vsplit:
341
+ # hsplit:
342
+ # Restriction:
343
+ # 1. There are only four types of node: :window, :tab, :vsplit and :hsplit
344
+ # 2. node level: :window > :tab > :vsplit, :hsplit
345
+ # 3. The root node should be :window
346
+ # 4. The root node should have a cwd attributes.
347
+ # If a node itself does not have cwd attributes, it inherits its parent's
348
+ # 5. Each node has only one 'cwd' and at most one 'command'
349
+ # 6. the type of 'cwd' and 'command' is String
350
+ def check ptree
351
+ # (5) is ensured by yml syntax
352
+ parse_variables ptree
353
+ ptree.each_pair do |k, node|
354
+ check_node node, :window if k.start_with?("window")
355
+ end
356
+ ptree
357
+ end
358
+
359
+ def check_node node, node_type, parent_cwd=''
360
+ unless node.is_a?(Hash)
361
+ raise SessionSyntaxError.new("#{node_type} should be a node")
362
+ end
363
+ if node.has_key?('cwd')
364
+ path = node['cwd']
365
+ unless path.is_a?(String)
366
+ raise SessionSyntaxError.new("'cwd' should be a String")
367
+ end
368
+ unless Pathname.new(path).absolute?
369
+ if parent_cwd == ''
370
+ msg = "missing base working directory for relative path #{path}"
371
+ raise SessionSyntaxError.new(msg)
372
+ end
373
+ path = File.absolute_path(path, parent_cwd)
374
+ node['cwd'] = path
375
+ end
376
+ unless check_cwd_cd_able path
377
+ raise SessionSyntaxError.new("can't cd to #{path}")
378
+ end
379
+ else
380
+ if parent_cwd == ''
381
+ raise SessionSyntaxError.new("'cwd' not found in #{node_type}")
382
+ end
383
+ node['cwd'] = parent_cwd
384
+ end
385
+ cwd = node['cwd']
386
+
387
+ if node.has_key?('command')
388
+ unless node['command'].is_a?(String)
389
+ raise SessionSyntaxError.new("'command' should be a String")
390
+ end
391
+ end
392
+
393
+ remain_attributes = ['cwd', 'command']
394
+ case node_type
395
+ when :window
396
+ node.each_pair do |attr, value|
397
+ if attr.start_with?('window')
398
+ check_node value, :window, cwd
399
+ elsif attr.start_with?('tab')
400
+ check_node value, :tab, cwd
401
+ elsif attr.start_with?('vsplit')
402
+ check_node value, :vsplit, cwd
403
+ elsif attr.start_with?('hsplit')
404
+ check_node value, :hsplit, cwd
405
+ elsif !remain_attributes.include?(attr)
406
+ node.delete attr
407
+ end
408
+ end
409
+ when :tab
410
+ node.each_pair do |attr, value|
411
+ if attr.start_with?('tab')
412
+ check_node value, :tab, cwd
413
+ elsif attr.start_with?('vsplit')
414
+ check_node value, :vsplit, cwd
415
+ elsif attr.start_with?('hsplit')
416
+ check_node value, :hsplit, cwd
417
+ elsif !remain_attributes.include?(attr)
418
+ node.delete attr
419
+ end
420
+ end
421
+ when :vsplit, :hsplit
422
+ node.each_pair do |attr, value|
423
+ if attr.start_with?('vsplit')
424
+ check_node value, :vsplit, cwd
425
+ elsif attr.start_with?('hsplit')
426
+ check_node value, :hsplit, cwd
427
+ elsif !remain_attributes.include?(attr)
428
+ node.delete attr
429
+ end
430
+ end
431
+ end
432
+ end
433
+
434
+ # +path+ is absolute path
435
+ def check_cwd_cd_able path
436
+ Dir.exist?(path) && File.readable?(path)
437
+ end
438
+
439
+ def parse_variables ptree
440
+ # rewrite with tap for ruby > 1.9
441
+ variables = ptree.select {|k, v| k.start_with?('$')}
442
+ ptree.delete_if {|k, v| k.start_with?('$')}
443
+ var = Regexp.new(/(?<!\\) # don't trap in \$
444
+ \$\{
445
+ .*?[^\\] # parse the name until }(but not \})
446
+ \}/mix).freeze
447
+ scan_tree = proc do |node|
448
+ node.each_pair do |k, v|
449
+ if k == 'cwd'
450
+ cwd = node[k]
451
+ cwd.gsub!(var) do |match|
452
+ # match is sth like ${foo}
453
+ name = match[2...-1]
454
+ value = variables['$' + name]
455
+ # Enter value for unknown variables
456
+ if value.nil?
457
+ print "Enter the value of '#{name}':"
458
+ value = $stdin.gets.chomp
459
+ end
460
+ value
461
+ end
462
+ cwd.sub!('~', Dir.home) if cwd == '~' || cwd.start_with?('~/')
463
+ elsif v.is_a?(Hash)
464
+ scan_tree.call v
465
+ end
466
+ end
467
+ end
468
+
469
+ ptree.each_pair {|k, v| scan_tree.call v if k.start_with?('window')}
470
+ end
471
+ end
472
+
473
+ end
474
+
@@ -0,0 +1,19 @@
1
+ module TermDump
2
+ class Process
3
+ attr_accessor :pid, :ppid, :stat, :command
4
+ def initialize pid, ppid, stat, command
5
+ @pid = pid
6
+ @ppid = ppid
7
+ @stat = stat
8
+ @command = command
9
+ end
10
+
11
+ def is_in_foreground
12
+ @stat.start_with?('S') && @stat.include?('+')
13
+ end
14
+
15
+ def is_child_of process
16
+ @ppid == process.pid
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,84 @@
1
+ module TermDump
2
+ Node = Struct.new(:type, :name, :cwd, :command)
3
+
4
+ class Session
5
+ # Optional Configures:
6
+ # * terminal: The type of terminal
7
+ # * new_window: The key sequence used to open a new window
8
+ # * new_tab: The key sequence used to open a new tab
9
+ # * new_vsplit: The key sequence used to open a new vsplit
10
+ # * new_hsplit: The key sequence used to open a new hsplit
11
+ def initialize config={}
12
+ if config.has_key?('terminal')
13
+ begin
14
+ terminal = config['terminal']
15
+ require_relative "terminal/#{terminal}"
16
+ rescue LoadError
17
+ puts "Load with terminal #{terminal} error:"
18
+ puts "Not support #{terminal} yet!"
19
+ exit 0
20
+ end
21
+ else
22
+ require_relative "terminal/base/default"
23
+ end
24
+ @terminal = Terminal.new(config)
25
+ @node_queue = []
26
+ @support_split = Terminal.public_method_defined?(:vsplit) &&
27
+ Terminal.public_method_defined?(:hsplit)
28
+ @support_tab = Terminal.public_method_defined?(:tab)
29
+ end
30
+
31
+ def replay task
32
+ scan task
33
+ fallback
34
+ exec
35
+ end
36
+
37
+ def enqueue type, name, attributes
38
+ @node_queue.push(Node.new(type, name,
39
+ attributes['cwd'], attributes['command']))
40
+ end
41
+
42
+ def scan node
43
+ node.each_pair do |k, v|
44
+ if k.start_with?('tab')
45
+ enqueue :tab, k, v
46
+ elsif k.start_with?('vsplit')
47
+ enqueue :vsplit, k, v
48
+ elsif k.start_with?('hsplit')
49
+ enqueue :hsplit, k, v
50
+ elsif k.start_with?('window')
51
+ enqueue :window, k, v
52
+ end
53
+ scan v if v.is_a?(Hash)
54
+ end
55
+ end
56
+
57
+ def fallback
58
+ unless @support_split
59
+ @node_queue.each_index do |i|
60
+ if @node_queue[i].type == :vsplit || @node_queue[i].type == :hsplit
61
+ @node_queue[i].type = :tab
62
+ end
63
+ end
64
+ end
65
+ unless @support_tab
66
+ @node_queue.each_index { |i|
67
+ @node_queue[i].type = :window if @node_queue[i].type == :tab }
68
+ end
69
+ end
70
+
71
+ def exec
72
+ current_tab = @node_queue.first
73
+ @terminal.exec current_tab.cwd, current_tab.command
74
+ @node_queue.shift
75
+ @node_queue.each do |node|
76
+ case node.type
77
+ when :window, :tab, :vsplit, :hsplit
78
+ @terminal.method(node.type).call(node.name, node.cwd, node.command)
79
+ end
80
+ end
81
+ end
82
+ end
83
+
84
+ end
@@ -0,0 +1,50 @@
1
+ require_relative './terminal_helper'
2
+
3
+ module TermDump
4
+ class BasicTerminal
5
+ include TerminalHelper
6
+
7
+ def initialize config
8
+ @user_defined_config = config
9
+ @config = {}
10
+ @default_config = {}
11
+ end
12
+
13
+ # Get user defined value/configure value/default value with a configure item.
14
+ # Raise keyError if value not found.
15
+ def configure configure_key
16
+ @user_defined_config.fetch(configure_key) {|key_in_config|
17
+ @config.fetch(key_in_config) {|default_key|
18
+ @default_config.fetch(default_key)}}
19
+ end
20
+
21
+ # run command in current window
22
+ # +cwd+ is the directory the command executed in
23
+ # +cmd+ if the cmd is nil, don't need to execute it; else execute the cmd
24
+ def exec cwd, cmd
25
+ raise NotImplementedError.new(
26
+ "exec should be implemented to execute cmd on current window")
27
+ end
28
+
29
+ # open a new window of this terminal automatically and focus on
30
+ # +name+ is the name of new window
31
+ # +cwd+ is the directory the command executed in
32
+ # +cmd+ if the cmd is nil, don't need to execute it; else execute the cmd
33
+ def window name, cwd, cmd
34
+ raise NotImplementedError.new(
35
+ "window should be implemented to open new window")
36
+ end
37
+
38
+ # open a new tab of this terminal automatically and focus on
39
+ # implement it if your terminal support tabs
40
+ # def tab(name, cwd, cmd); end
41
+
42
+ # open a new vertical split of this terminal automatically and focus on
43
+ # implement it if your terminal support tabs
44
+ # def vsplit(name, cwd, cmd); end
45
+
46
+ # open a new horizontal split of this terminal automatically and focus on
47
+ # implement it if your terminal support tabs
48
+ # def hsplit(name, cwd, cmd); end
49
+ end
50
+ end
@@ -0,0 +1,6 @@
1
+ require_relative 'base'
2
+
3
+ module TermDump
4
+ class Terminal < BasicTerminal
5
+ end
6
+ end
@@ -0,0 +1,50 @@
1
+ require_relative 'base'
2
+
3
+ module TermDump
4
+ class Terminal < BasicTerminal
5
+ attr_reader :done_actions
6
+ def initialize config
7
+ @done_actions = []
8
+ end
9
+
10
+ def exec cwd, cmd
11
+ if cmd.nil?
12
+ @done_actions.push(cwd)
13
+ else
14
+ @done_actions.push(cwd, cmd)
15
+ end
16
+ end
17
+
18
+ def window name, cwd, cmd
19
+ if cmd.nil?
20
+ @done_actions.push(:window, name, cwd)
21
+ else
22
+ @done_actions.push(:window, name, cwd, cmd)
23
+ end
24
+ end
25
+
26
+ def tab name, cwd, cmd
27
+ if cmd.nil?
28
+ @done_actions.push(:tab, name, cwd)
29
+ else
30
+ @done_actions.push(:tab, name, cwd, cmd)
31
+ end
32
+ end
33
+
34
+ def vsplit name, cwd, cmd
35
+ if cmd.nil?
36
+ @done_actions.push(:vsplit, name, cwd)
37
+ else
38
+ @done_actions.push(:vsplit, name, cwd, cmd)
39
+ end
40
+ end
41
+
42
+ def hsplit name, cwd, cmd
43
+ if cmd.nil?
44
+ @done_actions.push(:hsplit, name, cwd)
45
+ else
46
+ @done_actions.push(:hsplit, name, cwd, cmd)
47
+ end
48
+ end
49
+ end
50
+ end