libtmux 0.1.0.alpha.1
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/LICENSE +21 -0
- data/README.md +109 -0
- data/lib/libtmux/capture.rb +146 -0
- data/lib/libtmux/catalog.rb +116 -0
- data/lib/libtmux/child.rb +156 -0
- data/lib/libtmux/control.rb +885 -0
- data/lib/libtmux/criteria.rb +410 -0
- data/lib/libtmux/endpoint.rb +213 -0
- data/lib/libtmux/entity.rb +262 -0
- data/lib/libtmux/errors.rb +54 -0
- data/lib/libtmux/group.rb +59 -0
- data/lib/libtmux/metadata.rb +85 -0
- data/lib/libtmux/operations.rb +412 -0
- data/lib/libtmux/options.rb +180 -0
- data/lib/libtmux/owned.rb +252 -0
- data/lib/libtmux/process.rb +327 -0
- data/lib/libtmux/process_wait.rb +66 -0
- data/lib/libtmux/selection.rb +95 -0
- data/lib/libtmux/server.rb +534 -0
- data/lib/libtmux/snapshot.rb +428 -0
- data/lib/libtmux/socket_readiness.rb +232 -0
- data/lib/libtmux/source_query.rb +57 -0
- data/lib/libtmux/terminal.rb +197 -0
- data/lib/libtmux/version.rb +5 -0
- data/lib/libtmux.rb +18 -0
- data/schema/where-v1.json +2291 -0
- data/sig/control.rbs +75 -0
- data/sig/criteria.rbs +36 -0
- data/sig/fields.rbs +72 -0
- data/sig/group.rbs +14 -0
- data/sig/libtmux.rbs +186 -0
- data/sig/operations.rbs +91 -0
- data/sig/owned.rbs +7 -0
- data/sig/snapshot.rbs +70 -0
- data/sig/terminal.rbs +15 -0
- metadata +145 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "options"
|
|
4
|
+
|
|
5
|
+
module LibTmux
|
|
6
|
+
class Server
|
|
7
|
+
def options(scope: :server)
|
|
8
|
+
Options.__send__(:new, self, scope: scope)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def hooks(scope: :session)
|
|
12
|
+
raise ArgumentError, "hooks use session or window scope" unless [:session, :window].include?(scope)
|
|
13
|
+
|
|
14
|
+
Hooks.__send__(:new, self, scope: scope)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Formats are intentionally executable tmux formats, not literal text.
|
|
18
|
+
def display(format, target: nil, timeout: 5.0, cancel: nil)
|
|
19
|
+
return window_link(target).display(format, timeout: timeout, cancel: cancel) if target.is_a?(EntityRef) && target.kind == :window_link
|
|
20
|
+
|
|
21
|
+
args = ["display-message", "-p"]
|
|
22
|
+
if target
|
|
23
|
+
id = self.target(target, target.kind)
|
|
24
|
+
args.concat(["-t", id])
|
|
25
|
+
raise ArgumentError, "format must be a String" unless format.is_a?(String)
|
|
26
|
+
|
|
27
|
+
# display-message accepts a missing -t target; bind its returned context
|
|
28
|
+
# in this same command instead of accepting an empty fallback format.
|
|
29
|
+
format = id_format(target.kind) + format
|
|
30
|
+
end
|
|
31
|
+
result = execute_typed([*args, "--", format], timeout: timeout, cancel: cancel)
|
|
32
|
+
return result unless target
|
|
33
|
+
|
|
34
|
+
prefix = "#{id.bytesize}:#{id}"
|
|
35
|
+
unless result.stdout.start_with?(prefix)
|
|
36
|
+
raise TargetNotFoundError.new("display target no longer exists", phase: :command, delivery: :observed)
|
|
37
|
+
end
|
|
38
|
+
CommandResult.new(stdout: result.stdout.byteslice(prefix.bytesize..), stderr: result.stderr,
|
|
39
|
+
status: result.status, elapsed_seconds: result.elapsed_seconds, pid: result.pid, argv: result.argv)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def source_file(path, timeout: 5.0, cancel: nil)
|
|
43
|
+
execute_typed(["source-file", "--", path], timeout: timeout, cancel: cancel)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Retiring the client cannot remove a dispatched waiter from tmux's queue.
|
|
47
|
+
def wait_for(channel, action: :wait, timeout: 5.0, cancel: nil)
|
|
48
|
+
flag = {wait: nil, signal: "-S", lock: "-L", unlock: "-U"}.fetch(action) do
|
|
49
|
+
raise ArgumentError, "action must be :wait, :signal, :lock or :unlock"
|
|
50
|
+
end
|
|
51
|
+
execute_typed(["wait-for", *[flag].compact, "--", channel], timeout: timeout, cancel: cancel)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def write_buffer(name:, data:, timeout: 5.0, cancel: nil)
|
|
55
|
+
execute_typed(["load-buffer", "-b", name, "--", "-"], input: data, timeout: timeout, cancel: cancel)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def read_buffer(name, timeout: 5.0, cancel: nil)
|
|
59
|
+
execute_typed(["save-buffer", "-b", name, "--", "-"], timeout: timeout, cancel: cancel)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def delete_buffer(name, timeout: 5.0, cancel: nil)
|
|
63
|
+
execute_typed(["delete-buffer", "-b", name], timeout: timeout, cancel: cancel)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def list_buffers(timeout: 5.0, cancel: nil)
|
|
67
|
+
result = execute_typed(["list-buffers", "-F", metadata_format(%w[buffer_name buffer_size])], timeout: timeout, cancel: cancel)
|
|
68
|
+
Internal::Metadata.decode(result.stdout, fields: 2, quoted: true).map do |name, size|
|
|
69
|
+
{name: name, size: decode_integer(size, "buffer size")}.freeze
|
|
70
|
+
end.freeze
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def list_window_links(timeout: 5.0, cancel: nil)
|
|
74
|
+
acquire_links(["-a"], timeout: timeout, cancel: cancel)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def window_link(ref)
|
|
78
|
+
resolve(ref, :window_link)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def set_environment(name, value, hidden: false, timeout: 5.0, cancel: nil)
|
|
82
|
+
mutate_environment(["-g"], name, value, hidden: hidden, timeout: timeout, cancel: cancel)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def environment(name, hidden: false, timeout: 5.0, cancel: nil)
|
|
86
|
+
read_environment(["-g"], name, hidden: hidden, timeout: timeout, cancel: cancel)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def unset_environment(name, timeout: 5.0, cancel: nil)
|
|
90
|
+
execute_typed(["set-environment", "-g", "-u", "--", environment_name(name)], timeout: timeout, cancel: cancel)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def list_clients(timeout: 5.0, cancel: nil)
|
|
94
|
+
fields = %w[client_name client_pid client_created client_tty session_id client_control_mode]
|
|
95
|
+
result = execute_typed(["list-clients", "-F", metadata_format(fields)], timeout: timeout, cancel: cancel)
|
|
96
|
+
Internal::Metadata.decode(result.stdout, fields: fields.length, quoted: true).map do |name, pid, created, tty, session_id, control|
|
|
97
|
+
unless ["0", "1"].include?(control)
|
|
98
|
+
raise FieldDecodeError.new("client control mode is malformed", phase: :decode, delivery: :observed)
|
|
99
|
+
end
|
|
100
|
+
{name: name, pid: decode_integer(pid, "client PID"), created: decode_integer(created, "client creation time"),
|
|
101
|
+
tty: tty.empty? ? nil : tty, session_id: session_id.empty? ? nil : session_id, control: control == "1"}.freeze
|
|
102
|
+
end.freeze
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
# The format guard and nonwaiting mutation run within one tmux queue turn.
|
|
108
|
+
# Resolve configured aliases first; concurrent alias reconfiguration is not
|
|
109
|
+
# a supported coordination mechanism for these guarded operations.
|
|
110
|
+
def execute_link(refs, command, arguments, timeout: 5.0, cancel: nil, budget: nil)
|
|
111
|
+
budget ||= operation_budget(timeout, cancel)
|
|
112
|
+
refs.each { |ref| target(ref, :window_link) }
|
|
113
|
+
names = builtin_spellings("if-shell", "display-message", command, budget: budget)
|
|
114
|
+
marker = "libtmux-missing-#{SecureRandom.hex(16)}"
|
|
115
|
+
failure = tmux_command([names.fetch("display-message"), "-p", "--", marker])
|
|
116
|
+
body = tmux_command([names.fetch(command), *arguments])
|
|
117
|
+
refs.reverse_each do |ref|
|
|
118
|
+
guard = "\#{&&:\#{==:\#{session_id},#{ref.session_id}},\#{&&:\#{==:\#{window_index},#{ref.index}},\#{==:\#{window_id},#{ref.id}}}}"
|
|
119
|
+
body = tmux_command([names.fetch("if-shell"), "-F", "-t", "#{ref.session_id}:#{ref.index}", guard, body, failure])
|
|
120
|
+
end
|
|
121
|
+
# One command string is parsed by tmux; it never passes through a shell.
|
|
122
|
+
result = execute_typed([names.fetch("if-shell"), "-F", "1", body], **budget.options)
|
|
123
|
+
if result.stdout == "#{marker}\n"
|
|
124
|
+
raise TargetNotFoundError.new("window link was removed or replaced", phase: :command, delivery: :observed)
|
|
125
|
+
end
|
|
126
|
+
result
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def builtin_spellings(*commands, budget:)
|
|
130
|
+
inventory = options(scope: :server).list(name: "command-alias", **budget.options)
|
|
131
|
+
unless !inventory.empty? && inventory.all? { |option| option.name == "command-alias" && option.array? }
|
|
132
|
+
raise UnsupportedFeatureError.new("tmux command alias inventory is unavailable", phase: :admission)
|
|
133
|
+
end
|
|
134
|
+
aliases = inventory.filter_map do |option|
|
|
135
|
+
option.raw.split("=", 2).first if option.present?
|
|
136
|
+
end
|
|
137
|
+
result = execute_typed(["list-commands", "-F", metadata_format(%w[command_list_name command_list_alias])], **budget.options)
|
|
138
|
+
catalog = Internal::Metadata.decode(result.stdout, fields: 2, quoted: true).to_h
|
|
139
|
+
commands.uniq.to_h do |name|
|
|
140
|
+
candidates = [name, catalog[name], *(1...name.length).map { |length| name[0, length] }.reverse].compact
|
|
141
|
+
spelling = candidates.find do |candidate|
|
|
142
|
+
next false if aliases.include?(candidate)
|
|
143
|
+
|
|
144
|
+
exact_alias = catalog.find { |_key, value| value == candidate }&.first
|
|
145
|
+
matching = exact_alias ? [exact_alias] : catalog.keys.select { |key| key.start_with?(candidate) }
|
|
146
|
+
matching == [name] || candidate == name
|
|
147
|
+
end
|
|
148
|
+
raise UnsupportedFeatureError.new("all builtin spellings of #{name} are aliased", phase: :admission) unless spelling
|
|
149
|
+
|
|
150
|
+
[name, spelling]
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def kill_session_with_windows(ref, expected, expected_panes, timeout:, cancel:)
|
|
155
|
+
budget = operation_budget(timeout, cancel)
|
|
156
|
+
id = target(ref, :session)
|
|
157
|
+
unless expected.is_a?(Array) && expected.length <= 1024
|
|
158
|
+
raise ArgumentError, "expected_windows must be an Array of at most 1024 window references"
|
|
159
|
+
end
|
|
160
|
+
unless expected_panes.is_a?(Array) && expected_panes.length <= 4096
|
|
161
|
+
raise ArgumentError, "expected_panes must be an Array of at most 4096 pane references"
|
|
162
|
+
end
|
|
163
|
+
ids = expected.map { |window| target(window, :window) }.uniq
|
|
164
|
+
pane_ids = expected_panes.map { |pane| target(pane, :pane) }.uniq
|
|
165
|
+
names = builtin_spellings("if-shell", "display-message", "kill-session", budget: budget)
|
|
166
|
+
allowed = ids.empty? ? "0" : "\#{m/r:^(#{ids.join('|')})$,\#{window_id}}"
|
|
167
|
+
allowed_panes = pane_ids.empty? ? "0" : "\#{m/r:^(#{pane_ids.join('|')})$,\#{pane_id}}"
|
|
168
|
+
guard = "\#{&&:\#{==:\#{session_id},#{id}},\#{==:\#{W:\#{?#{allowed},,w}\#{P:\#{?#{allowed_panes},,p}}},}}"
|
|
169
|
+
marker = "libtmux-membership-#{SecureRandom.hex(16)}"
|
|
170
|
+
failure = tmux_command([names.fetch("display-message"), "-p", "--", marker])
|
|
171
|
+
body = tmux_command([names.fetch("kill-session"), "-t", id])
|
|
172
|
+
result = execute_typed([names.fetch("if-shell"), "-F", "-t", id, guard, body, failure], **budget.options)
|
|
173
|
+
if result.stdout == "#{marker}\n"
|
|
174
|
+
raise TargetNotFoundError.new("session contains windows or panes outside the expected ownership sets", phase: :command, delivery: :observed)
|
|
175
|
+
end
|
|
176
|
+
result
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def tmux_command(arguments)
|
|
180
|
+
validate_argv(arguments)
|
|
181
|
+
arguments.map { |argument| "'#{argument.gsub("'") { %q('\\'') }}'" }.join(" ")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def metadata_format(fields)
|
|
185
|
+
Internal::Metadata.format(fields)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def decode_integer(value, label)
|
|
189
|
+
unless value.match?(/\A\d+\z/)
|
|
190
|
+
raise FieldDecodeError.new("#{label} is malformed", phase: :decode, delivery: :observed)
|
|
191
|
+
end
|
|
192
|
+
Integer(value, 10)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def require_command_flag(command, flag, budget:)
|
|
196
|
+
result = execute_typed(["list-commands", "-F", metadata_format(%w[command_list_name command_list_usage]), command], **budget.options)
|
|
197
|
+
commands = Internal::Metadata.decode(result.stdout, fields: 2, quoted: true).to_h
|
|
198
|
+
usage = commands.fetch(command) do
|
|
199
|
+
raise UnsupportedFeatureError.new("tmux does not advertise #{command}", phase: :admission)
|
|
200
|
+
end
|
|
201
|
+
unless usage.scan(/\[-([A-Za-z]+)(?:\]|\s)/).flatten.any? { |flags| flags.include?(flag) }
|
|
202
|
+
raise UnsupportedFeatureError.new("tmux does not advertise #{command} -#{flag}", phase: :admission)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def acquire_links(flags, timeout: 5.0, cancel: nil)
|
|
207
|
+
result = execute_typed(["list-windows", *flags, "-F", metadata_format(%w[session_id window_index window_id])], timeout: timeout, cancel: cancel)
|
|
208
|
+
Internal::Metadata.decode(result.stdout, fields: 3, quoted: true).map do |session_id, index, id|
|
|
209
|
+
ref = EntityRef.__send__(:new, binding_key: @pin.key, kind: :window_link, id: id,
|
|
210
|
+
session_id: session_id, index: decode_integer(index, "window index"))
|
|
211
|
+
WindowLink.__send__(:new, self, ref)
|
|
212
|
+
end.sort_by { |link| [link.ref.session_id[1..].to_i, link.index] }.freeze
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def environment_name(name)
|
|
216
|
+
unless name.is_a?(String) && name.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
|
|
217
|
+
raise ArgumentError, "environment name must be a portable variable name"
|
|
218
|
+
end
|
|
219
|
+
name
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def mutate_environment(flags, name, value, hidden:, timeout: 5.0, cancel: nil)
|
|
223
|
+
execute_typed(["set-environment", *flags, *(hidden ? ["-h"] : []), "--", environment_name(name), value], timeout: timeout, cancel: cancel)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def read_environment(flags, name, hidden:, timeout: 5.0, cancel: nil)
|
|
227
|
+
name = environment_name(name)
|
|
228
|
+
result = execute_typed(["show-environment", "-s", *flags, *(hidden ? ["-h"] : []), "--", name], timeout: timeout, cancel: cancel)
|
|
229
|
+
return nil if result.stdout == "unset #{name};\n"
|
|
230
|
+
prefix, suffix = "#{name}=\"", "\"; export #{name};\n"
|
|
231
|
+
unless result.stdout.start_with?(prefix) && result.stdout.end_with?(suffix)
|
|
232
|
+
raise ProtocolError.new("tmux returned an unexpected environment record", phase: :decode, delivery: :observed)
|
|
233
|
+
end
|
|
234
|
+
Internal::Metadata.unquote(result.stdout.byteslice(prefix.bytesize, result.stdout.bytesize - prefix.bytesize - suffix.bytesize)).freeze
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
class Entity
|
|
239
|
+
def options
|
|
240
|
+
Options.__send__(:new, server, ref: ref)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def hooks
|
|
244
|
+
Hooks.__send__(:new, server, ref: ref)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def display(format, timeout: 5.0, cancel: nil)
|
|
248
|
+
server.display(format, target: ref, timeout: timeout, cancel: cancel)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
class Session
|
|
253
|
+
# Both allowlists are required for guarded deletion of a changing topology.
|
|
254
|
+
def kill(expected_windows: nil, expected_panes: nil, timeout: 5.0, cancel: nil)
|
|
255
|
+
return super(timeout: timeout, cancel: cancel) if expected_windows.nil? && expected_panes.nil?
|
|
256
|
+
|
|
257
|
+
server.__send__(:kill_session_with_windows, ref, expected_windows, expected_panes, timeout: timeout, cancel: cancel)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def rename(name, timeout: 5.0, cancel: nil)
|
|
261
|
+
server.__send__(:execute_typed, ["rename-session", "-t", target, "--", server.__send__(:literal_name, name)], timeout: timeout, cancel: cancel)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def list_window_links(timeout: 5.0, cancel: nil)
|
|
265
|
+
server.__send__(:acquire_links, ["-t", target], timeout: timeout, cancel: cancel)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def link_window(window, index:, timeout: 5.0, cancel: nil)
|
|
269
|
+
raise ArgumentError, "index must be a nonnegative Integer" unless index.is_a?(Integer) && index >= 0
|
|
270
|
+
|
|
271
|
+
source = server.__send__(:target, window, :window)
|
|
272
|
+
server.__send__(:execute_typed, ["link-window", "-d", "-s", source, "-t", "#{target}:#{index}"], timeout: timeout, cancel: cancel)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def set_environment(name, value, hidden: false, timeout: 5.0, cancel: nil)
|
|
276
|
+
server.__send__(:mutate_environment, ["-t", target], name, value, hidden: hidden, timeout: timeout, cancel: cancel)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def environment(name, hidden: false, timeout: 5.0, cancel: nil)
|
|
280
|
+
server.__send__(:read_environment, ["-t", target], name, hidden: hidden, timeout: timeout, cancel: cancel)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def unset_environment(name, timeout: 5.0, cancel: nil)
|
|
284
|
+
server.__send__(:execute_typed, ["set-environment", "-t", target, "-u", "--", server.__send__(:environment_name, name)], timeout: timeout, cancel: cancel)
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
class Window
|
|
289
|
+
def rename(name, timeout: 5.0, cancel: nil)
|
|
290
|
+
server.__send__(:execute_typed, ["rename-window", "-t", target, "--", server.__send__(:literal_name, name)], timeout: timeout, cancel: cancel)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def respawn(command:, kill: false, cwd: nil, environment: {}, timeout: 5.0, cancel: nil)
|
|
294
|
+
budget = server.__send__(:operation_budget, timeout, cancel)
|
|
295
|
+
args = ["respawn-window", "-t", target, *(kill ? ["-k"] : [])]
|
|
296
|
+
args.concat(server.__send__(:creation_options, cwd: cwd, environment: environment))
|
|
297
|
+
server.__send__(:execute_typed, [*args, "--", *server.__send__(:pane_command, command)], **budget.options)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def resize(width: nil, height: nil, timeout: 5.0, cancel: nil)
|
|
301
|
+
dimensions = {"-x" => width, "-y" => height}.flat_map do |flag, value|
|
|
302
|
+
next [] if value.nil?
|
|
303
|
+
raise ArgumentError, "dimensions must be positive Integers" unless value.is_a?(Integer) && value.positive?
|
|
304
|
+
|
|
305
|
+
[flag, value.to_s]
|
|
306
|
+
end
|
|
307
|
+
raise ArgumentError, "provide width or height" if dimensions.empty?
|
|
308
|
+
|
|
309
|
+
server.__send__(:execute_typed, ["resize-window", "-t", target, *dimensions], timeout: timeout, cancel: cancel)
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
class Pane
|
|
314
|
+
def select(timeout: 5.0, cancel: nil)
|
|
315
|
+
server.__send__(:execute_typed, ["select-pane", "-t", target], timeout: timeout, cancel: cancel)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def resize(width: nil, height: nil, direction: nil, amount: 1, zoom: false, timeout: 5.0, cancel: nil)
|
|
319
|
+
args = ["resize-pane", "-t", target]
|
|
320
|
+
{"-x" => width, "-y" => height}.each do |flag, value|
|
|
321
|
+
next if value.nil?
|
|
322
|
+
raise ArgumentError, "dimensions must be positive Integers" unless value.is_a?(Integer) && value.positive?
|
|
323
|
+
|
|
324
|
+
args.concat([flag, value.to_s])
|
|
325
|
+
end
|
|
326
|
+
if direction
|
|
327
|
+
flag = {left: "-L", right: "-R", up: "-U", down: "-D"}.fetch(direction) { raise ArgumentError, "invalid resize direction" }
|
|
328
|
+
raise ArgumentError, "amount must be a positive Integer" unless amount.is_a?(Integer) && amount.positive?
|
|
329
|
+
|
|
330
|
+
args.concat([flag, amount.to_s])
|
|
331
|
+
end
|
|
332
|
+
args << "-Z" if zoom
|
|
333
|
+
server.__send__(:execute_typed, args, timeout: timeout, cancel: cancel)
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def swap(other, timeout: 5.0, cancel: nil)
|
|
337
|
+
server.__send__(:execute_typed, ["swap-pane", "-d", "-s", target, "-t", server.__send__(:target, other, :pane)], timeout: timeout, cancel: cancel)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def join(other, direction:, before: false, timeout: 5.0, cancel: nil)
|
|
341
|
+
reposition("join-pane", other, direction: direction, before: before, timeout: timeout, cancel: cancel)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def move(other, direction:, before: false, timeout: 5.0, cancel: nil)
|
|
345
|
+
reposition("move-pane", other, direction: direction, before: before, timeout: timeout, cancel: cancel)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def break_out(session:, name:, timeout: 5.0, cancel: nil)
|
|
349
|
+
destination = server.__send__(:target, session, :session)
|
|
350
|
+
server.__send__(:create_entity, :window, ["break-pane", "-d", "-P", "-F", server.__send__(:id_format, :window),
|
|
351
|
+
"-s", target, "-t", "#{destination}:", "-n", server.__send__(:literal_name, name)], timeout: timeout, cancel: cancel)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def respawn(command:, kill: false, cwd: nil, environment: {}, timeout: 5.0, cancel: nil)
|
|
355
|
+
budget = server.__send__(:operation_budget, timeout, cancel)
|
|
356
|
+
args = ["respawn-pane", "-t", target, *(kill ? ["-k"] : [])]
|
|
357
|
+
args.concat(server.__send__(:creation_options, cwd: cwd, environment: environment))
|
|
358
|
+
server.__send__(:execute_typed, [*args, "--", *server.__send__(:pane_command, command)], **budget.options)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def paste(buffer:, delete: false, bracketed: false, separator: nil, timeout: 5.0, cancel: nil)
|
|
362
|
+
args = ["paste-buffer", "-t", target, "-b", buffer]
|
|
363
|
+
args << "-d" if delete
|
|
364
|
+
args << "-p" if bracketed
|
|
365
|
+
args.concat(["-s", separator]) if separator
|
|
366
|
+
server.__send__(:execute_typed, args, timeout: timeout, cancel: cancel)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# The shell command and its format expansion are explicitly requested here.
|
|
370
|
+
# only_if_closed follows tmux -o: an existing pipe is closed, not retained.
|
|
371
|
+
def pipe(shell_command: nil, input: false, output: true, only_if_closed: false, timeout: 5.0, cancel: nil)
|
|
372
|
+
if shell_command && !input && !output
|
|
373
|
+
raise ArgumentError, "a pipe command requires input or output"
|
|
374
|
+
end
|
|
375
|
+
args = ["pipe-pane", "-t", target]
|
|
376
|
+
args << "-I" if input
|
|
377
|
+
args << "-O" if output
|
|
378
|
+
args << "-o" if only_if_closed
|
|
379
|
+
args.concat(["--", shell_command]) if shell_command
|
|
380
|
+
server.__send__(:execute_typed, args, timeout: timeout, cancel: cancel)
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def copy_mode(scroll_up: false, exit_on_bottom: false, mouse_drag: false, cancel_mode: false, page_down: false, source: nil, timeout: 5.0, cancel: nil)
|
|
384
|
+
budget = server.__send__(:operation_budget, timeout, cancel)
|
|
385
|
+
args = ["copy-mode", "-t", target]
|
|
386
|
+
{"u" => scroll_up, "e" => exit_on_bottom, "M" => mouse_drag, "q" => cancel_mode, "d" => page_down}.each do |flag, enabled|
|
|
387
|
+
next unless enabled
|
|
388
|
+
|
|
389
|
+
server.__send__(:require_command_flag, "copy-mode", flag, budget: budget)
|
|
390
|
+
args << "-#{flag}"
|
|
391
|
+
end
|
|
392
|
+
if source
|
|
393
|
+
server.__send__(:require_command_flag, "copy-mode", "s", budget: budget)
|
|
394
|
+
args.concat(["-s", server.__send__(:target, source, :pane)])
|
|
395
|
+
end
|
|
396
|
+
server.__send__(:execute_typed, args, **budget.options)
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def copy_command(command, *arguments, timeout: 5.0, cancel: nil)
|
|
400
|
+
server.__send__(:execute_typed, ["send-keys", "-X", "-t", target, "--", command, *arguments], timeout: timeout, cancel: cancel)
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
private
|
|
404
|
+
|
|
405
|
+
def reposition(command, other, direction:, before:, timeout:, cancel:)
|
|
406
|
+
flag = {horizontal: "-h", vertical: "-v"}.fetch(direction) { raise ArgumentError, "invalid split direction" }
|
|
407
|
+
args = [command, "-d", flag, "-s", target, "-t", server.__send__(:target, other, :pane)]
|
|
408
|
+
args << "-b" if before
|
|
409
|
+
server.__send__(:execute_typed, args, timeout: timeout, cancel: cancel)
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LibTmux
|
|
4
|
+
# Raw bytes remain available even when a caller requests a typed conversion.
|
|
5
|
+
class OptionValue
|
|
6
|
+
attr_reader :name, :index, :raw
|
|
7
|
+
|
|
8
|
+
def initialize(name:, index:, raw:, inherited:, array:, present:)
|
|
9
|
+
@name, @raw = name.dup.freeze, raw.dup.freeze
|
|
10
|
+
@index, @inherited, @array, @present = index, inherited, array, present
|
|
11
|
+
freeze
|
|
12
|
+
end
|
|
13
|
+
private_class_method :new
|
|
14
|
+
|
|
15
|
+
def inherited?
|
|
16
|
+
@inherited
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def array?
|
|
20
|
+
@array
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def present?
|
|
24
|
+
@present
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def as(type)
|
|
28
|
+
case type
|
|
29
|
+
when :bytes then raw
|
|
30
|
+
when :string
|
|
31
|
+
text = raw.dup.force_encoding(Encoding::UTF_8)
|
|
32
|
+
raise FieldDecodeError.new("option is not valid UTF-8", phase: :decode, delivery: :observed) unless text.valid_encoding?
|
|
33
|
+
|
|
34
|
+
text.freeze
|
|
35
|
+
when :integer
|
|
36
|
+
raise FieldDecodeError.new("option is not an integer", phase: :decode, delivery: :observed) unless raw.match?(/\A-?\d+\z/)
|
|
37
|
+
|
|
38
|
+
Integer(raw, 10)
|
|
39
|
+
when :boolean
|
|
40
|
+
return true if ["on", "1"].include?(raw)
|
|
41
|
+
return false if ["off", "0"].include?(raw)
|
|
42
|
+
|
|
43
|
+
raise FieldDecodeError.new("option is not a boolean", phase: :decode, delivery: :observed)
|
|
44
|
+
else raise ArgumentError, "type must be :bytes, :string, :integer or :boolean"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Explicit acquisition and mutation at one option scope.
|
|
50
|
+
class Options
|
|
51
|
+
def initialize(server, ref: nil, scope: nil)
|
|
52
|
+
@server, @ref, @scope = server, ref, scope
|
|
53
|
+
freeze
|
|
54
|
+
end
|
|
55
|
+
private_class_method :new
|
|
56
|
+
|
|
57
|
+
def list(name: nil, inherited: false, timeout: 5.0, cancel: nil)
|
|
58
|
+
if inherited && is_a?(Hooks)
|
|
59
|
+
raise UnsupportedFeatureError.new("inherited hook acquisition is not implemented", phase: :admission)
|
|
60
|
+
end
|
|
61
|
+
command = is_a?(Hooks) ? "show-hooks" : "show-options"
|
|
62
|
+
args = [command, *scope_flags]
|
|
63
|
+
args << "-A" if inherited && !is_a?(Hooks)
|
|
64
|
+
args.concat(["--", option_name(name)]) if name
|
|
65
|
+
result = @server.__send__(:execute_typed, args, timeout: timeout, cancel: cancel)
|
|
66
|
+
result.stdout.lines.map do |line|
|
|
67
|
+
match = /\A([^\s\[\]*]+)(?:\[(\d+)\])?(\*)?(?: (.*))?\n\z/n.match(line)
|
|
68
|
+
raise ProtocolError.new("malformed escaped option record", phase: :decode, delivery: :observed) unless match
|
|
69
|
+
|
|
70
|
+
value = match[4]
|
|
71
|
+
raw = is_a?(Hooks) ? (value || "".b) : decode_escaped(value || "".b)
|
|
72
|
+
OptionValue.__send__(:new, name: match[1], index: match[2] && Integer(match[2], 10), raw: raw,
|
|
73
|
+
inherited: !match[3].nil?, array: !match[2].nil? || value.nil?, present: !value.nil?)
|
|
74
|
+
end.freeze
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def get(name, index: nil, inherited: true, timeout: 5.0, cancel: nil)
|
|
78
|
+
unless index.nil? || (index.is_a?(Integer) && index >= 0)
|
|
79
|
+
raise ArgumentError, "index must be a nonnegative Integer"
|
|
80
|
+
end
|
|
81
|
+
# tmux prints a nonexistent requested index as an empty string. Acquire
|
|
82
|
+
# the array once so absent indexes remain distinct from present empties.
|
|
83
|
+
values = list(name: name, inherited: inherited, timeout: timeout, cancel: cancel)
|
|
84
|
+
values = values.select { |value| value.index == index } unless index.nil?
|
|
85
|
+
if values.empty?
|
|
86
|
+
raise NoMatchError.new("option is absent at the requested scope", phase: :decode, delivery: :observed)
|
|
87
|
+
end
|
|
88
|
+
unless values.length == 1
|
|
89
|
+
raise MultipleMatchesError.new("option has multiple array entries; provide an index", phase: :decode, delivery: :observed)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
values.first
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def set(name, value, index: nil, append: false, timeout: 5.0, cancel: nil)
|
|
96
|
+
value = case value
|
|
97
|
+
when true then "on"
|
|
98
|
+
when false then "off"
|
|
99
|
+
when Integer then value.to_s
|
|
100
|
+
when String then value
|
|
101
|
+
else raise ArgumentError, "option value must be String, Integer or boolean"
|
|
102
|
+
end
|
|
103
|
+
mutate("set-option", name, value, index: index, append: append, timeout: timeout, cancel: cancel)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def unset(name, index: nil, timeout: 5.0, cancel: nil)
|
|
107
|
+
command = is_a?(Hooks) ? "set-hook" : "set-option"
|
|
108
|
+
@server.__send__(:execute_typed, [command, *scope_flags, "-u", "--", option_name(indexed_name(name, index))], timeout: timeout, cancel: cancel)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def scope_flags
|
|
114
|
+
if @ref
|
|
115
|
+
flags = {session: [], window: ["-w"], pane: ["-p"]}.fetch(@ref.kind)
|
|
116
|
+
[*flags, "-t", @server.__send__(:target, @ref, @ref.kind)]
|
|
117
|
+
else
|
|
118
|
+
{server: ["-s"], session: ["-g"], window: ["-g", "-w"]}.fetch(@scope) do
|
|
119
|
+
raise ArgumentError, "scope must be :server, :session or :window"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def option_name(name)
|
|
125
|
+
unless name.is_a?(String) && name.match?(/\A[^\s\0]+\z/)
|
|
126
|
+
raise ArgumentError, "option names cannot contain whitespace or NUL"
|
|
127
|
+
end
|
|
128
|
+
@server.__send__(:literal_name, name)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def indexed_name(name, index)
|
|
132
|
+
return name if index.nil?
|
|
133
|
+
raise ArgumentError, "index must be a nonnegative Integer" unless index.is_a?(Integer) && index >= 0
|
|
134
|
+
|
|
135
|
+
"#{name}[#{index}]"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def mutate(command, name, value, index:, append:, timeout:, cancel:)
|
|
139
|
+
args = [command, *scope_flags]
|
|
140
|
+
args << "-a" if append
|
|
141
|
+
@server.__send__(:execute_typed, [*args, "--", option_name(indexed_name(name, index)), value], timeout: timeout, cancel: cancel)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# show-options uses args_escape (VIS_OCTAL|VIS_CSTYLE), not shell escaping.
|
|
145
|
+
def decode_escaped(value)
|
|
146
|
+
if value.start_with?("'", '"')
|
|
147
|
+
unless value.end_with?(value.byteslice(0, 1)) && value.bytesize >= 2
|
|
148
|
+
raise ProtocolError.new("unterminated escaped option value", phase: :decode, delivery: :observed)
|
|
149
|
+
end
|
|
150
|
+
value = value.byteslice(1, value.bytesize - 2)
|
|
151
|
+
end
|
|
152
|
+
escapes = {"a" => "\a", "b" => "\b", "t" => "\t", "n" => "\n", "v" => "\v", "f" => "\f", "r" => "\r", "s" => " ", "E" => "\e"}
|
|
153
|
+
value.gsub(/\\([0-7]{3}|.)/n) do
|
|
154
|
+
escaped = Regexp.last_match(1)
|
|
155
|
+
if escaped.match?(/\A[0-7]{3}\z/)
|
|
156
|
+
escaped.to_i(8).chr(Encoding::BINARY)
|
|
157
|
+
else
|
|
158
|
+
escapes.fetch(escaped, escaped)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Hook values are executable tmux command strings, never literal data.
|
|
165
|
+
class Hooks < Options
|
|
166
|
+
def get(name, index: nil, inherited: false, timeout: 5.0, cancel: nil)
|
|
167
|
+
super
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def set(name, command:, index: nil, append: false, timeout: 5.0, cancel: nil)
|
|
171
|
+
raise ArgumentError, "command must be a String" unless command.is_a?(String)
|
|
172
|
+
|
|
173
|
+
mutate("set-hook", name, command, index: index, append: append, timeout: timeout, cancel: cancel)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def run(name, timeout: 5.0, cancel: nil)
|
|
177
|
+
@server.__send__(:execute_typed, ["set-hook", *scope_flags, "-R", "--", option_name(name)], timeout: timeout, cancel: cancel)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|