termdump 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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