appear 1.1.1 → 1.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,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