heytmux 0.0.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,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: []