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.
- checksums.yaml +7 -0
- data/Gemfile +4 -0
- data/LICENSE +21 -0
- data/README.md +317 -0
- data/Rakefile +10 -0
- data/exe/heytmux +43 -0
- data/heytmux.gemspec +31 -0
- data/lib/heytmux.rb +32 -0
- data/lib/heytmux/core.rb +200 -0
- data/lib/heytmux/tmux.rb +118 -0
- data/lib/heytmux/validations.rb +105 -0
- data/lib/heytmux/version.rb +5 -0
- metadata +139 -0
checksums.yaml
ADDED
@@ -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
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.
|
data/README.md
ADDED
@@ -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
|
data/Rakefile
ADDED
data/exe/heytmux
ADDED
@@ -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
|
data/heytmux.gemspec
ADDED
@@ -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
|
data/lib/heytmux.rb
ADDED
@@ -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'
|
data/lib/heytmux/core.rb
ADDED
@@ -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
|
data/lib/heytmux/tmux.rb
ADDED
@@ -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
|
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: []
|