foruiman 0.1.2 → 0.3.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 +4 -4
- data/CHANGELOG.md +22 -0
- data/README.md +21 -4
- data/docs/ARCHITECTURE.md +24 -6
- data/docs/COMPATIBILITY.md +62 -14
- data/docs/RAILS.md +19 -2
- data/lib/foruiman/ansi.rb +8 -3
- data/lib/foruiman/cli.rb +41 -20
- data/lib/foruiman/configuration.rb +101 -0
- data/lib/foruiman/engine.rb +135 -21
- data/lib/foruiman/env.rb +5 -2
- data/lib/foruiman/output.rb +22 -4
- data/lib/foruiman/output_line.rb +100 -0
- data/lib/foruiman/process.rb +1 -1
- data/lib/foruiman/tui/application.rb +78 -3
- data/lib/foruiman/tui/input_line.rb +217 -0
- data/lib/foruiman/tui/keyboard.rb +1 -1
- data/lib/foruiman/tui/log_formatter.rb +2 -0
- data/lib/foruiman/tui/renderer.rb +48 -11
- data/lib/foruiman/tui/state.rb +12 -1
- data/lib/foruiman/tui/theme.rb +2 -2
- data/lib/foruiman/version.rb +1 -1
- metadata +5 -2
data/lib/foruiman/engine.rb
CHANGED
|
@@ -11,29 +11,39 @@ class Foruiman::Engine
|
|
|
11
11
|
TERM_TIMEOUT = 5.0
|
|
12
12
|
READ_CHUNK = 4096
|
|
13
13
|
READ_BUDGET = 64 * 1024
|
|
14
|
+
INPUT_BUDGET = 64 * 1024
|
|
14
15
|
State = Struct.new(:name, :process, :port, :pid, :pgid, :status, :exit_status,
|
|
15
16
|
:generation, :restart_pending, :deadline, :reaped, :group_gone,
|
|
17
|
+
:input, :input_buffer,
|
|
16
18
|
keyword_init: true)
|
|
17
19
|
Event = Data.define(:type, :name, :pid, :status, :record, :message)
|
|
18
20
|
|
|
19
|
-
attr_reader :logs, :env, :processes, :root, :procfile_path
|
|
21
|
+
attr_reader :logs, :env, :processes, :root, :procfile_path, :term_timeout, :exit_on
|
|
20
22
|
|
|
21
|
-
def initialize(procfile: nil, root: Dir.pwd, env: ENV.to_h, port: 5000, log_lines: 10_000,
|
|
22
|
-
term_timeout: TERM_TIMEOUT)
|
|
23
|
+
def initialize(procfile: nil, root: Dir.pwd, env: ENV.to_h, input: $stdin, port: 5000, log_lines: 10_000,
|
|
24
|
+
term_timeout: TERM_TIMEOUT, exit_on: :all)
|
|
23
25
|
raise Foruiman::Error, "port must be an integer in 1..65535" unless port.is_a?(Integer) && (1..65_535).cover?(port)
|
|
24
26
|
raise Foruiman::Error, "log-lines must be a positive integer" unless log_lines.is_a?(Integer) && log_lines.positive?
|
|
27
|
+
unless term_timeout.is_a?(Numeric) && term_timeout.real? && term_timeout.finite? && term_timeout >= 0
|
|
28
|
+
raise Foruiman::Error, "timeout must be a finite nonnegative number"
|
|
29
|
+
end
|
|
30
|
+
raise Foruiman::Error, "exit-on must be all, any, or failure" unless %i[all any failure].include?(exit_on)
|
|
25
31
|
|
|
26
32
|
@root = File.expand_path(root)
|
|
27
33
|
raise Foruiman::Error, "working directory does not exist: #{@root}" unless File.directory?(@root)
|
|
28
34
|
|
|
29
35
|
@env = env.dup.freeze
|
|
36
|
+
@input = input
|
|
37
|
+
@managed_input = false
|
|
30
38
|
@base_port = port
|
|
31
39
|
@log_lines = log_lines
|
|
32
40
|
@term_timeout = term_timeout
|
|
41
|
+
@exit_on = exit_on
|
|
33
42
|
@processes = []
|
|
34
43
|
@names = {}
|
|
35
44
|
@running = {}
|
|
36
45
|
@readers = {}
|
|
46
|
+
@inputs = {}
|
|
37
47
|
@listeners = []
|
|
38
48
|
@shutdown = false
|
|
39
49
|
@explicit_shutdown = false
|
|
@@ -57,7 +67,8 @@ class Foruiman::Engine
|
|
|
57
67
|
Foruiman::Procfile.new[name] = command
|
|
58
68
|
process = Foruiman::Process.new(command, cwd: root, env: env)
|
|
59
69
|
state = State.new(name: name.freeze, process: process, port: @base_port + (processes.size * 100),
|
|
60
|
-
status: :pending, generation: 0, restart_pending: false, reaped: true, group_gone: true
|
|
70
|
+
status: :pending, generation: 0, restart_pending: false, reaped: true, group_gone: true,
|
|
71
|
+
input_buffer: +"".b)
|
|
61
72
|
@names[name] = state
|
|
62
73
|
processes << state
|
|
63
74
|
state
|
|
@@ -100,6 +111,16 @@ class Foruiman::Engine
|
|
|
100
111
|
self
|
|
101
112
|
end
|
|
102
113
|
|
|
114
|
+
# Give every child a dedicated pseudo-terminal for stdin. The TUI remains the
|
|
115
|
+
# sole reader of the real terminal and explicitly forwards input to one child.
|
|
116
|
+
def manage_input!
|
|
117
|
+
raise Foruiman::Error, "cannot change input mode after startup" if @started
|
|
118
|
+
|
|
119
|
+
require "pty"
|
|
120
|
+
@managed_input = true
|
|
121
|
+
self
|
|
122
|
+
end
|
|
123
|
+
|
|
103
124
|
def start(name = nil)
|
|
104
125
|
raise Foruiman::Error, "supervisor is closed" if @closed
|
|
105
126
|
return if @shutdown
|
|
@@ -145,6 +166,33 @@ class Foruiman::Engine
|
|
|
145
166
|
terminate(entry)
|
|
146
167
|
end
|
|
147
168
|
|
|
169
|
+
def write_input(name, bytes)
|
|
170
|
+
entry = state(name)
|
|
171
|
+
return false unless entry.status == :running && entry.input && !entry.input.closed?
|
|
172
|
+
return false if entry.input_buffer.bytesize + bytes.bytesize > INPUT_BUDGET
|
|
173
|
+
|
|
174
|
+
entry.input_buffer << bytes.b
|
|
175
|
+
flush_input(entry)
|
|
176
|
+
rescue IOError, SystemCallError
|
|
177
|
+
false
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def interrupt_process(name)
|
|
181
|
+
entry = state(name)
|
|
182
|
+
return unless entry.pgid
|
|
183
|
+
|
|
184
|
+
signal_group(entry, :INT)
|
|
185
|
+
self
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def resize_inputs(rows, columns)
|
|
189
|
+
processes.each do |entry|
|
|
190
|
+
entry.input&.winsize = [rows, columns]
|
|
191
|
+
rescue IOError, SystemCallError
|
|
192
|
+
nil
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
148
196
|
def shutdown(explicit: true)
|
|
149
197
|
@explicit_shutdown ||= explicit
|
|
150
198
|
return if @shutdown
|
|
@@ -165,7 +213,9 @@ class Foruiman::Engine
|
|
|
165
213
|
end
|
|
166
214
|
|
|
167
215
|
def exit_code
|
|
168
|
-
|
|
216
|
+
return 0 if @explicit_shutdown
|
|
217
|
+
|
|
218
|
+
@policy_exit_code || (@failed ? 1 : 0)
|
|
169
219
|
end
|
|
170
220
|
|
|
171
221
|
def run(keep_open: false)
|
|
@@ -189,9 +239,11 @@ class Foruiman::Engine
|
|
|
189
239
|
shutdown if @signal_requested
|
|
190
240
|
reap_children
|
|
191
241
|
advance_groups
|
|
192
|
-
|
|
242
|
+
writable = processes.filter_map { |entry| entry.input unless entry.input_buffer.empty? }
|
|
243
|
+
ready, writable = IO.select([@self_reader, *@readers.keys], writable, nil, timeout) || [[], []]
|
|
193
244
|
drain_signal_pipe if ready.delete(@self_reader)
|
|
194
245
|
shutdown if @signal_requested
|
|
246
|
+
writable.each { |input| flush_input(@inputs.fetch(input)) if @inputs.key?(input) }
|
|
195
247
|
read_output(ready)
|
|
196
248
|
reap_children
|
|
197
249
|
advance_groups
|
|
@@ -224,16 +276,20 @@ class Foruiman::Engine
|
|
|
224
276
|
|
|
225
277
|
stdout_reader, stdout_writer = create_pipe
|
|
226
278
|
stderr_reader, stderr_writer = create_pipe
|
|
279
|
+
input_master, input_slave = create_managed_input if @managed_input
|
|
227
280
|
begin
|
|
228
281
|
pid = entry.process.run(output: stdout_writer, error: stderr_writer,
|
|
282
|
+
input: input_slave || @input,
|
|
229
283
|
env: { "PORT" => entry.port.to_s, "PS" => "#{entry.name}.1" })
|
|
230
284
|
rescue SystemCallError => e
|
|
231
285
|
stdout_reader.close
|
|
232
286
|
stderr_reader.close
|
|
287
|
+
input_master&.close
|
|
233
288
|
entry.status = :failed
|
|
234
289
|
entry.restart_pending = false
|
|
235
290
|
@failed = true
|
|
236
291
|
lifecycle(entry, :failed, "failed to start: #{e.message}")
|
|
292
|
+
apply_exit_policy(1)
|
|
237
293
|
return
|
|
238
294
|
end
|
|
239
295
|
entry.pid = entry.pgid = pid
|
|
@@ -243,12 +299,19 @@ class Foruiman::Engine
|
|
|
243
299
|
entry.restart_pending = false
|
|
244
300
|
entry.deadline = nil
|
|
245
301
|
entry.reaped = entry.group_gone = false
|
|
302
|
+
entry.input = input_master
|
|
303
|
+
entry.input_buffer.clear
|
|
246
304
|
@running[pid] = entry
|
|
247
305
|
[[stdout_reader, :stdout], [stderr_reader, :stderr]].each do |reader, stream|
|
|
248
306
|
@readers[reader] = [entry, Foruiman::Output.new(logs, name: entry.name, stream: stream, pid: pid)]
|
|
249
307
|
end
|
|
308
|
+
if input_master
|
|
309
|
+
@inputs[input_master] = entry
|
|
310
|
+
@readers[input_master] = [entry, Foruiman::Output.new(logs, name: entry.name, stream: :stdin, pid: pid)]
|
|
311
|
+
end
|
|
250
312
|
lifecycle(entry, :started, "started with pid #{pid} (generation #{entry.generation})")
|
|
251
313
|
ensure
|
|
314
|
+
input_slave&.close
|
|
252
315
|
stdout_writer&.close
|
|
253
316
|
stderr_writer&.close
|
|
254
317
|
[stdout_reader, stderr_reader].compact.each do |reader|
|
|
@@ -278,13 +341,22 @@ class Foruiman::Engine
|
|
|
278
341
|
entry.reaped = true
|
|
279
342
|
entry.exit_status = result.last
|
|
280
343
|
success = entry.exit_status.success?
|
|
281
|
-
|
|
282
|
-
|
|
344
|
+
transitioning = %i[stopping restarting].include?(entry.status)
|
|
345
|
+
@failed ||= !success && !transitioning
|
|
346
|
+
entry.status = success ? :exited : :failed unless transitioning
|
|
283
347
|
lifecycle(entry, :exited, termination_message_for(entry.exit_status))
|
|
348
|
+
apply_exit_policy(entry.exit_status.exitstatus || (128 + entry.exit_status.termsig)) unless transitioning
|
|
284
349
|
terminate(entry)
|
|
285
350
|
end
|
|
286
351
|
end
|
|
287
352
|
|
|
353
|
+
def apply_exit_policy(code)
|
|
354
|
+
return if @shutdown || exit_on == :all || (exit_on == :failure && code.zero?)
|
|
355
|
+
|
|
356
|
+
@policy_exit_code = code
|
|
357
|
+
shutdown(explicit: false)
|
|
358
|
+
end
|
|
359
|
+
|
|
288
360
|
def advance_groups
|
|
289
361
|
processes.each do |entry|
|
|
290
362
|
next unless entry.pgid
|
|
@@ -299,6 +371,7 @@ class Foruiman::Engine
|
|
|
299
371
|
next unless entry.reaped && entry.group_gone
|
|
300
372
|
next if @readers.any? { |_reader, (owner, _output)| owner.equal?(entry) }
|
|
301
373
|
|
|
374
|
+
close_input(entry)
|
|
302
375
|
entry.pgid = nil
|
|
303
376
|
entry.deadline = nil
|
|
304
377
|
entry.status = :stopped if entry.status == :stopping
|
|
@@ -328,16 +401,19 @@ class Foruiman::Engine
|
|
|
328
401
|
break if budget <= 0
|
|
329
402
|
|
|
330
403
|
entry, output = @readers.fetch(reader)
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
404
|
+
begin
|
|
405
|
+
bytes = reader.read_nonblock(READ_CHUNK, exception: false)
|
|
406
|
+
if bytes.nil? || (bytes == :wait_readable && entry.group_gone)
|
|
407
|
+
finish_reader(reader, entry, output)
|
|
408
|
+
elsif bytes != :wait_readable
|
|
409
|
+
budget -= bytes.bytesize
|
|
410
|
+
output.feed(bytes)
|
|
411
|
+
# Rotate serviced readers to the back for the next select.
|
|
412
|
+
@readers[reader] = @readers.delete(reader)
|
|
413
|
+
end
|
|
414
|
+
rescue Errno::EIO
|
|
415
|
+
# PTY masters report EIO when the child closes the slave side.
|
|
416
|
+
finish_reader(reader, entry, output)
|
|
341
417
|
end
|
|
342
418
|
end
|
|
343
419
|
# A daemon that escaped the group may still hold a pipe open. Drain available
|
|
@@ -346,10 +422,48 @@ class Foruiman::Engine
|
|
|
346
422
|
entry, output = @readers.fetch(reader)
|
|
347
423
|
next unless entry.group_gone && !ready.include?(reader)
|
|
348
424
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
425
|
+
finish_reader(reader, entry, output)
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def create_managed_input
|
|
430
|
+
master, slave = PTY.open
|
|
431
|
+
[master, slave].each do |io|
|
|
432
|
+
io.binmode
|
|
433
|
+
io.close_on_exec = true
|
|
352
434
|
end
|
|
435
|
+
[master, slave]
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def flush_input(entry)
|
|
439
|
+
return if entry.input_buffer.empty? || !entry.input
|
|
440
|
+
|
|
441
|
+
written = entry.input.write_nonblock(entry.input_buffer, exception: false)
|
|
442
|
+
entry.input_buffer.slice!(0, written) if written.is_a?(Integer)
|
|
443
|
+
true
|
|
444
|
+
rescue IOError, SystemCallError
|
|
445
|
+
reader = entry.input
|
|
446
|
+
finish_reader(reader, entry, @readers.fetch(reader).last) if reader && @readers.key?(reader)
|
|
447
|
+
false
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def finish_reader(reader, entry, output)
|
|
451
|
+
@readers.delete(reader)
|
|
452
|
+
@inputs.delete(reader)
|
|
453
|
+
entry.input = nil if entry.input.equal?(reader)
|
|
454
|
+
reader.close unless reader.closed?
|
|
455
|
+
output.feed("", eof: true)
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def close_input(entry)
|
|
459
|
+
input = entry.input
|
|
460
|
+
return unless input
|
|
461
|
+
|
|
462
|
+
@inputs.delete(input)
|
|
463
|
+
@readers.delete(input)
|
|
464
|
+
input.close unless input.closed?
|
|
465
|
+
entry.input = nil
|
|
466
|
+
entry.input_buffer.clear
|
|
353
467
|
end
|
|
354
468
|
|
|
355
469
|
def lifecycle(entry, type, message)
|
data/lib/foruiman/env.rb
CHANGED
|
@@ -28,8 +28,11 @@ class Foruiman::Env
|
|
|
28
28
|
def self.load(root: Dir.pwd, file: nil, dotenv: true, inherited: ENV.to_h)
|
|
29
29
|
env = inherited.dup
|
|
30
30
|
default = File.join(root, ".env")
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
if file
|
|
32
|
+
Array(file).each { |filename| env.merge!(new(filename).to_h) }
|
|
33
|
+
elsif dotenv && File.file?(default)
|
|
34
|
+
env.merge!(new(default).to_h)
|
|
35
|
+
end
|
|
33
36
|
env.transform_values { |value| value.dup.freeze }.freeze
|
|
34
37
|
end
|
|
35
38
|
end
|
data/lib/foruiman/output.rb
CHANGED
|
@@ -8,7 +8,7 @@ class Foruiman::Output
|
|
|
8
8
|
def initialize(logs, name:, stream:, pid:)
|
|
9
9
|
@logs = logs
|
|
10
10
|
@metadata = { name: name, stream: stream, pid: pid }
|
|
11
|
-
@decoder = Foruiman::ANSI::Decoder.new
|
|
11
|
+
@decoder = Foruiman::ANSI::Decoder.new(line_controls: true)
|
|
12
12
|
@styles = Foruiman::ANSI::Styles.new
|
|
13
13
|
@text = +""
|
|
14
14
|
@previous = nil
|
|
@@ -18,20 +18,25 @@ class Foruiman::Output
|
|
|
18
18
|
|
|
19
19
|
def feed(bytes, eof: false)
|
|
20
20
|
decoded = @decoder.feed(bytes, eof: eof)
|
|
21
|
-
decoded.scan(/\e\[[0-9;:]*
|
|
21
|
+
decoded.scan(/\e\[[0-9;:]*[mGCDK]|[\n\r\b]|[^\e\n\r\b]+/).each do |token|
|
|
22
22
|
if token == "\n"
|
|
23
23
|
if @split_boundary && !@dirty && !@previous
|
|
24
24
|
@split_boundary = false
|
|
25
25
|
else
|
|
26
26
|
publish(true)
|
|
27
27
|
end
|
|
28
|
+
elsif token == "\r" || token == "\b" || token.match?(/\A\e\[\d*[GCDK]\z/)
|
|
29
|
+
require_relative "output_line"
|
|
30
|
+
@line ||= Foruiman::OutputLine.new(@text)
|
|
31
|
+
@line.control(token, limit: MAX_BYTES / 2)
|
|
32
|
+
@dirty = true
|
|
28
33
|
elsif token.start_with?("\e[")
|
|
29
34
|
split_record if @text.bytesize + token.bytesize > MAX_BYTES
|
|
30
|
-
@text << token
|
|
35
|
+
@text << token unless @line
|
|
31
36
|
@styles.apply(token)
|
|
32
37
|
@dirty = true
|
|
33
38
|
else
|
|
34
|
-
append_text(token)
|
|
39
|
+
@line ? edit_text(token) : append_text(token)
|
|
35
40
|
end
|
|
36
41
|
end
|
|
37
42
|
if eof
|
|
@@ -43,6 +48,17 @@ class Foruiman::Output
|
|
|
43
48
|
|
|
44
49
|
private
|
|
45
50
|
|
|
51
|
+
def edit_text(text)
|
|
52
|
+
text.scan(/\X/).each do |character|
|
|
53
|
+
if @line.cost + character.bytesize + @styles.prefix.bytesize + 4 > MAX_BYTES / 2
|
|
54
|
+
split_record
|
|
55
|
+
@line = Foruiman::OutputLine.new(@text)
|
|
56
|
+
end
|
|
57
|
+
@line.write(character, @styles.prefix)
|
|
58
|
+
@dirty = true
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
46
62
|
def append_text(text)
|
|
47
63
|
until text.empty?
|
|
48
64
|
available = MAX_BYTES - @text.bytesize
|
|
@@ -66,11 +82,13 @@ class Foruiman::Output
|
|
|
66
82
|
|
|
67
83
|
def publish(complete)
|
|
68
84
|
@split_boundary = false
|
|
85
|
+
@text = @line.to_s if @line
|
|
69
86
|
@previous = @logs.write(**@metadata, text: @text, complete: complete, previous: @previous)
|
|
70
87
|
@dirty = false
|
|
71
88
|
return unless complete
|
|
72
89
|
|
|
73
90
|
@text = +@styles.prefix
|
|
91
|
+
@line = nil
|
|
74
92
|
@previous = nil
|
|
75
93
|
end
|
|
76
94
|
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "unicode/display_width"
|
|
4
|
+
require_relative "ansi"
|
|
5
|
+
|
|
6
|
+
# A single editable terminal row. Used only after a child emits a horizontal
|
|
7
|
+
# cursor control; ordinary append-only logs keep their fast streaming path.
|
|
8
|
+
class Foruiman::OutputLine
|
|
9
|
+
attr_reader :cost
|
|
10
|
+
|
|
11
|
+
def initialize(text)
|
|
12
|
+
@cells = []
|
|
13
|
+
@cursor = 0
|
|
14
|
+
@cost = 0
|
|
15
|
+
styles = Foruiman::ANSI::Styles.new
|
|
16
|
+
text.scan(/\e\[[0-9;:]*m|\X/).each do |token|
|
|
17
|
+
if token.match?(Foruiman::ANSI::SGR)
|
|
18
|
+
styles.apply(token)
|
|
19
|
+
else
|
|
20
|
+
write(token, styles.prefix)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def write(character, style)
|
|
26
|
+
width = Unicode::DisplayWidth.of(character, emoji: :all)
|
|
27
|
+
if width.zero? && @cursor.positive?
|
|
28
|
+
index = @cursor - 1
|
|
29
|
+
index -= 1 while index.positive? && @cells[index] == :continuation
|
|
30
|
+
@cells[index][0] += character if @cells[index].is_a?(Array)
|
|
31
|
+
else
|
|
32
|
+
width.times { |offset| clear_cell(@cursor + offset) }
|
|
33
|
+
@cells[@cursor] = [character, style]
|
|
34
|
+
(1...width).each { |offset| @cells[@cursor + offset] = :continuation }
|
|
35
|
+
@cursor += width
|
|
36
|
+
end
|
|
37
|
+
# Conservative, constant-time bound including style transitions.
|
|
38
|
+
@cost += character.bytesize + style.bytesize + Foruiman::ANSI::RESET.bytesize
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def control(token, limit:)
|
|
42
|
+
@cost = to_s.bytesize
|
|
43
|
+
number = token[/\d+/].to_i
|
|
44
|
+
amount = [number, 1].max
|
|
45
|
+
case token
|
|
46
|
+
when "\r" then @cursor = 0
|
|
47
|
+
when "\b" then @cursor = [@cursor - 1, 0].max
|
|
48
|
+
else
|
|
49
|
+
case token[-1]
|
|
50
|
+
when "G" then @cursor = (amount - 1).clamp(0, limit)
|
|
51
|
+
when "C" then @cursor = (@cursor + amount).clamp(0, limit)
|
|
52
|
+
when "D" then @cursor = [@cursor - amount, 0].max
|
|
53
|
+
when "K" then erase(number)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def to_s
|
|
59
|
+
style = ""
|
|
60
|
+
@cells.each_with_object(+"") do |cell, text|
|
|
61
|
+
next if cell == :continuation
|
|
62
|
+
|
|
63
|
+
character, next_style = cell || [" ", ""]
|
|
64
|
+
if next_style != style
|
|
65
|
+
text << Foruiman::ANSI::RESET unless style.empty?
|
|
66
|
+
text << next_style
|
|
67
|
+
style = next_style
|
|
68
|
+
end
|
|
69
|
+
text << character
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def clear_cell(index)
|
|
76
|
+
if @cells[index] == :continuation
|
|
77
|
+
start = index - 1
|
|
78
|
+
start -= 1 while start.positive? && @cells[start] == :continuation
|
|
79
|
+
@cells[start] = nil
|
|
80
|
+
end
|
|
81
|
+
following = index + 1
|
|
82
|
+
while @cells[following] == :continuation
|
|
83
|
+
@cells[following] = nil
|
|
84
|
+
following += 1
|
|
85
|
+
end
|
|
86
|
+
@cells[index] = nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def erase(mode)
|
|
90
|
+
case mode
|
|
91
|
+
when 0
|
|
92
|
+
clear_cell(@cursor) if @cursor < @cells.size
|
|
93
|
+
@cells.slice!(@cursor..) if @cursor < @cells.size
|
|
94
|
+
when 1
|
|
95
|
+
[@cursor + 1, @cells.size].min.times { |index| clear_cell(index) }
|
|
96
|
+
when 2 then @cells.clear
|
|
97
|
+
end
|
|
98
|
+
@cost = to_s.bytesize
|
|
99
|
+
end
|
|
100
|
+
end
|
data/lib/foruiman/process.rb
CHANGED
|
@@ -12,7 +12,7 @@ class Foruiman::Process
|
|
|
12
12
|
|
|
13
13
|
def run(options = {})
|
|
14
14
|
::Process.spawn(env.merge(options.fetch(:env, {})), "/bin/sh", "-c", command,
|
|
15
|
-
chdir: cwd, in:
|
|
15
|
+
chdir: cwd, in: options.fetch(:input, $stdin), out: options.fetch(:output, $stdout),
|
|
16
16
|
err: options.fetch(:error, $stderr), pgroup: true, unsetenv_others: true,
|
|
17
17
|
close_others: true)
|
|
18
18
|
end
|
|
@@ -22,13 +22,16 @@ module Foruiman::TUI
|
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
def run
|
|
25
|
+
@engine.manage_input!
|
|
25
26
|
@terminal.session do
|
|
26
27
|
@engine.run(keep_open: true) do
|
|
27
28
|
rows, columns = @terminal.size
|
|
29
|
+
@engine.resize_inputs(rows, columns)
|
|
30
|
+
detach_unavailable_input
|
|
28
31
|
input = @terminal.read
|
|
29
32
|
@engine.shutdown if input.nil?
|
|
30
|
-
height = [Renderer.log_height(rows: rows, columns: columns), 1].max
|
|
31
|
-
|
|
33
|
+
height = [Renderer.log_height(rows: rows, columns: columns, input: state.input?), 1].max
|
|
34
|
+
read_keys(input.is_a?(String) ? input : "", height)
|
|
32
35
|
state.feedback = nil if monotonic >= @feedback_until
|
|
33
36
|
next if monotonic < @next_frame_at && @was_shutting_down == @engine.shutting_down?
|
|
34
37
|
|
|
@@ -66,7 +69,9 @@ module Foruiman::TUI
|
|
|
66
69
|
when :toggle_follow then state.viewport.toggle(buffer, height)
|
|
67
70
|
when :help then state.help = !state.help
|
|
68
71
|
when :escape then state.help = false
|
|
69
|
-
when :restart
|
|
72
|
+
when :restart then control(key)
|
|
73
|
+
when :stop then toggle_process
|
|
74
|
+
when :input then begin_input
|
|
70
75
|
when :restart_all, :stop_all
|
|
71
76
|
control_all(key == :restart_all ? :restart : :stop)
|
|
72
77
|
when :quit then @engine.shutdown
|
|
@@ -75,6 +80,76 @@ module Foruiman::TUI
|
|
|
75
80
|
|
|
76
81
|
private
|
|
77
82
|
|
|
83
|
+
def read_keys(bytes, height)
|
|
84
|
+
@keyboard.feed("").each { |key| handle(key, height) } unless state.input?
|
|
85
|
+
bytes.bytes.each_with_index do |byte, index|
|
|
86
|
+
if state.input?
|
|
87
|
+
forward_input(bytes.byteslice(index..))
|
|
88
|
+
break
|
|
89
|
+
end
|
|
90
|
+
@keyboard.feed(byte.chr).each { |key| handle(key, height) }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def begin_input
|
|
95
|
+
if state.name == "all"
|
|
96
|
+
feedback("Select a process before entering input mode")
|
|
97
|
+
elsif @engine.state(state.name).status != :running
|
|
98
|
+
feedback("#{state.name} is not running")
|
|
99
|
+
else
|
|
100
|
+
state.help = false
|
|
101
|
+
state.viewport.follow
|
|
102
|
+
state.input_target = state.name
|
|
103
|
+
state.feedback = nil
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def forward_input(bytes)
|
|
108
|
+
name = state.input_target
|
|
109
|
+
state.input_line.feed(bytes).each do |event|
|
|
110
|
+
case event
|
|
111
|
+
when :detach
|
|
112
|
+
state.input_target = nil
|
|
113
|
+
feedback("Returned from #{name}")
|
|
114
|
+
when :interrupt then @engine.interrupt_process(name)
|
|
115
|
+
when :eof then send_input(name, "\x04")
|
|
116
|
+
when Array then send_input(name, "#{event.last}\n")
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def send_input(name, bytes)
|
|
122
|
+
return if @engine.write_input(name, bytes)
|
|
123
|
+
|
|
124
|
+
state.input_target = nil
|
|
125
|
+
feedback("Input unavailable for #{name}")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def detach_unavailable_input
|
|
129
|
+
return unless state.input?
|
|
130
|
+
return if @engine.state(state.input_target).status == :running
|
|
131
|
+
|
|
132
|
+
name = state.input_target
|
|
133
|
+
state.input_target = nil
|
|
134
|
+
feedback("Input closed for #{name}")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def toggle_process
|
|
138
|
+
if state.name == "all"
|
|
139
|
+
control_all(:stop)
|
|
140
|
+
return
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
entry = @engine.state(state.name)
|
|
144
|
+
if entry.pgid
|
|
145
|
+
@engine.stop(state.name)
|
|
146
|
+
feedback("Stopping #{state.name}")
|
|
147
|
+
else
|
|
148
|
+
@engine.start(state.name)
|
|
149
|
+
feedback("Starting #{state.name}")
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
78
153
|
def control(action)
|
|
79
154
|
if state.name == "all"
|
|
80
155
|
if action == :restart
|