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.
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module LibTmux
6
+ # Replayable captured membership. Snapshot construction owns record values.
7
+ class Selection
8
+ include Enumerable
9
+ OMITTED = Object.new.freeze
10
+ private_constant :OMITTED
11
+
12
+ def initialize(records, entity: nil, graph: nil)
13
+ @records = records.to_a.dup.freeze
14
+ @entity = entity
15
+ @graph = graph
16
+ freeze
17
+ end
18
+
19
+ def each
20
+ return enum_for(:each) { @records.length } unless block_given?
21
+
22
+ @records.each { |record| yield record }
23
+ self
24
+ end
25
+
26
+ def select
27
+ return enum_for(:select) { @records.length } unless block_given?
28
+
29
+ derive(@records.select { |record| yield record })
30
+ end
31
+ alias filter select
32
+ alias find_all select
33
+
34
+ def reject
35
+ return enum_for(:reject) { @records.length } unless block_given?
36
+
37
+ derive(@records.reject { |record| yield record })
38
+ end
39
+
40
+ def to_a
41
+ @records.dup
42
+ end
43
+
44
+ def size
45
+ @records.length
46
+ end
47
+ alias length size
48
+
49
+ def empty?
50
+ @records.empty?
51
+ end
52
+
53
+ def where(criteria = OMITTED, **keywords, &block)
54
+ require_relative "criteria"
55
+ raise ArgumentError, "where does not accept a block" if block
56
+ raise InvalidFilterError.new(expected: "selection with an entity schema") unless @entity
57
+ unless criteria.equal?(OMITTED) || keywords.empty?
58
+ raise ArgumentError, "use positional or keyword criteria, not both"
59
+ end
60
+ criteria = keywords if criteria.equal?(OMITTED)
61
+ expression = FilterExpr.build(@entity, criteria)
62
+ derive(expression.__send__(:select_records, @records))
63
+ end
64
+
65
+ def one(criteria = OMITTED, **keywords, &block)
66
+ selection = retrieval_selection(criteria, keywords, block)
67
+ raise NoMatchError, "selection has no matches" if selection.empty?
68
+ raise MultipleMatchesError, "selection has at least two matches" if selection.size > 1
69
+
70
+ selection.first
71
+ end
72
+
73
+ def one_or_nil(criteria = OMITTED, **keywords, &block)
74
+ selection = retrieval_selection(criteria, keywords, block)
75
+ selection.empty? ? nil : selection.one
76
+ end
77
+
78
+ def exists?(criteria = OMITTED, **keywords, &block)
79
+ !retrieval_selection(criteria, keywords, block).empty?
80
+ end
81
+
82
+ private
83
+
84
+ def derive(records)
85
+ self.class.new(records, entity: @entity, graph: @graph)
86
+ end
87
+
88
+ def retrieval_selection(criteria, keywords, block)
89
+ raise ArgumentError, "retrieval methods do not accept a block" if block
90
+ return keywords.empty? ? self : where(**keywords) if criteria.equal?(OMITTED)
91
+
92
+ where(criteria, **keywords)
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,534 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "endpoint"
4
+ require_relative "process"
5
+ require_relative "metadata"
6
+ require_relative "entity"
7
+
8
+ module LibTmux
9
+ # Connects to an existing endpoint. Closing retires clients, never the daemon.
10
+ class Server
11
+ attr_reader :endpoint
12
+
13
+ def self.open(**options)
14
+ return new(**options) unless block_given?
15
+
16
+ failure = nil
17
+ result = nil
18
+ begin
19
+ Thread.handle_interrupt(Exception => :never) do
20
+ server = nil
21
+ begin
22
+ server = new(**options)
23
+ Thread.handle_interrupt(Exception => :immediate) { result = yield server }
24
+ rescue Exception => error
25
+ failure = error
26
+ ensure
27
+ begin
28
+ server&.close
29
+ rescue Exception => cleanup
30
+ if failure.is_a?(Error)
31
+ failure.__send__(:attach_cleanup_errors, ["server close failed (#{cleanup.class})"])
32
+ end
33
+ failure ||= cleanup
34
+ end
35
+ end
36
+ end
37
+ rescue Exception => deferred
38
+ failure ||= deferred
39
+ end
40
+ raise failure if failure
41
+
42
+ result
43
+ end
44
+
45
+ def initialize(endpoint: nil, socket_path: nil, socket_name: nil, executable: "tmux", max_requests: 32, max_controls: 4, close_timeout: 0.5)
46
+ if endpoint && (socket_path || socket_name || executable != "tmux")
47
+ raise ArgumentError, "endpoint cannot be combined with socket or executable options"
48
+ end
49
+ raise ArgumentError, "endpoint must be an Endpoint" if endpoint && !endpoint.is_a?(Endpoint)
50
+ unless max_requests.is_a?(Integer) && max_requests.positive?
51
+ raise ArgumentError, "max_requests must be a positive Integer"
52
+ end
53
+ unless max_controls.is_a?(Integer) && max_controls.positive?
54
+ raise ArgumentError, "max_controls must be a positive Integer"
55
+ end
56
+ unless close_timeout.is_a?(Numeric) && close_timeout.finite? && close_timeout.positive?
57
+ raise ArgumentError, "close_timeout must be positive and finite"
58
+ end
59
+
60
+ @endpoint = endpoint || Endpoint.new(socket_path: socket_path, socket_name: socket_name, executable: executable)
61
+ @owner_pid = Process.pid
62
+ @max_requests = max_requests
63
+ @max_controls = max_controls
64
+ @close_timeout = close_timeout
65
+ @mutex = Mutex.new
66
+ @idle = ConditionVariable.new
67
+ @requests = {}
68
+ @controls = []
69
+ @closed = false
70
+ @executor = Internal::ProcessExecutor.new
71
+ @pin = Internal::SocketIdentity.new(@endpoint)
72
+ end
73
+
74
+ # Raw tmux arguments retain tmux's separators and format semantics.
75
+ def run(argv, input: "".b, timeout: 5.0, cancel: nil)
76
+ ensure_owner
77
+ started = monotonic
78
+ validate_argv(argv)
79
+ raise ArgumentError, "raw commands cannot override endpoint flags" if argv.first.start_with?("-")
80
+ raise ArgumentError, "timeout must be finite" unless timeout.is_a?(Numeric) && timeout.finite?
81
+ perform_request(cancel: cancel) do |view|
82
+ @executor.run(@pin.command_prefix + argv, input: input,
83
+ timeout: timeout - (monotonic - started), cancel: view)
84
+ end
85
+ end
86
+
87
+ def close
88
+ if Process.pid != @owner_pid
89
+ @requests.each_key(&:close)
90
+ @controls.each(&:close)
91
+ @requests = {}
92
+ @controls = []
93
+ @closed = true
94
+ @pin.close
95
+ return nil
96
+ end
97
+
98
+ Thread.handle_interrupt(Exception => :never) do
99
+ @mutex.synchronize do
100
+ if @requests.value?(Thread.current)
101
+ raise ClosedError.new("cannot close a server from its active request", phase: :retire)
102
+ end
103
+ @closed = true
104
+ deadline = monotonic + @close_timeout
105
+ @controls.each { |control| control.__send__(:request_close) }
106
+ @requests.each_key(&:cancel)
107
+ until @requests.empty?
108
+ remaining = deadline - monotonic
109
+ unless remaining.positive?
110
+ raise DeadlineExceeded.new("server clients have not retired; retry close", phase: :retire, delivery: :possibly_sent)
111
+ end
112
+ @idle.wait(@mutex, remaining)
113
+ end
114
+ @controls.dup.each do |control|
115
+ control.close(timeout: (deadline - monotonic).clamp(0, 0.5))
116
+ @controls.delete(control)
117
+ end
118
+ @pin.close
119
+ end
120
+ end
121
+ nil
122
+ end
123
+
124
+ # Returns a frozen local snapshot, including after close. Slots are admitted
125
+ # requests; control connections are retained registrations, not OS clients.
126
+ def diagnostics
127
+ ensure_owner
128
+ @mutex.synchronize do
129
+ {admitted_requests: @requests.length, reserved_process_slots: @requests.length,
130
+ control_connections: @controls.length, closed: @closed,
131
+ limits: {max_requests: @max_requests, max_controls: @max_controls,
132
+ close_timeout: @close_timeout}.freeze}.freeze
133
+ end
134
+ end
135
+
136
+ def kill(timeout: 5.0, cancel: nil)
137
+ execute_typed(["kill-server"], timeout: timeout, cancel: cancel)
138
+ end
139
+
140
+ def new_session(name:, command:, width: nil, height: nil, window_name: nil, cwd: nil, environment: {}, receipt: false, timeout: 5.0, cancel: nil)
141
+ budget = operation_budget(timeout, cancel)
142
+ arguments = ["new-session", "-d", "-P", "-F", creation_format(:session, receipt), "-s", literal_name(name)]
143
+ arguments.concat(["-n", literal_name(window_name)]) if window_name
144
+ {"-x" => width, "-y" => height}.each do |flag, value|
145
+ next if value.nil?
146
+ raise ArgumentError, "dimensions must be positive integers" unless value.is_a?(Integer) && value.positive?
147
+
148
+ arguments.concat([flag, value.to_s])
149
+ end
150
+ arguments.concat(creation_options(cwd: cwd, environment: environment))
151
+ create_entity(:session, arguments + ["--"] + pane_command(command), receipt: receipt, **budget.options)
152
+ end
153
+
154
+ def list_sessions(timeout: 5.0, cancel: nil)
155
+ list_entities(:session, [], global: true, timeout: timeout, cancel: cancel)
156
+ end
157
+
158
+ def list_windows(timeout: 5.0, cancel: nil)
159
+ list_entities(:window, ["-a"], global: true, timeout: timeout, cancel: cancel)
160
+ end
161
+
162
+ def list_panes(timeout: 5.0, cancel: nil)
163
+ list_entities(:pane, ["-a"], global: true, timeout: timeout, cancel: cancel)
164
+ end
165
+
166
+ def session(ref)
167
+ resolve(ref, :session)
168
+ end
169
+
170
+ def window(ref)
171
+ resolve(ref, :window)
172
+ end
173
+
174
+ def pane(ref)
175
+ resolve(ref, :pane)
176
+ end
177
+
178
+ def open_control(session:, **options)
179
+ session_id = target(session, :session)
180
+ failure = nil
181
+ result = nil
182
+ connection = nil
183
+ keep = false
184
+ begin
185
+ Thread.handle_interrupt(Exception => :never) do
186
+ begin
187
+ @mutex.synchronize do
188
+ ensure_open
189
+ @controls.reject!(&:closed?)
190
+ if @controls.length >= @max_controls
191
+ raise CapacityError.new("server control capacity is exhausted", phase: :admission)
192
+ end
193
+ connection = ControlConnection.new(binding: @pin, session_id: session_id, **options)
194
+ @controls << connection
195
+ end
196
+ if block_given?
197
+ Thread.handle_interrupt(Exception => :immediate) { result = yield connection }
198
+ else
199
+ Thread.handle_interrupt(Exception => :immediate) { nil }
200
+ result = connection
201
+ keep = true
202
+ end
203
+ rescue Exception => error
204
+ failure = error
205
+ ensure
206
+ if connection && !keep
207
+ begin
208
+ connection.close(timeout: [@close_timeout, 0.5].min)
209
+ @mutex.synchronize { @controls.delete(connection) }
210
+ rescue Exception => cleanup
211
+ failure.__send__(:attach_cleanup_errors, ["control close failed (#{cleanup.class})"]) if failure.is_a?(Error)
212
+ failure ||= cleanup
213
+ end
214
+ end
215
+ end
216
+ end
217
+ rescue Exception => deferred
218
+ failure ||= deferred
219
+ end
220
+ raise failure if failure
221
+
222
+ result
223
+ end
224
+
225
+ def snapshot(**options)
226
+ ensure_owner
227
+ @mutex.synchronize { ensure_open }
228
+ Internal::Capture.new(self, binding_key: @pin.key).call(**options)
229
+ end
230
+
231
+ def search_panes(where: {}, pushdown: :auto, **options)
232
+ Internal::SourceQuery.new(:pane, where: where, pushdown: pushdown).execute(self, **options)
233
+ end
234
+
235
+ def explain_panes(where: {}, pushdown: :auto)
236
+ Internal::SourceQuery.new(:pane, where: where, pushdown: pushdown).explain
237
+ end
238
+
239
+ private
240
+
241
+ class OperationBudget
242
+ def initialize(timeout, cancel, clock)
243
+ raise ArgumentError, "timeout must be finite" unless timeout.is_a?(Numeric) && timeout.finite?
244
+ if cancel && (!cancel.respond_to?(:reader) || !cancel.respond_to?(:cancelled?))
245
+ raise ArgumentError, "cancel must provide a cancellation reader and state"
246
+ end
247
+ @clock, @cancel = clock, cancel
248
+ @deadline = clock.call + timeout
249
+ @dispatched = false
250
+ end
251
+
252
+ def options
253
+ delivery = @dispatched ? :possibly_sent : :not_sent
254
+ raise Cancelled.new("operation was cancelled", phase: :admission, delivery: delivery) if @cancel&.cancelled?
255
+
256
+ remaining = @deadline - @clock.call
257
+ unless remaining.positive?
258
+ raise DeadlineExceeded.new("operation deadline elapsed", phase: :admission, delivery: delivery)
259
+ end
260
+ @dispatched = true
261
+ {timeout: remaining, cancel: @cancel}
262
+ end
263
+ end
264
+ private_constant :OperationBudget
265
+
266
+ def operation_budget(timeout, cancel)
267
+ OperationBudget.new(timeout, cancel, method(:monotonic))
268
+ end
269
+
270
+ class CancellationView
271
+ def initialize(owned, external)
272
+ @owned = owned
273
+ @external = external
274
+ end
275
+
276
+ def reader
277
+ @owned.reader
278
+ end
279
+
280
+ def cancelled?
281
+ @owned.cancelled? || !!@external&.cancelled?
282
+ end
283
+ end
284
+ private_constant :CancellationView
285
+
286
+ def monotonic
287
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
288
+ end
289
+
290
+ def ensure_owner
291
+ unless Process.pid == @owner_pid
292
+ raise ClosedError.new("server belongs to another process", phase: :admission)
293
+ end
294
+ end
295
+
296
+ # Borrowing adapters may copy these references, but must not suspend or do I/O.
297
+ def with_bound_endpoint
298
+ ensure_owner
299
+ @mutex.synchronize do
300
+ ensure_open
301
+ yield @endpoint, @pin
302
+ end
303
+ end
304
+
305
+ def perform_request(cancel:)
306
+ ensure_owner
307
+ if cancel && (!cancel.respond_to?(:reader) || !cancel.respond_to?(:cancelled?))
308
+ raise ArgumentError, "cancel must provide a cancellation reader and state"
309
+ end
310
+
311
+ failure = nil
312
+ result = nil
313
+ begin
314
+ Thread.handle_interrupt(Exception => :never) do
315
+ owned = nil
316
+ watcher = nil
317
+ begin
318
+ @mutex.synchronize do
319
+ ensure_open
320
+ if @requests.length >= @max_requests
321
+ raise CapacityError.new("server request capacity is exhausted", phase: :admission)
322
+ end
323
+ owned = Internal::Cancellation.new
324
+ @requests[owned] = Thread.current
325
+ end
326
+ if cancel
327
+ watcher = Thread.new do
328
+ Thread.current.report_on_exception = false
329
+ IO.select([cancel.reader, owned.reader]) unless cancel.cancelled?
330
+ owned.cancel if cancel.cancelled?
331
+ rescue IOError, SystemCallError
332
+ owned.cancel
333
+ end
334
+ end
335
+ view = CancellationView.new(owned, cancel)
336
+ Thread.handle_interrupt(Exception => :immediate) do
337
+ result = yield view
338
+ end
339
+ rescue Exception => error
340
+ failure = error
341
+ ensure
342
+ cleanup_errors = retire_request(owned, watcher) if owned
343
+ if cleanup_errors && !cleanup_errors.empty?
344
+ if failure.is_a?(Error)
345
+ failure.__send__(:attach_cleanup_errors, cleanup_errors)
346
+ elsif failure.nil?
347
+ failure = TransportError.new("server request cleanup failed", phase: :retire,
348
+ delivery: result ? :observed : :possibly_sent, cleanup_errors: cleanup_errors)
349
+ end
350
+ end
351
+ end
352
+ end
353
+ rescue Exception => deferred
354
+ failure ||= deferred
355
+ end
356
+ raise failure if failure
357
+
358
+ result
359
+ end
360
+
361
+ def retire_request(owned, watcher)
362
+ errors = []
363
+ begin
364
+ owned.cancel
365
+ watcher&.join(@close_timeout) || errors << "cancellation watcher did not retire" if watcher
366
+ rescue Exception => error
367
+ errors << "cancellation watcher failed (#{error.class})"
368
+ ensure
369
+ begin
370
+ owned.close
371
+ rescue Exception => error
372
+ errors << "cancellation descriptors failed to close (#{error.class})"
373
+ ensure
374
+ @mutex.synchronize do
375
+ @requests.delete(owned)
376
+ @idle.broadcast
377
+ end
378
+ end
379
+ end
380
+ errors
381
+ end
382
+
383
+ def ensure_open
384
+ raise ClosedError.new("server is closed", phase: :admission) if @closed
385
+ end
386
+
387
+ def validate_argv(argv)
388
+ unless argv.is_a?(Array) && !argv.empty? && argv.all? { |value| value.is_a?(String) && !value.include?("\0") }
389
+ raise ArgumentError, "argv must be a nonempty Array of Strings without NUL"
390
+ end
391
+ end
392
+
393
+ def execute_typed(arguments, **options)
394
+ result = run(encode_arguments(arguments), **options)
395
+ raise CommandError.new(result: result, phase: :command) unless result.success?
396
+
397
+ result
398
+ end
399
+
400
+ def encode_arguments(arguments)
401
+ validate_argv(arguments)
402
+ # cmd_parse_from_arguments consumes one backslash before a final ';'.
403
+ arguments.map { |value| value.end_with?(";") ? value[0...-1] + "\\;" : value }
404
+ end
405
+
406
+ def literal_name(name)
407
+ unless name.is_a?(String) && !name.empty? && !name.include?("\0")
408
+ raise ArgumentError, "name must be a nonempty String without NUL"
409
+ end
410
+ name.gsub("#", "##")
411
+ end
412
+
413
+ def pane_command(command)
414
+ validate_argv(command)
415
+ raise ArgumentError, "command executable must not be empty" if command.first.empty?
416
+
417
+ # A single tmux argument would invoke $SHELL -c instead of execvp.
418
+ command.length == 1 ? ["/usr/bin/env", "--", *command] : command
419
+ end
420
+
421
+ def target(ref, kind)
422
+ ensure_owner
423
+ @mutex.synchronize { ensure_open }
424
+ unless ref.is_a?(EntityRef) && ref.kind == kind && ref.binding_key == @pin.key
425
+ raise TargetNotFoundError.new("target does not belong to this server binding", phase: :admission)
426
+ end
427
+ ref.id
428
+ end
429
+
430
+ def resolve(ref, kind)
431
+ target(ref, kind)
432
+ entity_class(kind).__send__(:new, self, ref)
433
+ end
434
+
435
+ def entity_class(kind)
436
+ {session: Session, window: Window, pane: Pane, window_link: WindowLink}.fetch(kind)
437
+ end
438
+
439
+ def build_entity(kind, id)
440
+ ref = EntityRef.__send__(:new, binding_key: @pin.key, kind: kind, id: id)
441
+ entity_class(kind).__send__(:new, self, ref)
442
+ end
443
+
444
+ def id_format(kind)
445
+ "\#{n:#{kind}_id}:\#{#{kind}_id}"
446
+ end
447
+
448
+ def creation_format(kind, receipt)
449
+ raise ArgumentError, "receipt must be Boolean" unless [true, false].include?(receipt)
450
+ return id_format(kind) unless receipt
451
+
452
+ kinds = kind == :session ? %i[session window pane] : %i[window pane]
453
+ kinds.map { |child| id_format(child) }.join
454
+ end
455
+
456
+ def create_entity(kind, arguments, receipt: false, **options)
457
+ result = execute_typed(arguments, **options)
458
+ kinds = receipt ? (kind == :session ? %i[session window pane] : %i[window pane]) : [kind]
459
+ rows = Internal::Metadata.decode(result.stdout, fields: kinds.length, max_rows: 1)
460
+ raise ProtocolError.new("tmux did not return the created #{kind} ID", delivery: :observed, phase: :decode) unless rows.length == 1
461
+
462
+ entities = kinds.zip(rows.first).to_h { |child, id| [child, build_entity(child, id)] }
463
+ return entities.fetch(kind) unless receipt
464
+
465
+ CreationReceipt.__send__(:new, entity: entities.fetch(kind), window: entities.fetch(:window),
466
+ pane: entities.fetch(:pane), result: result)
467
+ end
468
+
469
+ def list_entities(kind, options, global: false, timeout: 5.0, cancel: nil)
470
+ result = execute_typed(["list-#{kind}s", *options, "-F", id_format(kind)], timeout: timeout, cancel: cancel)
471
+ entities = Internal::Metadata.decode(result.stdout, fields: 1).map { |row| build_entity(kind, row.first) }
472
+ entities = entities.uniq.sort_by { |entity| entity.id[1..].to_i } if global
473
+ entities.freeze
474
+ end
475
+
476
+ def creation_options(cwd:, environment:)
477
+ arguments = []
478
+ if cwd
479
+ unless cwd.is_a?(String) && !cwd.empty? && !cwd.include?("\0")
480
+ raise ArgumentError, "cwd must be a nonempty String without NUL"
481
+ end
482
+ directory = File.expand_path(cwd)
483
+ raise ArgumentError, "cwd must name an existing accessible directory" unless File.directory?(directory) && File.executable?(directory)
484
+
485
+ # tmux expands -c as a format, while -e values are literal.
486
+ arguments.concat(["-c", directory.gsub("#", "##")])
487
+ end
488
+ raise ArgumentError, "environment must be a Hash" unless environment.is_a?(Hash)
489
+
490
+ environment.each do |name, value|
491
+ environment_name(name)
492
+ unless value.is_a?(String) && !value.include?("\0")
493
+ raise ArgumentError, "environment values must be Strings without NUL"
494
+ end
495
+ arguments.concat(["-e", "#{name}=#{value}"])
496
+ end
497
+ arguments
498
+ end
499
+
500
+ def create_window(ref, name:, command:, index: nil, cwd: nil, environment: {}, focus: false, receipt: false, timeout: 5.0, cancel: nil)
501
+ budget = operation_budget(timeout, cancel)
502
+ unless index.nil? || (index.is_a?(Integer) && index.between?(0, (1 << 31) - 1))
503
+ raise ArgumentError, "index must be a nonnegative 32-bit Integer"
504
+ end
505
+ raise ArgumentError, "focus must be boolean" unless [true, false].include?(focus)
506
+
507
+ destination = target(ref, :session)
508
+ destination += ":#{index}" unless index.nil?
509
+ arguments = ["new-window", *(focus ? [] : ["-d"]), "-P", "-F", creation_format(:window, receipt), "-t", destination, "-n", literal_name(name)]
510
+ arguments.concat(creation_options(cwd: cwd, environment: environment))
511
+ create_entity(:window, arguments + ["--"] + pane_command(command), receipt: receipt, **budget.options)
512
+ end
513
+
514
+ def split_window(ref, direction:, command:, size: nil, cwd: nil, environment: {}, focus: false, timeout: 5.0, cancel: nil)
515
+ budget = operation_budget(timeout, cancel)
516
+ flag = {horizontal: "-h", vertical: "-v"}.fetch(direction) do
517
+ raise ArgumentError, "direction must be :horizontal or :vertical"
518
+ end
519
+ unless size.nil? || (size.is_a?(Integer) && size.between?(1, (1 << 31) - 1)) ||
520
+ (size.is_a?(String) && size.match?(/\A(?:[1-9][0-9]?|100)%\z/))
521
+ raise ArgumentError, "size must be positive cells or a percentage from 1% to 100%"
522
+ end
523
+ raise ArgumentError, "focus must be boolean" unless [true, false].include?(focus)
524
+ unless ref.is_a?(EntityRef) && [:window, :pane].include?(ref.kind)
525
+ raise ArgumentError, "split target must be a window or pane ref"
526
+ end
527
+
528
+ arguments = ["split-window", *(focus ? [] : ["-d"]), flag, "-P", "-F", id_format(:pane), "-t", target(ref, ref.kind)]
529
+ arguments.concat(["-l", size.to_s]) if size
530
+ arguments.concat(creation_options(cwd: cwd, environment: environment))
531
+ create_entity(:pane, arguments + ["--"] + pane_command(command), **budget.options)
532
+ end
533
+ end
534
+ end