heytmux 0.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: cb566cb530c83ebd00f2dcfb707eb0e992593ee7
4
+ data.tar.gz: a0a6b6a585a503d1d5ceab93752345b897c125b2
5
+ SHA512:
6
+ metadata.gz: 3e768bf1f0f205cfe32546253338856ca6d0a6d6742cf8f6ab9244e146be6cf2978823114517cad0554a10ea94e7fbd65e8f54fdeda84a85ed04135a23b4632d
7
+ data.tar.gz: 2df1f6fcff06149be16865c6991238519f5163bebaca3180c2a410a7dbf764edcadfd404c56b84642599d5cd335848d739037f3f7bc5f544d5bbc41a1decafc4
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in heytmux.gemspec
4
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2017 Junegunn Choi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,317 @@
1
+ Hey tmux!
2
+ =========
3
+
4
+ Tmux scripting made easy.
5
+
6
+ Installation
7
+ ------------
8
+
9
+ Heytmux requires Ruby 2.0+ and tmux 2.3+.
10
+
11
+ #### As Ruby gem
12
+
13
+ ```sh
14
+ gem install heytmux
15
+ ```
16
+
17
+ - Installs `heytmux` executable
18
+
19
+ #### As Vim plugin
20
+
21
+ Using [vim-plug](https://github.com/junegunn/vim-plug):
22
+
23
+ ```vim
24
+ Plug 'junegunn/heytmux'
25
+ ```
26
+
27
+ - Registers `:Heytmux` command
28
+ - No need to install Gem
29
+
30
+ Usage
31
+ -----
32
+
33
+ Create a YAML file that describes a desired tmux workspace.
34
+
35
+ ```yaml
36
+ # workspace.yml
37
+ - first window:
38
+ layout: tiled
39
+ panes:
40
+ - first pane: sleep 1
41
+ - second pane: sleep 2
42
+ - third pane: |
43
+ sleep 3
44
+ sleep 4
45
+
46
+ - second window:
47
+ layout: even-vertical
48
+ pane-border-status: top
49
+ synchronize-panes: true
50
+ panes:
51
+ - pane 2-1: sleep 5
52
+ - pane 2-2: sleep 6
53
+ ```
54
+
55
+ Then run `heytmux workspace.yml`.
56
+
57
+ Instead of creating a new session from scratch, Heytmux looks at the current
58
+ session and only creates windows and panes that are not found. So you can
59
+ repeatedly run the same input file only to issue commands on the existing
60
+ panes.
61
+
62
+ Heytmux identifies windows and panes by their names and titles, so renaming
63
+ them can confuse Heytmux. Duplicate names are okay as long as you don't
64
+ reorder windows or panes of the same names.
65
+
66
+ More examples can be found [here](examples/).
67
+
68
+ Heytmux can read STDIN, so `cat workspace.yml | heytmux` is also
69
+ [valid][caat]. It may seem pointless, but it allows you to do `:w !heytmux`
70
+ with a visual selection in Vim.
71
+
72
+ [caat]: http://porkmail.org/era/unix/award.html
73
+
74
+ Step-by-step tutorial
75
+ ---------------------
76
+
77
+ #### List of window names
78
+
79
+ In the simplest case, input file only has to contain the names of windows as
80
+ the top-level list. Create `workspace.yml` and add the following lines.
81
+
82
+ ```yaml
83
+ - window 1
84
+ - window 2
85
+ - window 3
86
+ ```
87
+
88
+ `heytmux workspace.yml` will create 3 windows with the given names. If you
89
+ re-run the same command, you'll notice Heytmux doesn't create more windows as
90
+ they already exist.
91
+
92
+ #### Windows and panes
93
+
94
+ Well, that was not particularly interesting. Let's split the windows and run
95
+ some commands.
96
+
97
+ First run `heytmux --kill workspace.yml` to kill the windows we just created,
98
+ and update your input file as follows:
99
+
100
+ ```yaml
101
+ - window 1:
102
+ - echo 1-1
103
+ - echo 1-2
104
+ - echo 1-3
105
+ - window 2:
106
+ - echo 2-1
107
+ - echo 2-2
108
+ - window 3:
109
+ - sleep 3
110
+ ```
111
+
112
+ Run Heytmux with it and you'll see that panes are created under the windows to
113
+ run those commands. If you rerun the same command, Heytmux will send them
114
+ again to the same panes. No new panes are created.
115
+
116
+ #### Pane titles
117
+
118
+ However, if you change a command on the input file (e.g. `echo 1-1` to
119
+ `sleep 1`) and run Heytmux, a new pane for the command will be created. That's
120
+ because Heytmux identifies windows and panes by their names and titles, and in
121
+ the above case, the title of a pane is implicitly set to the given command. So
122
+ changing the command changes the identifier of the pane, and Heytmux no longer
123
+ can find the previous pane.
124
+
125
+ To reuse the existing panes, you have to explictly name the panes. Update the
126
+ input file, close the windows (`heytmux --kill workspace.yml`), and rerun the
127
+ command.
128
+
129
+ ```yaml
130
+ - window 1:
131
+ - pane 1: sleep 1
132
+ - pane 2: sleep 2
133
+ - pane 3: sleep 3
134
+ - window 2:
135
+ - pane 2-1: sleep 1
136
+ - pane 2-2: |
137
+ sleep 2
138
+ sleep 3
139
+ ```
140
+
141
+ Now, you can freely change the commands without worrying about getting extra
142
+ panes. You can also create and use input files for any subset of the panes.
143
+
144
+ ```yaml
145
+ # In another file
146
+ - window 2:
147
+ - pane 2: echo 'I slept 5 seconds!'
148
+ ```
149
+
150
+ #### Window layout and options
151
+
152
+ What if we want to change the layout of the windows, or if we want to set some
153
+ window options of tmux? To do that, move the list of panes to `panes` under
154
+ each window entry, so you can specify additional settings.
155
+
156
+ ```yaml
157
+ - window 1:
158
+ layout: even-horizontal
159
+ synchronize-panes: true
160
+ pane-border-status: bottom
161
+ panes:
162
+ - pane 1: sleep 1
163
+ - pane 2: sleep 2
164
+ - pane 3: sleep 3
165
+ - window 2:
166
+ layout: even-horizontal
167
+ synchronize-panes: true
168
+ pane-border-status: top
169
+ panes:
170
+ - pane 2-1: sleep 1
171
+ - pane 2-2: |
172
+ sleep 1
173
+ sleep 2
174
+ ```
175
+
176
+ #### Root layout and options
177
+
178
+ That's nice, but looks like we're repeating ourselves with the same options.
179
+ We can reorganize the input file as follows to define the root layout and
180
+ options that are applied to all windows.
181
+
182
+ ```yaml
183
+ layout: even-horizontal
184
+ synchronize-panes: true
185
+ pane-border-status: bottom
186
+
187
+ windows:
188
+ - window 1:
189
+ panes:
190
+ - pane 1: sleep 1
191
+ - pane 2: sleep 2
192
+ - pane 3: sleep 3
193
+ - window 2:
194
+ # Override root option
195
+ pane-border-status: top
196
+ panes:
197
+ - pane 2-1: sleep 1
198
+ - pane 2-2: |
199
+ sleep 1
200
+ sleep 2
201
+ ```
202
+
203
+ #### Expanding panes with `{{ item }}`
204
+
205
+ The panes under `window 1` in the previous example are similar in their names
206
+ and commands, and this is a very common case. To avoid repetition, set `items`
207
+ list for a window, then panes with `{{ item }}` in their titles will be
208
+ expanded according to the list.
209
+
210
+ ```yaml
211
+ # Equivalent to the previous example
212
+ - window 1:
213
+ items: [1, 2, 3]
214
+ panes:
215
+ - pane {{item}}: sleep {{item}}
216
+ ```
217
+
218
+ This is often useful when you have to work with a series of log files or with
219
+ a set of servers.
220
+
221
+ ```yaml
222
+ - servers:
223
+ layout: tiled
224
+ items:
225
+ - west-host1
226
+ - west-host2
227
+ - east-host1
228
+ - east-host2
229
+ panes:
230
+ - ssh user@{{item}} tail -f /var/log/server-{{item}}.log
231
+ ```
232
+
233
+
234
+ #### Referring to environment variables
235
+
236
+ You can refer to environment variables using `{{ $ENV_VAR }}` syntax. For
237
+ default values, use `{{ $ENV_VAR | the-default-value }}` syntax. Heytmux will
238
+ not start if an environment variable is not defined and there's no default
239
+ value.
240
+
241
+ #### Expecting pattern
242
+
243
+ Sometimes it's not enough to just send lines of text at once. For example, the
244
+ following example will not work as expected.
245
+
246
+ ```yaml
247
+ - servers:
248
+ - server 1: |
249
+ ssh server1
250
+ {{ $MY_SSH_PASSWORD }}
251
+ uptime
252
+ ```
253
+
254
+ With `expect` construct, you can make Heytmux wait until a certain regular
255
+ expression pattern appears on the the pane (a la [Expect][expect]).
256
+
257
+ [expect]: https://en.wikipedia.org/wiki/Expect
258
+
259
+ ```yaml
260
+ - servers:
261
+ - server 1:
262
+ - ssh server1
263
+ - expect: '[Pp]assword:'
264
+ - {{ $MY_SSH_PASSWORD }}
265
+ - uptime
266
+ ```
267
+
268
+ Vim plugin
269
+ ----------
270
+
271
+ You don't really need a Vim plugin for Heytmux (because `:w !heytmux` will
272
+ just do), but here's one anyway, to save you some typing.
273
+
274
+ - `:Heytmux [OPTIONS]`
275
+ - Run with the current file
276
+ - `:Heytmux [OPTIONS] FILES...`
277
+ - Run with the files
278
+ - `:'<,'>Heytmux [OPTIONS]` (in visual mode)
279
+ - Run with the given range
280
+
281
+ Use bang version of the command (`:Heytmux!`) not to move focus. It is
282
+ equivalent to passing `-d` flag to heytmux executable.
283
+
284
+ Related projects
285
+ ----------------
286
+
287
+ Many of the ideas were borrowed from [Tmuxinator][tmuxinator] and
288
+ [Ansible][ansible], but Heytmux solves a different problem.
289
+
290
+ [tmuxinator]: https://github.com/tmuxinator/tmuxinator
291
+ [ansible]: https://github.com/ansible/ansible
292
+
293
+ There are also other projects that are similar to tmuxinator.
294
+
295
+ - [teamocil](https://github.com/remiprev/teamocil)
296
+ - [tmuxp](https://github.com/tony/tmuxp)
297
+
298
+ #### How is this different from tmuxinator?
299
+
300
+ With Tmuxinator, you can manage session configurations each of which defines
301
+ the initial layout of a tmux session.
302
+
303
+ On the other hand, Heytmux does not care about sessions, instead it simply
304
+ creates windows and panes on the current session, and it only creates the ones
305
+ that don't exist. So it can be used not only to bootstrap the initial
306
+ workspace, but also to send commands to any subset of the existing panes,
307
+ which means you can use it for scripting your tasks that span multiple tmux
308
+ windows and panes. Heytmux somehow feels like a hybrid of Tmuxinator and
309
+ Ansible.
310
+
311
+ I primarily use Heytmux to write Markdown documents with fenced code blocks of
312
+ YAML snippets that I can easily select and run with Heytmux in my editor.
313
+
314
+ License
315
+ -------
316
+
317
+ MIT
@@ -0,0 +1,10 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rake/testtask'
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << 'test'
6
+ t.libs << 'lib'
7
+ t.test_files = FileList['test/**/*_test.rb']
8
+ end
9
+
10
+ task default: :test
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'heytmux'
4
+
5
+ if ARGV.include?('--version')
6
+ puts Heytmux::VERSION
7
+ exit
8
+ end
9
+
10
+ focus = ARGV.delete('-d').nil?
11
+ kill = ARGV.delete('--kill')
12
+ abort 'usage: heytmux [-d] [--kill] YAML_SPECS...' if ARGV.empty? && $stdin.tty?
13
+ abort 'Not on tmux' unless ENV['TMUX']
14
+
15
+ if (unreadable = ARGV.find { |f| !File.readable?(f) })
16
+ abort "Cannot read #{unreadable}"
17
+ end
18
+
19
+ require 'yaml'
20
+ strings = [$stdin.tty? ? nil : ['standard input', $stdin.read],
21
+ *ARGV.map { |f| [f, File.read(f)] }].compact
22
+ specs = strings.map do |name, str|
23
+ begin
24
+ str = Heytmux.replace_env_vars(str)
25
+ YAML.respond_to?(:safe_load) ? YAML.safe_load(str) : YAML.load(str)
26
+ rescue => e
27
+ abort("Failed to parse #{name}: #{e}")
28
+ end
29
+ end
30
+
31
+ begin
32
+ specs.each do |s|
33
+ Heytmux::Validations.validate(s)
34
+ end
35
+ rescue ArgumentError, RegexpError => e
36
+ abort(e.message)
37
+ end
38
+
39
+ if kill
40
+ specs.each { |spec| Heytmux.kill! spec }
41
+ else
42
+ specs.each { |spec| Heytmux.process! spec, focus }
43
+ end
@@ -0,0 +1,31 @@
1
+ # coding: utf-8
2
+
3
+ lib = File.expand_path('../lib', __FILE__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'heytmux/version'
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = 'heytmux'
9
+ spec.version = Heytmux::VERSION
10
+ spec.authors = ['Junegunn Choi']
11
+ spec.email = ['junegunn.c@gmail.com']
12
+
13
+ spec.summary = 'Hey tmux!'
14
+ spec.description = 'Tmux scripting made easy'
15
+ spec.homepage = 'https://github.com/junegunn/heytmux'
16
+
17
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
18
+ f.match(/^(bin|test|spec|features|plugin|examples|\.)/)
19
+ end
20
+ spec.bindir = 'exe'
21
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
22
+ spec.require_paths = ['lib']
23
+
24
+ spec.required_ruby_version = '>= 2.0'
25
+ spec.add_development_dependency 'bundler', '~> 1.15'
26
+ spec.add_development_dependency 'rake', '~> 12.0'
27
+ spec.add_development_dependency 'minitest', '~> 5.10'
28
+ spec.add_development_dependency 'pry'
29
+ spec.add_development_dependency 'simplecov'
30
+ spec.add_development_dependency 'coveralls'
31
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ # Hey tmux!
6
+ module Heytmux
7
+ DEFAULT_LAYOUT = ->(num_panes) { num_panes <= 3 ? 'even-vertical' : 'tiled' }
8
+ DEFAULT_OPTIONS = {
9
+ 'automatic-rename' => 'off',
10
+ 'allow-rename' => 'off',
11
+ 'pane-base-index' => 0,
12
+ 'pane-border-status' => 'bottom',
13
+ 'pane-border-format' => '#{pane_title}'
14
+ }.freeze
15
+
16
+ SUPPORTED_ACTIONS = %w[expect].to_set.freeze
17
+
18
+ WINDOWS_KEY = 'windows'
19
+ LAYOUT_KEY = 'layout'
20
+ PANES_KEY = 'panes'
21
+ ITEMS_KEY = 'items'
22
+ ITEM_PAT = /{{ *item *}}/i
23
+ ENV_PAT = /{{ *\$([a-z0-9_]+) *(?:\| *(.*?) *)?}}/i
24
+
25
+ EXPECT_SLEEP_INTERVAL = 0.5
26
+ EXPECT_TIMEOUT = 60
27
+ end
28
+
29
+ require 'heytmux/version'
30
+ require 'heytmux/validations'
31
+ require 'heytmux/tmux'
32
+ require 'heytmux/core'
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Core functions of Heytmux
4
+ module Heytmux
5
+ module_function
6
+
7
+ def replace_env_vars(raw_input)
8
+ raw_input.gsub(ENV_PAT) do
9
+ name = Regexp.last_match[1]
10
+ default = Regexp.last_match[2]
11
+ ENV.fetch(name, default) ||
12
+ raise(ArgumentError, "Missing environment variable: #{name}")
13
+ end
14
+ end
15
+
16
+ # Processes spec struct
17
+ def process!(root_spec, focus)
18
+ # Find window indexes
19
+ windows, root_options = interpret_root_spec(root_spec, Tmux.list)
20
+ root_layout = root_options.delete(LAYOUT_KEY)
21
+
22
+ # Create windows if not found (no :index)
23
+ found_windows, new_windows = create_if_missing(windows, :index) do |window|
24
+ create_first_pane(window)
25
+ end
26
+
27
+ # Process panes in windows
28
+ threads = (found_windows + new_windows).flat_map.with_index do |window, idx|
29
+ process_window!(window, root_options, root_layout, focus && idx.zero?)
30
+ end
31
+ threads.each(&:join)
32
+ end
33
+
34
+ # Kills windows specified in the input
35
+ def kill!(root_spec)
36
+ windows, = interpret_root_spec(root_spec, Tmux.list)
37
+ windows.map { |w| w[:index] }.compact.sort.reverse.each do |index|
38
+ Tmux.kill(index)
39
+ end
40
+ end
41
+
42
+ # Executes tasks to the window
43
+ def process_window!(window, root_options, root_layout, focus)
44
+ index, panes, options, layout =
45
+ window.values_at(:index, :panes, :options, :layout)
46
+ layout ||= root_layout
47
+
48
+ # Set additional options
49
+ Tmux.set_window_options(index, root_options.merge(options))
50
+
51
+ # Split panes
52
+ found_panes, new_panes = create_if_missing(panes, :index) do |pane|
53
+ pane_index =
54
+ split_and_select_layout(index,
55
+ pane[:title],
56
+ layout || DEFAULT_LAYOUT[panes.length])
57
+ pane.merge(index: pane_index)
58
+ end
59
+
60
+ # Select layout when it's explicitly given
61
+ Tmux.select_layout(index, layout) if layout && new_panes.empty?
62
+
63
+ # Focus window
64
+ Tmux.select_window(index) if focus
65
+
66
+ # Execute commands
67
+ (found_panes + new_panes).map do |pane|
68
+ Thread.new { process_command!(index, pane) }
69
+ end
70
+ end
71
+
72
+ def process_command!(window_index, pane)
73
+ pane_index, item, commands = pane.values_at(:index, :item, :command)
74
+ [*commands].each do |command|
75
+ case command
76
+ when Hash
77
+ _action, regex = command.first
78
+ regex = Regexp.compile(regex.to_s)
79
+ wait_until do
80
+ content = Tmux.capture(window_index, pane_index)
81
+ content =~ regex
82
+ end
83
+ else
84
+ command = command.gsub(ITEM_PAT, item) if item
85
+ Tmux.paste(window_index, pane_index, command)
86
+ end
87
+ end
88
+ end
89
+
90
+ def wait_until
91
+ timeout = Time.now + EXPECT_TIMEOUT
92
+ loop do
93
+ sleep EXPECT_SLEEP_INTERVAL
94
+ return if yield
95
+ raise 'Timed out' if Time.now > timeout
96
+ end
97
+ end
98
+
99
+ # Groups entities by the group key and extracts unique, sorted indexes and
100
+ # returns stateful indexer that issues index number for the given group key
101
+ def indexer(entities, group_key, index_key)
102
+ indexes =
103
+ Hash[entities.group_by { |e| e[group_key] }
104
+ .map { |g, es| [g, es.map { |e| e[index_key] }.sort.uniq] }]
105
+ ->(group) { indexes.fetch(group, []).shift }
106
+ end
107
+ private_class_method :indexer
108
+
109
+ # Interprets root spec for workspace
110
+ def interpret_root_spec(root_spec, all_panes)
111
+ window_indexer = indexer(all_panes, :window_name, :window_index)
112
+ windows = window_specs(root_spec).map do |window|
113
+ interpret_window_spec(window, all_panes, window_indexer)
114
+ end
115
+ [windows, root_options(root_spec)]
116
+ end
117
+
118
+ def window_specs(root_spec)
119
+ # The list of window specs can be specified under top-level 'windows' keys,
120
+ # or it can be given as the top-level array.
121
+ windows = root_spec.is_a?(Array) ? root_spec : root_spec[WINDOWS_KEY]
122
+ windows.map do |window|
123
+ window.is_a?(String) ? { window => {} } : window
124
+ end
125
+ end
126
+ private_class_method :window_specs
127
+
128
+ def root_options(root_spec)
129
+ if root_spec.is_a?(Array)
130
+ {}
131
+ else
132
+ root_spec.dup.tap { |copy| copy.delete(WINDOWS_KEY) }
133
+ end
134
+ end
135
+ private_class_method :root_options
136
+
137
+ # Interprets spec for a window
138
+ def interpret_window_spec(window, all_panes, window_indexer)
139
+ name, spec = window.first
140
+ spec = { PANES_KEY => spec } if spec.is_a?(Array)
141
+ index = window_indexer[name]
142
+ existing_panes = all_panes.select { |h| h[:window_index] == index }
143
+ pane_indexer = indexer(existing_panes, :pane_title, :pane_index)
144
+
145
+ spec = spec.dup || {}
146
+ layout = spec.delete(LAYOUT_KEY)
147
+ items = spec.delete(ITEMS_KEY)
148
+ items = items ? items.map { |item| Regexp.escape(item.to_s) } : ['\0']
149
+ panes = spec.delete(PANES_KEY) || []
150
+ panes = panes.flat_map do |pane|
151
+ interpret_and_expand_pane_spec(pane, items, pane_indexer)
152
+ end
153
+
154
+ { name: name, index: index, panes: panes, layout: layout, options: spec }
155
+ end
156
+
157
+ def interpret_and_expand_pane_spec(pane, items, pane_indexer)
158
+ title, command = pane.is_a?(Hash) ? pane.first : [pane.tr("\n", ' '), pane]
159
+ if title =~ ITEM_PAT
160
+ items.map do |item|
161
+ title_sub = title.gsub(ITEM_PAT, item.to_s)
162
+ { title: title_sub, command: command,
163
+ item: item.to_s, index: pane_indexer[title_sub] }
164
+ end
165
+ else
166
+ [{ title: title, command: command, index: pane_indexer[title] }]
167
+ end
168
+ end
169
+
170
+ # Checks if there are entities without the required_field and creates them
171
+ # with the given block. Returns two arrays of found and created entities.
172
+ def create_if_missing(entities, required_field)
173
+ found, missing = entities.partition { |e| e[required_field] }
174
+ [found, missing.map { |e| yield e }]
175
+ end
176
+ private_class_method :create_if_missing
177
+
178
+ # Creates a new tmux window and sets the title of its first pane.
179
+ # Returns the index of the new window.
180
+ def create_first_pane(window)
181
+ name, panes = window.values_at(:name, :panes)
182
+ pane_title = panes.map { |pane| pane[:title] }.first
183
+ new_index = Tmux.create_window(name, pane_title, DEFAULT_OPTIONS)
184
+ if pane_title
185
+ panes = panes.dup
186
+ panes[0] = panes[0].dup.tap do |pane|
187
+ pane[:index] = DEFAULT_OPTIONS['pane-base-index']
188
+ end
189
+ end
190
+ window.merge(index: new_index, panes: panes)
191
+ end
192
+
193
+ # To avoid 'pane too small' error, we rearrange panes after every split
194
+ def split_and_select_layout(window_index, pane_title, layout)
195
+ Tmux.split_window(window_index, pane_title).tap do
196
+ Tmux.select_layout(window_index, layout)
197
+ end
198
+ end
199
+ private_class_method :split_and_select_layout
200
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+ require 'tempfile'
5
+
6
+ module Heytmux
7
+ # Tmux integration
8
+ module Tmux
9
+ module_function
10
+
11
+ def tmux(*args)
12
+ args = args.flatten.map { |a| Shellwords.escape(a.to_s) }
13
+ command = "tmux #{args.join(' ')}"
14
+ (@mutex ||= Mutex.new).synchronize do
15
+ puts command if ENV['HEYTMUX_DEBUG']
16
+ `#{command}`.chomp
17
+ end
18
+ end
19
+
20
+ def list
21
+ labels = %i[window_index window_name pane_index pane_title]
22
+ delimiter = '::::'
23
+ list = tmux(%w[list-panes -s -F],
24
+ labels.map { |label| "\#{#{label}}" }.join(delimiter))
25
+ list.each_line.map do |line|
26
+ Hash[labels.zip(line.chomp.split(delimiter))].tap do |h|
27
+ h[:window_index] = h[:window_index].to_i
28
+ h[:pane_index] = h[:pane_index].to_i
29
+ end
30
+ end
31
+ end
32
+
33
+ def create_window(window_name, pane_title, window_options)
34
+ tmux(%w[new-window -d -P -F #{window_index} -n],
35
+ window_name).to_i.tap do |index|
36
+ set_window_options(index, window_options)
37
+ if pane_title
38
+ base_index = window_options.fetch('pane-base-index', 0)
39
+ set_pane_title(index, base_index, pane_title)
40
+ end
41
+ end
42
+ end
43
+
44
+ def paste(window_index, pane_index, keys)
45
+ file = Tempfile.new('heytmux')
46
+ file.puts keys
47
+ file.close
48
+
49
+ tmux(%w[load-buffer -b heytmux], file.path,
50
+ %w[; paste-buffer -d -b heytmux],
51
+ target(window_index, pane_index))
52
+ ensure
53
+ file.unlink
54
+ end
55
+
56
+ # Applies a set of window options
57
+ def set_window_options(window_index, opts)
58
+ args = opts.flat_map do |k, v|
59
+ [';', 'set-window-option', target(window_index),
60
+ k, { true => 'on', false => 'off' }.fetch(v, v)]
61
+ end.drop(1)
62
+ tmux(*args) if args.any?
63
+ end
64
+
65
+ # Splits the window and returns the index of the new pane
66
+ def split_window(window_index, pane_title)
67
+ tmux(%w[split-window -P -F #{pane_index} -d],
68
+ target(window_index)).to_i.tap do |pane_index|
69
+ set_pane_title(window_index, pane_index, pane_title)
70
+ tmux('select-pane', target(window_index, pane_index))
71
+ end
72
+ end
73
+
74
+ # Sets the title of the pane
75
+ def set_pane_title(window_index, pane_index, title)
76
+ # The space at the beginning is for preventing it from being added to
77
+ # shell history
78
+ paste(window_index, pane_index,
79
+ %( sh -c "printf '\\033]2;#{title}\\033\\';clear"))
80
+ end
81
+
82
+ # Selects window layout
83
+ def select_layout(window_index, layout)
84
+ tmux('select-layout', target(window_index), layout)
85
+ end
86
+
87
+ # Selects window
88
+ def select_window(window_index)
89
+ tmux('select-window', target(window_index))
90
+ end
91
+
92
+ # Queries tmux
93
+ def query(*indexes, print_format)
94
+ tmux(%w[display-message -p -F], print_format, target(*indexes))
95
+ end
96
+
97
+ # Kills window
98
+ def kill(index)
99
+ tmux('kill-window', target(index))
100
+ end
101
+
102
+ # Captures pane content
103
+ def capture(window_index, pane_index)
104
+ file = Tempfile.new('heytmux')
105
+ file.close
106
+ tmux('capture-pane', target(window_index, pane_index),
107
+ ';', 'save-buffer', file.path)
108
+ File.read(file.path).strip
109
+ ensure
110
+ file.unlink
111
+ end
112
+
113
+ # Returns target identifier for tmux commands
114
+ def target(*indexes)
115
+ ['-t', ':' + indexes.join('.')]
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Heytmux
4
+ # Validates workspace specification
5
+ module Validations
6
+ module_function
7
+
8
+ # Validates spec struct and raises ArgumentError on error
9
+ def validate(spec)
10
+ windows = case spec
11
+ when Hash
12
+ unless spec.key?(WINDOWS_KEY)
13
+ raise(ArgumentError, 'windows key is not found')
14
+ end
15
+ spec[WINDOWS_KEY]
16
+ when Array then spec
17
+ else
18
+ raise ArgumentError, "Not a valid spec: #{spec}"
19
+ end
20
+
21
+ unless windows.is_a?(Array)
22
+ raise ArgumentError, 'windows must be given as a list'
23
+ end
24
+ windows.each { |window| validate_window(window) }
25
+ nil
26
+ end
27
+
28
+ # Validates spec struct for a window and raises ArgumentError on error
29
+ # - window_name
30
+ # - window_name => [panes...]
31
+ # - window_name => { 'panes' => [panes...], ... }
32
+ def validate_window(window_spec)
33
+ message = "Not a valid window spec: #{window_spec.inspect}"
34
+ case window_spec
35
+ when Hash
36
+ unless single_spec?(window_spec, Hash, Array)
37
+ raise ArgumentError, message
38
+ end
39
+ spec = window_spec.first.last
40
+ spec = { PANES_KEY => spec } if spec.is_a?(Array)
41
+ spec.fetch(PANES_KEY, []).each do |pane|
42
+ validate_pane(pane)
43
+ end
44
+ else
45
+ # Just the name
46
+ raise ArgumentError, message unless valid_name?(window_spec)
47
+ end
48
+ nil
49
+ end
50
+
51
+ # - pane_name
52
+ # - { pane_name => command_string }
53
+ # - { pane_name => [commands...] }
54
+ def validate_pane(pane_spec)
55
+ message = "Not a valid pane spec: #{pane_spec.inspect}"
56
+ case pane_spec
57
+ when Hash
58
+ raise ArgumentError, message unless single_spec?(pane_spec)
59
+ validate_commands(pane_spec.first.last)
60
+ else
61
+ raise ArgumentError, message unless valid_name?(pane_spec)
62
+ end
63
+ end
64
+
65
+ # - command_string
66
+ # - [command_strings_or_hashes...]
67
+ def validate_commands(commands)
68
+ return if commands.is_a?(String)
69
+ message = "Invalid command: #{commands.inspect}"
70
+ case commands
71
+ when Array
72
+ hashes, strings = commands.partition { |command| command.is_a?(Hash) }
73
+ hashes.each do |command|
74
+ raise ArgumentError, message unless single_spec?(command)
75
+ action, condition = command.first
76
+ unless SUPPORTED_ACTIONS.include?(action)
77
+ raise ArgumentError, "Unsupported action: #{action}"
78
+ end
79
+ raise ArgumentError, message unless valid_name?(condition)
80
+ Regexp.compile(condition.to_s)
81
+ end
82
+ strings.each do |command|
83
+ raise ArgumentError, message unless valid_name?(command)
84
+ end
85
+ else
86
+ raise ArgumentError, message unless valid_name?(commands)
87
+ end
88
+ nil
89
+ end
90
+
91
+ # Checks if the given hash only contains one mapping from a string to
92
+ # a hash
93
+ def single_spec?(spec, *klasses)
94
+ (key, value), second = spec.take(2)
95
+ second.nil? && !key.to_s.empty? &&
96
+ (klasses.empty? || klasses.any? { |k| value.is_a?(k) })
97
+ end
98
+ private_class_method :single_spec?
99
+
100
+ def valid_name?(value)
101
+ !(value.nil? || value.is_a?(Array) || value.to_s.empty?)
102
+ end
103
+ private_class_method :valid_name?
104
+ end
105
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Heytmux
4
+ VERSION = '0.0.0'
5
+ end
metadata ADDED
@@ -0,0 +1,139 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: heytmux
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Junegunn Choi
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2017-07-03 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.15'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.15'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '12.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '12.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: minitest
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '5.10'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '5.10'
55
+ - !ruby/object:Gem::Dependency
56
+ name: pry
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: simplecov
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: coveralls
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ description: Tmux scripting made easy
98
+ email:
99
+ - junegunn.c@gmail.com
100
+ executables:
101
+ - heytmux
102
+ extensions: []
103
+ extra_rdoc_files: []
104
+ files:
105
+ - Gemfile
106
+ - LICENSE
107
+ - README.md
108
+ - Rakefile
109
+ - exe/heytmux
110
+ - heytmux.gemspec
111
+ - lib/heytmux.rb
112
+ - lib/heytmux/core.rb
113
+ - lib/heytmux/tmux.rb
114
+ - lib/heytmux/validations.rb
115
+ - lib/heytmux/version.rb
116
+ homepage: https://github.com/junegunn/heytmux
117
+ licenses: []
118
+ metadata: {}
119
+ post_install_message:
120
+ rdoc_options: []
121
+ require_paths:
122
+ - lib
123
+ required_ruby_version: !ruby/object:Gem::Requirement
124
+ requirements:
125
+ - - ">="
126
+ - !ruby/object:Gem::Version
127
+ version: '2.0'
128
+ required_rubygems_version: !ruby/object:Gem::Requirement
129
+ requirements:
130
+ - - ">="
131
+ - !ruby/object:Gem::Version
132
+ version: '0'
133
+ requirements: []
134
+ rubyforge_project:
135
+ rubygems_version: 2.6.8
136
+ signing_key:
137
+ specification_version: 4
138
+ summary: Hey tmux!
139
+ test_files: []