appear 1.1.1 → 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,127 @@
1
+ require 'appear/service'
2
+ require 'appear/constants'
3
+
4
+ module Appear
5
+ # A base service that retrieves a usable Terminal instance, suitable for
6
+ # spawning command-line programs.
7
+ class Terminals
8
+ def initialize(terminals)
9
+ @terminals = terminals
10
+ end
11
+
12
+ # Get the Terminal instance
13
+ #
14
+ # @return [Terminal::MacTerminal]
15
+ def get
16
+ current || default
17
+ end
18
+
19
+ private
20
+
21
+ def current
22
+ @terminals.find { |t| t.running? }
23
+ end
24
+
25
+ def default
26
+ # TODO: select terminals based on OS support or should that happen
27
+ # upstream? in the creator of the Terminals instance? No. It should
28
+ # happen here.
29
+ @terminals.find { |t| t.class == TerminalApp }
30
+ end
31
+ end
32
+
33
+ # This module contains Terminal service implementations. So far, we're only
34
+ # concerned with Mac usrs, so you can look at {MacTerminal} to read the
35
+ # interface.
36
+ module Terminal
37
+ # Base class for mac terminal support
38
+ class MacTerminal < Service
39
+ require_service :mac_os
40
+ require_service :processes
41
+
42
+ # @abstract subclasses must implement this method.
43
+ # @return [String] "app name" on OS X. Both the process name of processes
44
+ # of this app, and the name used by Applescript to send events to this
45
+ # app.
46
+ def app_name
47
+ raise NotImplemented
48
+ end
49
+
50
+ # @return [Boolean] true if the app has a running process, false
51
+ # otherwise.
52
+ def running?
53
+ services.processes.pgrep(app_name).length > 0
54
+ end
55
+
56
+ # Enumerate the panes (seperate interactive sessions) that this terminal
57
+ # program has.
58
+ #
59
+ # @abstract subclasses must implement this method.
60
+ # @return [Array<#tty>] any objects with a tty field
61
+ def panes
62
+ raise NotImplemented
63
+ end
64
+
65
+ # Reveal a pane. Subclasses must implement this method.
66
+ #
67
+ # @abstract subclasses must implement this method.
68
+ # @param pane [#tty] any object with a tty field
69
+ def reveal_pane(pane)
70
+ raise NotImplemented
71
+ end
72
+ end # MacTerminal
73
+
74
+ # TerminalApp support service.
75
+ class TerminalApp < MacTerminal
76
+ # @see MacTerminal#app_name
77
+ def app_name
78
+ 'Terminal'
79
+ end
80
+
81
+ # @see MacTerminal#panes
82
+ def panes
83
+ pids = services.processes.pgrep(app_name)
84
+ services.mac_os.call_method('terminal_panes').map do |hash|
85
+ hash[:pids] = pids
86
+ OpenStruct.new(hash)
87
+ end
88
+ end
89
+
90
+ # @see MacTerminal#reveal_pane
91
+ def reveal_pane(pane)
92
+ services.mac_os.call_method('terminal_reveal_tty', pane.tty)
93
+ end
94
+ end
95
+
96
+ # Iterm2 support service.
97
+ class Iterm2 < MacTerminal
98
+ # @see MacTerminal#app_name
99
+ def app_name
100
+ 'iTerm2'
101
+ end
102
+
103
+ # @see MacTerminal#panes
104
+ def panes
105
+ pids = services.processes.pgrep(app_name)
106
+ services.mac_os.call_method('iterm2_panes').map do |hash|
107
+ hash[:pids] = pids
108
+ OpenStruct.new(hash)
109
+ end
110
+ end
111
+
112
+ # @see MacTerminal#reveal_pane
113
+ def reveal_pane(pane)
114
+ services.mac_os.call_method('iterm2_reveal_tty', pane.tty)
115
+ end
116
+
117
+ # Create a new window running the given command.
118
+ #
119
+ # @param command_str [String] command to run
120
+ # @return [#tty] pane
121
+ def new_window(command_str)
122
+ res = services.mac_os.call_method('iterm2_new_window', command_str)
123
+ OpenStruct.new(res)
124
+ end
125
+ end
126
+ end
127
+ end
data/lib/appear/tmux.rb CHANGED
@@ -1,5 +1,6 @@
1
- require 'ostruct'
2
1
  require 'appear/service'
2
+ require 'appear/util/command_builder'
3
+ require 'appear/util/value_class'
3
4
 
4
5
  module Appear
5
6
  # The Tmux service is in charge of interacting with `tmux` processes. It is
@@ -11,67 +12,312 @@ module Appear
11
12
  class Tmux < Service
12
13
  delegate :run, :runner
13
14
 
15
+ # Base value class for Tmux values. This class works in concert with the
16
+ # Tmux service to make a fluent Tmux API easy.
17
+ #
18
+ # TmuxValues all have a reference to the Tmux service that created them, so
19
+ # that they can implement methods that proxy Tmux service interaction.
20
+ class TmuxValue < ::Appear::Util::ValueClass
21
+ # @return [Tmux] the tmux service that created this pane
22
+ property :tmux
23
+
24
+ # @option opts [Symbol] :tmux tmux format string name of this attribute
25
+ # @option opts [#to_proc] :parse proc taking a String (read from tmux) and
26
+ # returns the type-coerced version of this field. A symbol can be used,
27
+ # just like with usual block syntax.
28
+ def self.property(name, opts = {})
29
+ var = super(name, opts)
30
+ @tmux_attrs ||= {}
31
+ @tmux_attrs[var] = opts if opts[:tmux]
32
+ end
33
+
34
+ # The format string we pass to Tmux when we expect a result of this
35
+ # class's type. This format string should cause Tmux to return a value we
36
+ # can hand to {self.parse}
37
+ #
38
+ # @return [String]
39
+ def self.format_string
40
+ result = ""
41
+ @tmux_attrs.each do |var, opts|
42
+ next unless opts[:tmux]
43
+ part = ' ' + var.to_s + ':#{' + opts[:tmux].to_s + '}'
44
+ result += part
45
+ end
46
+ result
47
+ end
48
+
49
+ # Parse a raw data has as returned by the {Tmux} service into an instance
50
+ # of this class.
51
+ #
52
+ # @param tmux_hash [Hash]
53
+ # @param tmux [Tmux] the tmux service
54
+ def self.parse(tmux_hash, tmux)
55
+ result = { :tmux => tmux }
56
+ tmux_hash.each do |var, tmux_val|
57
+ parser = @tmux_attrs[var][:parse]
58
+ if parser
59
+ result[var] = parser.to_proc.call(tmux_val)
60
+ else
61
+ result[var] = tmux_val
62
+ end
63
+ end
64
+ self.new(result)
65
+ end
66
+ end
67
+
68
+ # A tmux pane.
69
+ class Pane < TmuxValue
70
+ # @return [Fixnum] pid of the process running in the pane
71
+ property :pid, tmux: :pane_pid, parse: :to_i
72
+
73
+ # @return [String] session name
74
+ property :session, tmux: :session_name
75
+
76
+ # @return [Fixnum] window index
77
+ property :window, tmux: :window_index, parse: :to_i
78
+
79
+ # @return [Fixnum] pane index
80
+ property :pane, tmux: :pane_index, parse: :to_i
81
+
82
+ # @return [Boolean] is this pane the active pane in this session
83
+ property :active?, var: :active, tmux: :pane_active, parse: proc {|a| a.to_i != 0 }
84
+
85
+ # @return [String] command running in this pane
86
+ property :command_name, tmux: :pane_current_command
87
+
88
+ # @return [String] pane current path
89
+ property :current_path, tmux: :pane_current_path
90
+
91
+ # @return [String] window id
92
+ property :id, :tmux => :pane_id
93
+
94
+ # String suitable for use as the "target" specifier for a Tmux command
95
+ #
96
+ # @return [String]
97
+ def target
98
+ # "#{session}:#{window}.#{pane}"
99
+ id
100
+ end
101
+
102
+ # Split this pane
103
+ #
104
+ # @param opts [Hash]
105
+ def split(opts = {})
106
+ tmux.split_window(opts.merge(:t => target))
107
+ end
108
+
109
+ # Reveal this pane
110
+ def reveal
111
+ tmux.reveal_pane(self)
112
+ end
113
+
114
+ # Send keys to this pane
115
+ #
116
+ # @param keys [String]
117
+ # @param opts [Hash]
118
+ def send_keys(keys, opts = {})
119
+ tmux.send_keys(self, keys, opts)
120
+ end
121
+ end
122
+
123
+ # A tmux session.
124
+ # Has many windows.
125
+ class Session < TmuxValue
126
+ # @return [String] session name
127
+ property :session, tmux: :session_name
128
+
129
+ # @return [String] tmux id of this session
130
+ property :id, :tmux => :session_id
131
+
132
+ # @return [Fixnum] number of clients attached to this session
133
+ property :attached, :tmux => :session_attached, :parse => :to_i
134
+
135
+ # @return [Fixnum] width, in text columns
136
+ property :width, :tmux => :session_width, :parse => :to_i
137
+
138
+ # @return [Fixnum] height, in text rows
139
+ property :height, :tmux => :session_height, :parse => :to_i
140
+
141
+ # String suitable for use as the "target" specifier for a Tmux command
142
+ #
143
+ # @return [String]
144
+ def target
145
+ # session
146
+ id
147
+ end
148
+
149
+ # @return [Array<Window>] the windows in this session
150
+ def windows
151
+ tmux.windows.select { |w| w.session == session }
152
+ end
153
+
154
+ # @return [Array<Client>] all clients attached to this session
155
+ def clients
156
+ tmux.clients.select { |c| c.session == session }
157
+ end
158
+
159
+ # Create a new window in this session. By default, the window will be
160
+ # created at the end of the session.
161
+ #
162
+ # @param opts [Hash]
163
+ def new_window(opts = {})
164
+ win = windows.last.window || -1
165
+ tmux.new_window(opts.merge(:t => "#{target}:#{win + 1}"))
166
+ end
167
+ end
168
+
169
+ # A tmux window.
170
+ # Has many panes.
171
+ class Window < TmuxValue
172
+ # @return [String] session name
173
+ property :session, :tmux => :session_name
174
+
175
+ # @return [Fixnum] window index
176
+ property :window, :tmux => :window_index, :parse => :to_i
177
+
178
+ # @return [String] window id
179
+ property :id, :tmux => :window_id
180
+
181
+ # @return [Boolean] is the window active?
182
+ property :active?,
183
+ :tmux => :window_active,
184
+ :var => :active,
185
+ :parse => proc {|b| b.to_i != 0}
186
+
187
+ # @return [Array<Pane>]
188
+ def panes
189
+ tmux.panes.select { |p| p.session == session && p.window == window }
190
+ end
191
+
192
+ # String suitable for use as the "target" specifier for a Tmux command
193
+ #
194
+ # @return [String]
195
+ def target
196
+ # "#{session}:#{window}"
197
+ id
198
+ end
199
+ end
200
+
201
+ # A tmux client.
202
+ class Client < TmuxValue
203
+ # @return [String] path to the TTY device of this client
204
+ property :tty, :tmux => :client_tty
205
+
206
+ # @return [String] term name
207
+ property :term, :tmux => :client_termname
208
+
209
+ # @return [String] session name
210
+ property :session, :tmux => :client_session
211
+
212
+ # String suitable for use as the "target" specifier for a Tmux command
213
+ #
214
+ # @return [String]
215
+ def target
216
+ tty
217
+ end
218
+ end
219
+
220
+ def initialize(svcs = {})
221
+ super(svcs)
222
+ @memo = ::Appear::Util::Memoizer.new
223
+ end
224
+
225
+ # List all the tmux clients on the system
226
+ #
227
+ # @return [Array<Client>]
14
228
  def clients
15
- ipc([
16
- 'list-clients',
17
- '-F',
18
- format_string(
19
- :tty => :client_tty,
20
- :term => :client_termname,
21
- :session => :client_session
22
- ),
23
- ])
229
+ ipc_returning(command('list-clients'), Client)
24
230
  end
25
231
 
232
+ # List all the tmux panes on the system
233
+ #
234
+ # @return [Array<Pane>]
26
235
  def panes
27
- panes = ipc([
28
- 'list-panes',
29
- '-a',
30
- '-F',
31
- format_string(
32
- :pid => :pane_pid,
33
- :session => :session_name,
34
- :window => :window_index,
35
- :pane => :pane_index,
36
- :command_name => :pane_current_command,
37
- :active => :pane_active)
38
- ])
236
+ ipc_returning(command('list-panes').flags(:a => true), Pane)
237
+ end
39
238
 
40
- panes.each do |pane|
41
- pane.window = pane.window.to_i
42
- pane.pid = pane.pid.to_i
43
- pane.active = pane.active.to_i != 0
44
- end
239
+ # List all the tmux sessions on the system
240
+ #
241
+ # @return [Array<Session>]
242
+ def sessions
243
+ ipc_returning(command('list-sessions'), Session)
244
+ end
45
245
 
46
- panes
246
+ # List all the tmux windows in any session on the system
247
+ #
248
+ # @return [Array<Window>]
249
+ def windows
250
+ ipc_returning(command('list-windows').flags(:a => true), Window)
47
251
  end
48
252
 
253
+ # Reveal a pane in tmux.
254
+ #
255
+ # @param pane [Pane] a pane
49
256
  def reveal_pane(pane)
50
- ipc(['select-pane', '-t', "#{pane.session}:#{pane.window}.#{pane.pane}"])
51
- ipc(['select-window', '-t', "#{pane.session}:#{pane.window}"])
257
+ ipc(command('select-pane').flags(:t => pane.target))
258
+ # TODO: how do we use a real target for this?
259
+ ipc(command('select-window').flags(:t => "#{pane.session}:#{pane.window}"))
260
+ pane
261
+ end
262
+
263
+ # Create a new window
264
+ def new_window(opts = {})
265
+ ipc_returning_one(command('new-window').flags(opts), Window)
266
+ end
267
+
268
+ # Split a window
269
+ def split_window(opts = {})
270
+ ipc_returning_one(command('split-window').flags(opts), Pane)
271
+ end
272
+
273
+ # Create a new session
274
+ def new_session(opts = {})
275
+ ipc_returning_one(command('new-session').flags(opts), Session)
276
+ end
277
+
278
+ # Send keys to a pane
279
+ def send_keys(pane, keys, opts = {})
280
+ ipc(command('send-keys').flags(opts.merge(:t => pane.target)).args(*keys))
281
+ end
282
+
283
+ # Construct a command that will attach the given session when run
284
+ #
285
+ # @param session [String] use Session#target
286
+ # @return [Appear::Util::CommandBuilder]
287
+ def attach_session_command(session)
288
+ command('attach-session').flags(:t => session)
52
289
  end
53
290
 
54
291
  private
55
292
 
56
- def ipc(args)
57
- res = run(['tmux'] + args)
293
+ def command(subcommand)
294
+ Appear::Util::CommandBuilder.new(['tmux', subcommand])
295
+ end
296
+
297
+ def ipc(cmd)
298
+ res = run(cmd.to_a)
58
299
  res.lines.map do |line|
59
300
  info = {}
60
301
  line.strip.split(' ').each do |pair|
61
302
  key, *value = pair.split(':')
62
303
  info[key.to_sym] = value.join(':')
63
304
  end
64
- OpenStruct.new(info)
305
+ info
65
306
  end
66
307
  end
67
308
 
68
- def format_string(spec)
69
- result = ""
70
- spec.each do |key, value|
71
- part = ' ' + key.to_s + ':#{' + value.to_s + '}'
72
- result += part
309
+ def ipc_returning(cmd, klass)
310
+ @memo.call(cmd, klass) do
311
+ cmd.flags(:F => klass.format_string)
312
+ ipc(cmd).map do |row|
313
+ klass.parse(row, self)
314
+ end
73
315
  end
74
- result
316
+ end
317
+
318
+ def ipc_returning_one(cmd, klass)
319
+ # -P in tmux is usually required to print information about newly created objects
320
+ ipc_returning(cmd.flags(:P => true), klass).first
75
321
  end
76
322
  end
77
323
  end
@@ -0,0 +1,148 @@
1
+ module Appear
2
+ module Util
3
+ # Builds command strings.
4
+ #
5
+ # @example A tmux query command
6
+ # tmux_panes = CommandBuilder.new(%w(tmux list-panes)).
7
+ # flags(:a => true, :F => '#{session_name} #{pane_index}')
8
+ # output, status = Open3.capture2e(*tmux_panes.to_a)
9
+ class CommandBuilder
10
+ # @param command [#to_s, Array<#to_s>] the command. Use an array if you
11
+ # need multiple words before we start listing arguments, eg `%w(vagrant
12
+ # up)`
13
+ #
14
+ # @param opts [Hash] options hash
15
+ # @option opts [Boolean] :single_dash_long_flags When true, flags like
16
+ # :foo will be printed like "-foo" instead of the default "--foo"
17
+ # @option opts [Boolean] :dashdash_after_flags When true, a "--" argument
18
+ # will be inserted after the flags but before the arguments.
19
+ #
20
+ # @example dashdash_after_flags
21
+ # c = CommandBuilder.new('ssh', :dashdash_after_flags => true)
22
+ # .flags(:foo => 1, :b => true).args('a', 'b').to_s
23
+ # "ssh --foo 1 -b -- a b"
24
+ def initialize(command, opts = {})
25
+ @command = command
26
+ @flags = opts.delete(:flags) || Hash.new { |h, k| h[k] = [] }
27
+ @argv = opts.delete(:argv) || []
28
+ @options = {
29
+ :single_dash_long_flags => false,
30
+ :dashdash_after_flags => false,
31
+ }.merge(opts)
32
+ end
33
+
34
+ # Add a flag to this command
35
+ #
36
+ # @param name [#to_s] flag name, eg 'cached' for --cached
37
+ # @param val [Boolean, #to_s] flag value, eg '3fdb21'. Can pass "true"
38
+ # for boolean, set-only flags.
39
+ # @return [self]
40
+ def flag(name, val)
41
+ @flags[name] << val
42
+ self
43
+ end
44
+
45
+ # Add a bunch of flags at once, using a map of flag => argument.
46
+ #
47
+ # @param flag_map [Hash<#to_s, [TrueClass, #to_s, Array<#to_s>]>]
48
+ # @return [self]
49
+ #
50
+ # @example multiple duplicate args
51
+ # CommandBuilder.new('foo').flags(:o => ['Val1', 'Val2]).to_s
52
+ # # "foo -o Val1 -o Val2"
53
+ def flags(flag_map)
54
+ flag_map.each do |f, v|
55
+ if v.is_a?(Array)
56
+ v.each do |v_prime|
57
+ flag(f, v_prime)
58
+ end
59
+ else
60
+ flag(f, v)
61
+ end
62
+ end
63
+ self
64
+ end
65
+
66
+ # Add arguments to this command. Arguments always come after flags, and
67
+ # may be seperated from flags with -- if you pass :dashdash_after_flags
68
+ # option in the constructor.
69
+ #
70
+ # @param args [Array<#to_s>] args to add
71
+ # @return [self]
72
+ def args(*args)
73
+ @argv.concat(args)
74
+ self
75
+ end
76
+
77
+ # Add a subcommand, with its own flags arguments, after the current
78
+ # command. This is useful for eg building calls to nested commands.
79
+ #
80
+ # @param name [#to_s, Array<#to_s>] the subcommand, see {#initialize}
81
+ # @param opts [Hash] see {#initialize}
82
+ # @yield [subc] Add flags and arguments to the subcommand
83
+ # @yieldparam [CommandBuilder] subc the subcommand
84
+ # @return [self]
85
+ #
86
+ # @example eg, vagrant
87
+ # v_up = CommandBuilder.new('vagrant').flags(:root => pwd).subcommand('up') do |up|
88
+ # up.flags(:provider => :virtualbox', 'no-provision' => true).args('apollo')
89
+ # end
90
+ def subcommand(name, opts = {})
91
+ # use our options as the defaults
92
+ # then use the given options as the overrides
93
+ subc = CommandBuilder.new(name, @options.merge(opts))
94
+ yield(subc)
95
+ args(*subc.to_a)
96
+ self
97
+ end
98
+
99
+ # Render this command to an array of strings, suitable for execution with
100
+ # `system` or other methods that take an ARGV array.
101
+ #
102
+ # @return [Array<String>] the command
103
+ def to_a
104
+ res = [@command].flatten
105
+ @flags.each do |name, params|
106
+ flag = flag_name_to_arg(name)
107
+ params.each do |param|
108
+ res << flag
109
+ res << param.to_s unless param == true
110
+ end
111
+ end
112
+ res << '--' if @options[:dashdash_after_flags]
113
+ res.concat(@argv)
114
+ res.map { |v| v.to_s }
115
+ end
116
+
117
+ # Render this command as a string, suitable for execution with `sh` or
118
+ # `system` or other methods that take a command string.
119
+ #
120
+ # @return [String] the command
121
+ def to_s
122
+ to_a.shelljoin
123
+ end
124
+
125
+ # Duplicate this {CommandBuilder} instance.
126
+ #
127
+ # @return [CommandBuilder]
128
+ def dup
129
+ opts = @options.dup
130
+ opts[:argv] = @argv.dup
131
+ opts[:flags] = @flags.dup
132
+ self.class.new(@command.dup, opts)
133
+ end
134
+
135
+ private
136
+
137
+ # @param flag [#to_s]
138
+ # @return [String]
139
+ def flag_name_to_arg(flag)
140
+ if flag.to_s.length == 1 || @options[:single_dash_long_flags]
141
+ "-#{flag}"
142
+ else
143
+ "--#{flag}"
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end