foruiman 0.1.2 → 0.2.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 +11 -0
- data/README.md +10 -1
- data/docs/ARCHITECTURE.md +17 -4
- data/docs/COMPATIBILITY.md +4 -4
- data/docs/RAILS.md +7 -1
- data/lib/foruiman/ansi.rb +8 -3
- data/lib/foruiman/engine.rb +116 -18
- 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 +46 -10
- data/lib/foruiman/tui/state.rb +12 -1
- data/lib/foruiman/tui/theme.rb +2 -2
- data/lib/foruiman/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 726795acd4c4991d3a924364ec484ca7ec1e7051b8c9a660a53e6b8ecd1759f8
|
|
4
|
+
data.tar.gz: 0c24bdd2fded37d051b8bb0e771991b0284893888a89b3a461662d2cc483dee4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 68b7fa94a095c09fc3ed636165ee88db37640d24aa2ede0b4d2c417680cf2655910b881ad2180116babc701949c91cc9f751f2e0b67f222d081f71ca9153a178
|
|
7
|
+
data.tar.gz: 22ec4c35cd32fd23d897ca2de19d72d5c219b53d0b86019f3fc3f562b3436371bd353ca5119aac2d4e1c559c68ddca37984c53b68e5e036cc12538e04f71321e
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.2.0
|
|
6
|
+
|
|
7
|
+
- Keep child stdin open so default watcher modes continue running.
|
|
8
|
+
- Add per-process TTY input from the TUI: select a process, press `i`, and use
|
|
9
|
+
Ctrl-X to return to Foruiman. Ctrl-C interrupts the attached process group.
|
|
10
|
+
- Add a command input bar with local backspace, cursor editing and per-process
|
|
11
|
+
history. Interpret debugger redraws instead of appending repeated prompts.
|
|
12
|
+
- Make `s` stop a running selected process and start it again once stopped,
|
|
13
|
+
without affecting peers. Use clear `▶` running and `■` stopped glyphs.
|
|
14
|
+
- Make `s` on the `all` tab stop every process, matching the scope of `r`.
|
|
15
|
+
|
|
5
16
|
- Test Ruby 3.2, 3.3, 3.4, and 4.0 in a reusable CI workflow and keep the
|
|
6
17
|
development dependency set compatible with Ruby 3.2.
|
|
7
18
|
- Add a tag-driven RubyGems trusted-publishing workflow gated by the full CI suite.
|
data/README.md
CHANGED
|
@@ -46,6 +46,14 @@ The TUI opens when stdin and stdout are terminals. Otherwise, Foruiman streams
|
|
|
46
46
|
plain logs and exits when the processes finish. Processes run independently, and
|
|
47
47
|
restarting one process does not interrupt the others.
|
|
48
48
|
|
|
49
|
+
Each TUI process gets its own open terminal input, so asset builders using their
|
|
50
|
+
default `--watch` mode stay alive. Select a process and press `i` to type into a
|
|
51
|
+
debugger such as Pry, Byebug or IRB. Edit in the input bar, press Enter to send,
|
|
52
|
+
and Ctrl-X to return to Foruiman. Arrow keys edit and recall commands; Ctrl-C
|
|
53
|
+
interrupts the selected process. Debugger tab completion is not forwarded.
|
|
54
|
+
Press `s` to stop the selected process, or all processes from `all`; on a stopped
|
|
55
|
+
process tab, `s` starts it again.
|
|
56
|
+
|
|
49
57
|
## Options
|
|
50
58
|
|
|
51
59
|
| Option | Default | Purpose |
|
|
@@ -69,9 +77,10 @@ restarting one process does not interrupt the others.
|
|
|
69
77
|
| Home / `g` | Jump to oldest retained log |
|
|
70
78
|
| End / `G` / `f` | Follow new output |
|
|
71
79
|
| Space | Pause or resume following |
|
|
80
|
+
| `i` / Ctrl-X | Open command input / return to Foruiman |
|
|
72
81
|
| `r` | Restart the selected process, or all from `all` |
|
|
73
82
|
| `R` | Restart all processes |
|
|
74
|
-
| `s` / `S` |
|
|
83
|
+
| `s` / `S` | Start/stop selected (`s` on `all` stops all) / stop all |
|
|
75
84
|
| `?` / Escape | Toggle help / close help |
|
|
76
85
|
| `q` / Ctrl-C | Stop processes and quit |
|
|
77
86
|
|
data/docs/ARCHITECTURE.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
`Procfile` retains Foreman's ordered parser/writer API with strict file validation.
|
|
4
4
|
`Env` retains its quoting rules and adds a non-mutating precedence merge. `Process`
|
|
5
|
-
wraps `/bin/sh -c` with a new process group, two output streams, and
|
|
5
|
+
wraps `/bin/sh -c` with a new process group, two output streams, and configurable stdin.
|
|
6
6
|
`CLI < Thor` validates the full configuration before starting the engine.
|
|
7
7
|
|
|
8
8
|
`Engine` owns registration, PID/group tracking, nonblocking pipes, child reaping,
|
|
@@ -35,6 +35,7 @@ begin
|
|
|
35
35
|
engine.tick(timeout: 0.03)
|
|
36
36
|
engine.restart("web") # asynchronous; peers keep running
|
|
37
37
|
engine.stop("worker") # asynchronous
|
|
38
|
+
engine.start("worker") # start it again once stopped
|
|
38
39
|
engine.shutdown # cancels replacements and requests group cleanup
|
|
39
40
|
engine.tick until engine.finished?
|
|
40
41
|
ensure
|
|
@@ -50,6 +51,18 @@ process cleanup. `term_timeout:` exists for deterministic embedded tests; CLI
|
|
|
50
51
|
shutdown always uses five seconds. `state(name)` exposes lifecycle state for
|
|
51
52
|
rendering; callers should not mutate it.
|
|
52
53
|
|
|
54
|
+
Children inherit the configured input stream in plain and embedded use. Before
|
|
55
|
+
startup, `manage_input!` gives each child a dedicated pseudo-terminal while the
|
|
56
|
+
caller retains the real terminal; `write_input(name, bytes)` forwards input,
|
|
57
|
+
`interrupt_process(name)` sends SIGINT to that process group, and `resize_inputs` keeps
|
|
58
|
+
the pseudo-terminals sized with the UI. The TUI uses this mode so watch commands
|
|
59
|
+
do not see EOF and only the selected process receives interactive input.
|
|
60
|
+
|
|
61
|
+
The TUI edits commands locally in a bounded `InputLine`, with independent history
|
|
62
|
+
for each process, then sends a complete line on Enter. Ctrl-X leaves input mode.
|
|
63
|
+
`OutputLine` interprets carriage returns, backspaces, horizontal cursor moves and
|
|
64
|
+
line erasure before recording child output; screen controls remain suppressed.
|
|
65
|
+
|
|
53
66
|
`LogStore` uses per-process `Ring` instances and an aggregate `Ring`, each with O(1)
|
|
54
67
|
append, eviction, record replacement, and identity lookup. Immutable `Data` records
|
|
55
68
|
are shared. A partial record has one sequence identity; completing it replaces
|
|
@@ -74,9 +87,9 @@ clips rows safely. `Theme` uses the terminal's ANSI palette, default foreground
|
|
|
74
87
|
background, and reverse-video selection, with a monochrome fallback. It does not
|
|
75
88
|
read OS theme files or override palette entries; child SGR resets restore terminal
|
|
76
89
|
defaults. The header uses the engine's `procfile_path` to identify the loaded file.
|
|
77
|
-
`LogFormatter` aligns timestamps,
|
|
78
|
-
|
|
79
|
-
|
|
90
|
+
`LogFormatter` aligns timestamps, process names, stream markers, and content.
|
|
91
|
+
Complete frames reset styles at each row. `Application` connects navigation,
|
|
92
|
+
start/stop controls, and selected-child input to the engine and limits drawing to 30 FPS. Resize handling reads current
|
|
80
93
|
terminal dimensions each loop; it does not replace an application's WINCH handler.
|
|
81
94
|
|
|
82
95
|
`Plain` prints completed records and runs to natural completion. `Diagnostics`
|
data/docs/COMPATIBILITY.md
CHANGED
|
@@ -4,7 +4,7 @@ Foruiman is a fork-derived MVP, not a drop-in replacement for every Foreman feat
|
|
|
4
4
|
|
|
5
5
|
| Area | Foruiman behavior |
|
|
6
6
|
| --- | --- |
|
|
7
|
-
| Identity | `foruiman` gem/executable, `Foruiman` namespace, version 0.
|
|
7
|
+
| Identity | `foruiman` gem/executable, `Foruiman` namespace, version 0.2.0 |
|
|
8
8
|
| Runtime | Ruby 3.2+, POSIX process groups; Linux CI |
|
|
9
9
|
| CLI | Thor-based `start [PROCESS]`, `check`, `version`, and `help` |
|
|
10
10
|
| Excluded features | No export, scaling/formation, `run`, `.foreman` YAML, custom shutdown timeout, forced color, or timestamp toggle |
|
|
@@ -18,10 +18,10 @@ Foruiman is a fork-derived MVP, not a drop-in replacement for every Foreman feat
|
|
|
18
18
|
| Restart | Stop only the affected group, wait for descendants and output, then replace |
|
|
19
19
|
| Shutdown | TERM, five-second grace, KILL; track groups after leader exit; restore prior signal handlers |
|
|
20
20
|
| Output | Separate stdout/stderr metadata; bounded logs and live partial records |
|
|
21
|
-
| Terminal | Tabs, independent scroll/follow, restart
|
|
21
|
+
| Terminal | Tabs, independent scroll/follow, restart, start/stop, and selected-process input controls |
|
|
22
22
|
| Plain exit | Wait for all processes; status 0/1; explicit orderly shutdown returns 0 |
|
|
23
23
|
| Interactive exit | Remain open after all processes exit; quit explicitly |
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
Full-screen child terminal applications and background daemonization are outside
|
|
26
|
+
this release. Remote process control, persistence across Foruiman sessions,
|
|
27
27
|
search, horizontal scrolling, and log export are not provided.
|
data/docs/RAILS.md
CHANGED
|
@@ -46,7 +46,13 @@ exec foruiman start -f Procfile.dev "$@"
|
|
|
46
46
|
|
|
47
47
|
Select the web tab and press `r` to restart Rails without interrupting the worker
|
|
48
48
|
or asset watcher. On the `all` tab, `r` restarts all entries; `R` does so from any
|
|
49
|
-
tab.
|
|
49
|
+
tab. Default watch modes stay running because their stdin remains open. When Rails
|
|
50
|
+
stops in Pry, Byebug or IRB, select `web` and press `i`. Edit commands in the input
|
|
51
|
+
bar and press Enter to send them. Up/Down recall history, Left/Right move the
|
|
52
|
+
cursor, and Ctrl-X returns to Foruiman. Ctrl-C interrupts the selected process;
|
|
53
|
+
debugger tab completion is not forwarded. Press `s` to stop the selected entry
|
|
54
|
+
and press it again to start it; `s` on `all` stops every entry. The header shows the
|
|
55
|
+
active Procfile. `q` or Ctrl-C stops all owned process
|
|
50
56
|
groups, including grandchildren, before returning to the shell.
|
|
51
57
|
|
|
52
58
|
Redirect output with `foruiman start -f Procfile.dev > development.log` to use plain
|
data/lib/foruiman/ansi.rb
CHANGED
|
@@ -63,10 +63,11 @@ module Foruiman::ANSI
|
|
|
63
63
|
end
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
-
#
|
|
67
|
-
#
|
|
66
|
+
# Screen controls are discarded. Output may opt into horizontal line editing
|
|
67
|
+
# controls, which it interprets before storing or displaying any records.
|
|
68
68
|
class Decoder
|
|
69
|
-
def initialize
|
|
69
|
+
def initialize(line_controls: false)
|
|
70
|
+
@line_controls = line_controls
|
|
70
71
|
@state = :text
|
|
71
72
|
@escape = +""
|
|
72
73
|
@pending = +"".b
|
|
@@ -92,6 +93,7 @@ module Foruiman::ANSI
|
|
|
92
93
|
case byte
|
|
93
94
|
when 27 then @state = :escape
|
|
94
95
|
when 9 then output << " "
|
|
96
|
+
when 8, 13 then output << byte if @line_controls
|
|
95
97
|
when 10, 32..126, 128..255 then output << byte
|
|
96
98
|
end
|
|
97
99
|
when :escape
|
|
@@ -108,6 +110,9 @@ module Foruiman::ANSI
|
|
|
108
110
|
when :csi
|
|
109
111
|
if byte.between?(64, 126)
|
|
110
112
|
output << "\e[#{@escape}m" if byte == 109 && @escape.match?(/\A[0-9;:]*\z/)
|
|
113
|
+
if @line_controls && [67, 68, 71, 75].include?(byte) && @escape.match?(/\A[0-9]*\z/)
|
|
114
|
+
output << "\e[#{@escape}#{byte.chr}"
|
|
115
|
+
end
|
|
111
116
|
@state = :text
|
|
112
117
|
elsif @escape.bytesize < 96
|
|
113
118
|
@escape << byte
|
data/lib/foruiman/engine.rb
CHANGED
|
@@ -11,14 +11,16 @@ 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
21
|
attr_reader :logs, :env, :processes, :root, :procfile_path
|
|
20
22
|
|
|
21
|
-
def initialize(procfile: nil, root: Dir.pwd, env: ENV.to_h, port: 5000, log_lines: 10_000,
|
|
23
|
+
def initialize(procfile: nil, root: Dir.pwd, env: ENV.to_h, input: $stdin, port: 5000, log_lines: 10_000,
|
|
22
24
|
term_timeout: TERM_TIMEOUT)
|
|
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,6 +29,8 @@ class Foruiman::Engine
|
|
|
27
29
|
raise Foruiman::Error, "working directory does not exist: #{@root}" unless File.directory?(@root)
|
|
28
30
|
|
|
29
31
|
@env = env.dup.freeze
|
|
32
|
+
@input = input
|
|
33
|
+
@managed_input = false
|
|
30
34
|
@base_port = port
|
|
31
35
|
@log_lines = log_lines
|
|
32
36
|
@term_timeout = term_timeout
|
|
@@ -34,6 +38,7 @@ class Foruiman::Engine
|
|
|
34
38
|
@names = {}
|
|
35
39
|
@running = {}
|
|
36
40
|
@readers = {}
|
|
41
|
+
@inputs = {}
|
|
37
42
|
@listeners = []
|
|
38
43
|
@shutdown = false
|
|
39
44
|
@explicit_shutdown = false
|
|
@@ -57,7 +62,8 @@ class Foruiman::Engine
|
|
|
57
62
|
Foruiman::Procfile.new[name] = command
|
|
58
63
|
process = Foruiman::Process.new(command, cwd: root, env: env)
|
|
59
64
|
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
|
|
65
|
+
status: :pending, generation: 0, restart_pending: false, reaped: true, group_gone: true,
|
|
66
|
+
input_buffer: +"".b)
|
|
61
67
|
@names[name] = state
|
|
62
68
|
processes << state
|
|
63
69
|
state
|
|
@@ -100,6 +106,16 @@ class Foruiman::Engine
|
|
|
100
106
|
self
|
|
101
107
|
end
|
|
102
108
|
|
|
109
|
+
# Give every child a dedicated pseudo-terminal for stdin. The TUI remains the
|
|
110
|
+
# sole reader of the real terminal and explicitly forwards input to one child.
|
|
111
|
+
def manage_input!
|
|
112
|
+
raise Foruiman::Error, "cannot change input mode after startup" if @started
|
|
113
|
+
|
|
114
|
+
require "pty"
|
|
115
|
+
@managed_input = true
|
|
116
|
+
self
|
|
117
|
+
end
|
|
118
|
+
|
|
103
119
|
def start(name = nil)
|
|
104
120
|
raise Foruiman::Error, "supervisor is closed" if @closed
|
|
105
121
|
return if @shutdown
|
|
@@ -145,6 +161,33 @@ class Foruiman::Engine
|
|
|
145
161
|
terminate(entry)
|
|
146
162
|
end
|
|
147
163
|
|
|
164
|
+
def write_input(name, bytes)
|
|
165
|
+
entry = state(name)
|
|
166
|
+
return false unless entry.status == :running && entry.input && !entry.input.closed?
|
|
167
|
+
return false if entry.input_buffer.bytesize + bytes.bytesize > INPUT_BUDGET
|
|
168
|
+
|
|
169
|
+
entry.input_buffer << bytes.b
|
|
170
|
+
flush_input(entry)
|
|
171
|
+
rescue IOError, SystemCallError
|
|
172
|
+
false
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def interrupt_process(name)
|
|
176
|
+
entry = state(name)
|
|
177
|
+
return unless entry.pgid
|
|
178
|
+
|
|
179
|
+
signal_group(entry, :INT)
|
|
180
|
+
self
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def resize_inputs(rows, columns)
|
|
184
|
+
processes.each do |entry|
|
|
185
|
+
entry.input&.winsize = [rows, columns]
|
|
186
|
+
rescue IOError, SystemCallError
|
|
187
|
+
nil
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
148
191
|
def shutdown(explicit: true)
|
|
149
192
|
@explicit_shutdown ||= explicit
|
|
150
193
|
return if @shutdown
|
|
@@ -189,9 +232,11 @@ class Foruiman::Engine
|
|
|
189
232
|
shutdown if @signal_requested
|
|
190
233
|
reap_children
|
|
191
234
|
advance_groups
|
|
192
|
-
|
|
235
|
+
writable = processes.filter_map { |entry| entry.input unless entry.input_buffer.empty? }
|
|
236
|
+
ready, writable = IO.select([@self_reader, *@readers.keys], writable, nil, timeout) || [[], []]
|
|
193
237
|
drain_signal_pipe if ready.delete(@self_reader)
|
|
194
238
|
shutdown if @signal_requested
|
|
239
|
+
writable.each { |input| flush_input(@inputs.fetch(input)) if @inputs.key?(input) }
|
|
195
240
|
read_output(ready)
|
|
196
241
|
reap_children
|
|
197
242
|
advance_groups
|
|
@@ -224,12 +269,15 @@ class Foruiman::Engine
|
|
|
224
269
|
|
|
225
270
|
stdout_reader, stdout_writer = create_pipe
|
|
226
271
|
stderr_reader, stderr_writer = create_pipe
|
|
272
|
+
input_master, input_slave = create_managed_input if @managed_input
|
|
227
273
|
begin
|
|
228
274
|
pid = entry.process.run(output: stdout_writer, error: stderr_writer,
|
|
275
|
+
input: input_slave || @input,
|
|
229
276
|
env: { "PORT" => entry.port.to_s, "PS" => "#{entry.name}.1" })
|
|
230
277
|
rescue SystemCallError => e
|
|
231
278
|
stdout_reader.close
|
|
232
279
|
stderr_reader.close
|
|
280
|
+
input_master&.close
|
|
233
281
|
entry.status = :failed
|
|
234
282
|
entry.restart_pending = false
|
|
235
283
|
@failed = true
|
|
@@ -243,12 +291,19 @@ class Foruiman::Engine
|
|
|
243
291
|
entry.restart_pending = false
|
|
244
292
|
entry.deadline = nil
|
|
245
293
|
entry.reaped = entry.group_gone = false
|
|
294
|
+
entry.input = input_master
|
|
295
|
+
entry.input_buffer.clear
|
|
246
296
|
@running[pid] = entry
|
|
247
297
|
[[stdout_reader, :stdout], [stderr_reader, :stderr]].each do |reader, stream|
|
|
248
298
|
@readers[reader] = [entry, Foruiman::Output.new(logs, name: entry.name, stream: stream, pid: pid)]
|
|
249
299
|
end
|
|
300
|
+
if input_master
|
|
301
|
+
@inputs[input_master] = entry
|
|
302
|
+
@readers[input_master] = [entry, Foruiman::Output.new(logs, name: entry.name, stream: :stdin, pid: pid)]
|
|
303
|
+
end
|
|
250
304
|
lifecycle(entry, :started, "started with pid #{pid} (generation #{entry.generation})")
|
|
251
305
|
ensure
|
|
306
|
+
input_slave&.close
|
|
252
307
|
stdout_writer&.close
|
|
253
308
|
stderr_writer&.close
|
|
254
309
|
[stdout_reader, stderr_reader].compact.each do |reader|
|
|
@@ -278,8 +333,9 @@ class Foruiman::Engine
|
|
|
278
333
|
entry.reaped = true
|
|
279
334
|
entry.exit_status = result.last
|
|
280
335
|
success = entry.exit_status.success?
|
|
281
|
-
|
|
282
|
-
|
|
336
|
+
transitioning = %i[stopping restarting].include?(entry.status)
|
|
337
|
+
@failed ||= !success && !transitioning
|
|
338
|
+
entry.status = success ? :exited : :failed unless transitioning
|
|
283
339
|
lifecycle(entry, :exited, termination_message_for(entry.exit_status))
|
|
284
340
|
terminate(entry)
|
|
285
341
|
end
|
|
@@ -299,6 +355,7 @@ class Foruiman::Engine
|
|
|
299
355
|
next unless entry.reaped && entry.group_gone
|
|
300
356
|
next if @readers.any? { |_reader, (owner, _output)| owner.equal?(entry) }
|
|
301
357
|
|
|
358
|
+
close_input(entry)
|
|
302
359
|
entry.pgid = nil
|
|
303
360
|
entry.deadline = nil
|
|
304
361
|
entry.status = :stopped if entry.status == :stopping
|
|
@@ -328,16 +385,19 @@ class Foruiman::Engine
|
|
|
328
385
|
break if budget <= 0
|
|
329
386
|
|
|
330
387
|
entry, output = @readers.fetch(reader)
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
388
|
+
begin
|
|
389
|
+
bytes = reader.read_nonblock(READ_CHUNK, exception: false)
|
|
390
|
+
if bytes.nil? || (bytes == :wait_readable && entry.group_gone)
|
|
391
|
+
finish_reader(reader, entry, output)
|
|
392
|
+
elsif bytes != :wait_readable
|
|
393
|
+
budget -= bytes.bytesize
|
|
394
|
+
output.feed(bytes)
|
|
395
|
+
# Rotate serviced readers to the back for the next select.
|
|
396
|
+
@readers[reader] = @readers.delete(reader)
|
|
397
|
+
end
|
|
398
|
+
rescue Errno::EIO
|
|
399
|
+
# PTY masters report EIO when the child closes the slave side.
|
|
400
|
+
finish_reader(reader, entry, output)
|
|
341
401
|
end
|
|
342
402
|
end
|
|
343
403
|
# A daemon that escaped the group may still hold a pipe open. Drain available
|
|
@@ -346,12 +406,50 @@ class Foruiman::Engine
|
|
|
346
406
|
entry, output = @readers.fetch(reader)
|
|
347
407
|
next unless entry.group_gone && !ready.include?(reader)
|
|
348
408
|
|
|
349
|
-
|
|
350
|
-
reader.close
|
|
351
|
-
output.feed("", eof: true)
|
|
409
|
+
finish_reader(reader, entry, output)
|
|
352
410
|
end
|
|
353
411
|
end
|
|
354
412
|
|
|
413
|
+
def create_managed_input
|
|
414
|
+
master, slave = PTY.open
|
|
415
|
+
[master, slave].each do |io|
|
|
416
|
+
io.binmode
|
|
417
|
+
io.close_on_exec = true
|
|
418
|
+
end
|
|
419
|
+
[master, slave]
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def flush_input(entry)
|
|
423
|
+
return if entry.input_buffer.empty? || !entry.input
|
|
424
|
+
|
|
425
|
+
written = entry.input.write_nonblock(entry.input_buffer, exception: false)
|
|
426
|
+
entry.input_buffer.slice!(0, written) if written.is_a?(Integer)
|
|
427
|
+
true
|
|
428
|
+
rescue IOError, SystemCallError
|
|
429
|
+
reader = entry.input
|
|
430
|
+
finish_reader(reader, entry, @readers.fetch(reader).last) if reader && @readers.key?(reader)
|
|
431
|
+
false
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def finish_reader(reader, entry, output)
|
|
435
|
+
@readers.delete(reader)
|
|
436
|
+
@inputs.delete(reader)
|
|
437
|
+
entry.input = nil if entry.input.equal?(reader)
|
|
438
|
+
reader.close unless reader.closed?
|
|
439
|
+
output.feed("", eof: true)
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def close_input(entry)
|
|
443
|
+
input = entry.input
|
|
444
|
+
return unless input
|
|
445
|
+
|
|
446
|
+
@inputs.delete(input)
|
|
447
|
+
@readers.delete(input)
|
|
448
|
+
input.close unless input.closed?
|
|
449
|
+
entry.input = nil
|
|
450
|
+
entry.input_buffer.clear
|
|
451
|
+
end
|
|
452
|
+
|
|
355
453
|
def lifecycle(entry, type, message)
|
|
356
454
|
logs&.write(name: entry.name, stream: :lifecycle, pid: entry.pid,
|
|
357
455
|
text: "--- #{message} ---", complete: true)
|
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
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Foruiman::TUI
|
|
4
|
+
class InputLine
|
|
5
|
+
# Leave room for the newline in the child's canonical terminal buffer.
|
|
6
|
+
MAX_BYTES = 4095
|
|
7
|
+
HISTORY_SIZE = 100
|
|
8
|
+
SEQUENCES = {
|
|
9
|
+
"\e[A" => :history_previous, "\e[B" => :history_next,
|
|
10
|
+
"\e[C" => :right, "\e[D" => :left,
|
|
11
|
+
"\e[H" => :home, "\e[F" => :end,
|
|
12
|
+
"\e[1~" => :home, "\e[4~" => :end, "\e[3~" => :delete
|
|
13
|
+
}.freeze
|
|
14
|
+
CONTROLS = {
|
|
15
|
+
1 => :home, 3 => :interrupt, 4 => :eof, 5 => :end, 8 => :backspace,
|
|
16
|
+
10 => :submit, 13 => :submit, 18 => :history_previous, 21 => :clear, 23 => :delete_word,
|
|
17
|
+
24 => :detach, 127 => :backspace
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
attr_reader :text, :cursor
|
|
21
|
+
|
|
22
|
+
def initialize
|
|
23
|
+
@text = +""
|
|
24
|
+
@cursor = 0
|
|
25
|
+
@bytes = +"".b
|
|
26
|
+
@history = []
|
|
27
|
+
@history_index = nil
|
|
28
|
+
@draft = nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def feed(bytes)
|
|
32
|
+
bytes, detach, = bytes.b.partition("\x18")
|
|
33
|
+
@bytes << bytes
|
|
34
|
+
events = []
|
|
35
|
+
until @bytes.empty?
|
|
36
|
+
if @bytes.start_with?("\e")
|
|
37
|
+
if @bytes.bytesize > 64 && incomplete_escape?
|
|
38
|
+
@bytes.clear
|
|
39
|
+
break
|
|
40
|
+
end
|
|
41
|
+
break if incomplete_escape?
|
|
42
|
+
|
|
43
|
+
sequence = SEQUENCES.keys.find { |candidate| @bytes.start_with?(candidate) }
|
|
44
|
+
if sequence
|
|
45
|
+
edit(SEQUENCES.fetch(sequence))
|
|
46
|
+
@bytes.slice!(0, sequence.bytesize)
|
|
47
|
+
else
|
|
48
|
+
match = @bytes.match(%r{\A\e(?:\[[0-?]*[ -/]*[@-~]|O.|.)}m)
|
|
49
|
+
@bytes.slice!(0, match ? match[0].bytesize : 1)
|
|
50
|
+
end
|
|
51
|
+
next
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
byte = @bytes.getbyte(0)
|
|
55
|
+
if @after_cr && byte == 10
|
|
56
|
+
@bytes.slice!(0, 1)
|
|
57
|
+
@after_cr = false
|
|
58
|
+
next
|
|
59
|
+
end
|
|
60
|
+
@after_cr = byte == 13
|
|
61
|
+
if CONTROLS.key?(byte)
|
|
62
|
+
@bytes.slice!(0, 1)
|
|
63
|
+
event = edit(CONTROLS.fetch(byte))
|
|
64
|
+
events << event if event
|
|
65
|
+
elsif byte < 32
|
|
66
|
+
@bytes.slice!(0, 1)
|
|
67
|
+
else
|
|
68
|
+
character = next_character
|
|
69
|
+
break unless character
|
|
70
|
+
|
|
71
|
+
insert(character)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
unless detach.empty?
|
|
75
|
+
@bytes.clear
|
|
76
|
+
clear
|
|
77
|
+
events << :detach
|
|
78
|
+
end
|
|
79
|
+
events
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def clear
|
|
83
|
+
replace("")
|
|
84
|
+
@history_index = nil
|
|
85
|
+
@draft = nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def edit(action)
|
|
91
|
+
case action
|
|
92
|
+
when :left then @cursor -= 1 if cursor.positive?
|
|
93
|
+
when :right then @cursor += 1 if cursor < graphemes.size
|
|
94
|
+
when :home then @cursor = 0
|
|
95
|
+
when :end then @cursor = graphemes.size
|
|
96
|
+
when :backspace then remove(cursor - 1) if cursor.positive?
|
|
97
|
+
when :delete then remove(cursor) if cursor < graphemes.size
|
|
98
|
+
when :delete_word then delete_word
|
|
99
|
+
when :clear then clear
|
|
100
|
+
when :history_previous then history_previous
|
|
101
|
+
when :history_next then history_next
|
|
102
|
+
when :submit then return submit
|
|
103
|
+
when :interrupt
|
|
104
|
+
clear
|
|
105
|
+
return :interrupt
|
|
106
|
+
when :eof
|
|
107
|
+
return :eof if text.empty?
|
|
108
|
+
|
|
109
|
+
remove(cursor) if cursor < graphemes.size
|
|
110
|
+
when :detach
|
|
111
|
+
clear
|
|
112
|
+
return :detach
|
|
113
|
+
end
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def submit
|
|
118
|
+
line = text.dup
|
|
119
|
+
@history << line.freeze unless line.empty? || @history.last == line
|
|
120
|
+
@history.shift if @history.size > HISTORY_SIZE
|
|
121
|
+
clear
|
|
122
|
+
[:submit, line]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def insert(character)
|
|
126
|
+
return if text.bytesize + character.bytesize > MAX_BYTES
|
|
127
|
+
return if character.match?(/[\u0080-\u009f]/)
|
|
128
|
+
|
|
129
|
+
if character.ascii_only? && cursor == text.length
|
|
130
|
+
@text << character
|
|
131
|
+
@cursor += 1
|
|
132
|
+
@history_index = nil
|
|
133
|
+
return
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
parts = graphemes
|
|
137
|
+
parts.insert(cursor, character)
|
|
138
|
+
@text = parts.join
|
|
139
|
+
@cursor = [cursor + 1, graphemes.size].min
|
|
140
|
+
@history_index = nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def remove(index)
|
|
144
|
+
parts = graphemes
|
|
145
|
+
parts.delete_at(index)
|
|
146
|
+
@text = parts.join
|
|
147
|
+
@cursor -= 1 if index < cursor
|
|
148
|
+
@history_index = nil
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def delete_word
|
|
152
|
+
parts = graphemes
|
|
153
|
+
index = cursor
|
|
154
|
+
index -= 1 while index.positive? && parts[index - 1].match?(/\s/)
|
|
155
|
+
index -= 1 while index.positive? && !parts[index - 1].match?(/\s/)
|
|
156
|
+
parts.slice!(index...cursor)
|
|
157
|
+
@text = parts.join
|
|
158
|
+
@cursor = index
|
|
159
|
+
@history_index = nil
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def history_previous
|
|
163
|
+
return if @history.empty?
|
|
164
|
+
|
|
165
|
+
@draft = text.dup unless @history_index
|
|
166
|
+
@history_index = [(@history_index || @history.size) - 1, 0].max
|
|
167
|
+
replace(@history.fetch(@history_index))
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def history_next
|
|
171
|
+
return unless @history_index
|
|
172
|
+
|
|
173
|
+
@history_index += 1
|
|
174
|
+
if @history_index >= @history.size
|
|
175
|
+
@history_index = nil
|
|
176
|
+
replace(@draft.to_s)
|
|
177
|
+
@draft = nil
|
|
178
|
+
else
|
|
179
|
+
replace(@history.fetch(@history_index))
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def replace(value)
|
|
184
|
+
@text = +value
|
|
185
|
+
@cursor = graphemes.size
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def graphemes
|
|
189
|
+
text.scan(/\X/)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def next_character
|
|
193
|
+
size = utf8_size(@bytes.getbyte(0))
|
|
194
|
+
return @bytes.slice!(0, 1).force_encoding(Encoding::UTF_8).scrub unless size
|
|
195
|
+
unless @bytes.byteslice(1, size - 1).bytes.all? { |byte| byte.between?(128, 191) }
|
|
196
|
+
return @bytes.slice!(0, 1).force_encoding(Encoding::UTF_8).scrub
|
|
197
|
+
end
|
|
198
|
+
return if @bytes.bytesize < size
|
|
199
|
+
|
|
200
|
+
@bytes.slice!(0, size).force_encoding(Encoding::UTF_8).scrub
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def utf8_size(byte)
|
|
204
|
+
case byte
|
|
205
|
+
when 0..127 then 1
|
|
206
|
+
when 194..223 then 2
|
|
207
|
+
when 224..239 then 3
|
|
208
|
+
when 240..244 then 4
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def incomplete_escape?
|
|
213
|
+
@bytes == "\e" || @bytes == "\e[" || @bytes == "\eO" ||
|
|
214
|
+
(@bytes.start_with?("\e[") && !@bytes.match?(%r{\A\e\[[0-?]*[ -/]*[@-~]}))
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
@@ -12,7 +12,7 @@ module Foruiman::TUI
|
|
|
12
12
|
"\t" => :next, "h" => :previous, "l" => :next, "k" => :up, "j" => :down,
|
|
13
13
|
"g" => :home, "G" => :end, "f" => :follow, " " => :toggle_follow,
|
|
14
14
|
"r" => :restart, "R" => :restart_all, "s" => :stop, "S" => :stop_all,
|
|
15
|
-
"?" => :help, "q" => :quit, "\x03" => :quit,
|
|
15
|
+
"i" => :input, "?" => :help, "q" => :quit, "\x03" => :quit,
|
|
16
16
|
"\x15" => :page_up, "\x04" => :page_down
|
|
17
17
|
}.freeze
|
|
18
18
|
|
|
@@ -30,6 +30,7 @@ module Foruiman::TUI
|
|
|
30
30
|
|
|
31
31
|
def stream_style(stream)
|
|
32
32
|
case stream
|
|
33
|
+
when :stdin then ["in ", :cyan]
|
|
33
34
|
when :stderr then ["err", :red]
|
|
34
35
|
when :lifecycle then ["sys", :amber]
|
|
35
36
|
else ["out", :faint]
|
|
@@ -38,6 +39,7 @@ module Foruiman::TUI
|
|
|
38
39
|
|
|
39
40
|
def body_color(stream)
|
|
40
41
|
case stream
|
|
42
|
+
when :stdin then :cyan
|
|
41
43
|
when :stderr then :red
|
|
42
44
|
when :lifecycle then :muted
|
|
43
45
|
else :text
|
|
@@ -16,7 +16,8 @@ module Foruiman::TUI
|
|
|
16
16
|
[nil, "f / G / End", "Follow newest output", nil],
|
|
17
17
|
[nil, "Space", "Pause / resume following", nil],
|
|
18
18
|
["⚙ PROCESSES", "r / R", "Restart selected / all", "r on all restarts all"],
|
|
19
|
-
[nil, "s / S", "
|
|
19
|
+
[nil, "s / S", "Start/stop selected / stop all", "s on all stops all"],
|
|
20
|
+
["⌨ INPUT", "i", "Edit a command; Enter sends", "Ctrl-X returns to Foruiman"],
|
|
20
21
|
["◇ SESSION", "? / Escape", "Close help", nil],
|
|
21
22
|
[nil, "q / Ctrl-C", "Stop processes and quit", nil]
|
|
22
23
|
].freeze
|
|
@@ -30,16 +31,16 @@ module Foruiman::TUI
|
|
|
30
31
|
rows >= 14 && columns >= 60
|
|
31
32
|
end
|
|
32
33
|
|
|
33
|
-
def self.log_height(rows:, columns:)
|
|
34
|
+
def self.log_height(rows:, columns:, input: false)
|
|
34
35
|
return 0 if rows < 6 || columns < 24
|
|
35
36
|
|
|
36
|
-
rows - (expanded?(rows: rows, columns: columns) ? 7 : 5)
|
|
37
|
+
rows - (expanded?(rows: rows, columns: columns) ? 7 : 5) - (input && rows >= 8 ? 2 : 0)
|
|
37
38
|
end
|
|
38
39
|
|
|
39
40
|
def render(state, engine, rows:, columns:)
|
|
40
41
|
@width = [columns - 1, 1].max
|
|
41
42
|
@inside = [@width - 4, 0].max
|
|
42
|
-
@height = self.class.log_height(rows: rows, columns: columns)
|
|
43
|
+
@height = self.class.log_height(rows: rows, columns: columns, input: state.input?)
|
|
43
44
|
return tiny_frame(state, engine, rows) if @height.zero?
|
|
44
45
|
|
|
45
46
|
lines = [header(engine)]
|
|
@@ -54,6 +55,7 @@ module Foruiman::TUI
|
|
|
54
55
|
lines << log_header(state, engine)
|
|
55
56
|
lines.concat(state.help ? help_rows : log_rows(state, engine))
|
|
56
57
|
lines << log_footer(state, engine)
|
|
58
|
+
lines.concat(input_rows(state)) if state.input? && rows >= 8
|
|
57
59
|
lines << controls(state, engine)
|
|
58
60
|
frame(lines, rows)
|
|
59
61
|
end
|
|
@@ -157,7 +159,9 @@ module Foruiman::TUI
|
|
|
157
159
|
else
|
|
158
160
|
engine.state(state.name).status == :running
|
|
159
161
|
end
|
|
160
|
-
mode = if
|
|
162
|
+
mode = if state.input?
|
|
163
|
+
@theme.paint(" ▶ INPUT ", :cyan)
|
|
164
|
+
elsif !state.viewport.following
|
|
161
165
|
@theme.paint(" Ⅱ PAUSED ", :amber)
|
|
162
166
|
elsif active
|
|
163
167
|
@theme.paint(" ● LIVE ", :green)
|
|
@@ -244,6 +248,12 @@ module Foruiman::TUI
|
|
|
244
248
|
end
|
|
245
249
|
|
|
246
250
|
def controls(state, engine)
|
|
251
|
+
if state.input?
|
|
252
|
+
left = @theme.paint(" Enter send · ↑↓ history · Ctrl-C interrupt", :muted)
|
|
253
|
+
right = @theme.paint(" Ctrl-X back ", :amber, bold: true)
|
|
254
|
+
return distribute(left, right, @width)
|
|
255
|
+
end
|
|
256
|
+
|
|
247
257
|
quit = @theme.paint(" q ", :accent, bold: true) + @theme.paint(" × quit ", :muted)
|
|
248
258
|
left = if engine.shutting_down?
|
|
249
259
|
@theme.paint(" ◌ Stopping process groups · TERM → KILL after 5s", :amber)
|
|
@@ -252,20 +262,46 @@ module Foruiman::TUI
|
|
|
252
262
|
elsif state.help
|
|
253
263
|
@theme.paint(" ? / Escape close help", :muted)
|
|
254
264
|
else
|
|
255
|
-
shortcuts(state)
|
|
265
|
+
shortcuts(state, engine)
|
|
256
266
|
end
|
|
257
267
|
distribute(left, quit, @width)
|
|
258
268
|
end
|
|
259
269
|
|
|
260
|
-
def shortcuts(state)
|
|
270
|
+
def shortcuts(state, engine)
|
|
261
271
|
restart = ["r", state.name == "all" ? "↻ restart all" : "↻ restart"]
|
|
262
|
-
|
|
263
|
-
|
|
272
|
+
toggle = if state.name == "all"
|
|
273
|
+
["s", "■ stop all"]
|
|
274
|
+
elsif process_active?(engine.state(state.name))
|
|
275
|
+
["s", "■ stop"]
|
|
276
|
+
else
|
|
277
|
+
["s", "▶ start"]
|
|
278
|
+
end
|
|
279
|
+
pairs = [%w[Tab switch], ["↑↓", "scroll"], %w[f follow], restart,
|
|
280
|
+
toggle, %w[i input], ["?", "help"]]
|
|
281
|
+
pairs = [restart, toggle, %w[i input], ["?", "help"]] if @width < 85
|
|
264
282
|
pairs.map do |key, label|
|
|
265
283
|
@theme.paint(" #{key} ", :accent, bold: true) + @theme.paint(" #{label} ", :muted)
|
|
266
284
|
end.join
|
|
267
285
|
end
|
|
268
286
|
|
|
287
|
+
def process_active?(entry)
|
|
288
|
+
entry.pgid || %i[running restarting stopping].include?(entry.status)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def input_rows(state)
|
|
292
|
+
editor = state.input_line
|
|
293
|
+
parts = editor.text.scan(/\X/)
|
|
294
|
+
before = parts.take(editor.cursor)
|
|
295
|
+
current = parts[editor.cursor] || " "
|
|
296
|
+
after = parts.drop(editor.cursor + 1).join
|
|
297
|
+
available = [@inside - 5, 1].max
|
|
298
|
+
before.shift while !before.empty? && Text.width(before.join + current) > available
|
|
299
|
+
draft = @theme.paint("❯ ", :cyan, bold: true) + before.join +
|
|
300
|
+
@theme.paint(current,
|
|
301
|
+
selected: true) + Text.clip(after, [available - Text.width(before.join + current), 0].max)
|
|
302
|
+
[border(@theme.paint(" ⌨ Input to #{state.input_target} ", :cyan, bold: true)), panel(draft)]
|
|
303
|
+
end
|
|
304
|
+
|
|
269
305
|
def border(left, right = "", bottom: false)
|
|
270
306
|
corners = bottom ? %w[╰ ╯] : %w[╭ ╮]
|
|
271
307
|
middle = distribute(left, right, @inside, fill: "─")
|
|
@@ -290,7 +326,7 @@ module Foruiman::TUI
|
|
|
290
326
|
title = @theme.paint(" FORUIMAN", :accent, bold: true)
|
|
291
327
|
message = engine.shutting_down? ? " Stopping…" : " #{state.name} · enlarge terminal"
|
|
292
328
|
lines = [title, @theme.paint(message, :muted)]
|
|
293
|
-
lines[rows - 1] = @theme.paint(" q quit", :accent) if rows > 2
|
|
329
|
+
lines[rows - 1] = @theme.paint(state.input? ? " Ctrl-X back" : " q quit", :accent) if rows > 2
|
|
294
330
|
frame(lines, rows)
|
|
295
331
|
end
|
|
296
332
|
|
data/lib/foruiman/tui/state.rb
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "viewport"
|
|
4
|
+
require_relative "input_line"
|
|
4
5
|
|
|
5
6
|
module Foruiman::TUI
|
|
6
7
|
class State
|
|
7
8
|
attr_reader :tabs, :selected, :viewports
|
|
8
|
-
attr_accessor :help, :feedback
|
|
9
|
+
attr_accessor :help, :feedback, :input_target
|
|
9
10
|
|
|
10
11
|
def initialize(names)
|
|
11
12
|
@tabs = [*names, "all"].freeze
|
|
@@ -13,6 +14,8 @@ module Foruiman::TUI
|
|
|
13
14
|
@viewports = tabs.to_h { |name| [name, Viewport.new] }
|
|
14
15
|
@help = false
|
|
15
16
|
@feedback = nil
|
|
17
|
+
@input_target = nil
|
|
18
|
+
@input_lines = names.to_h { |name| [name, InputLine.new] }
|
|
16
19
|
end
|
|
17
20
|
|
|
18
21
|
def name
|
|
@@ -23,6 +26,14 @@ module Foruiman::TUI
|
|
|
23
26
|
viewports.fetch(name)
|
|
24
27
|
end
|
|
25
28
|
|
|
29
|
+
def input?
|
|
30
|
+
!input_target.nil?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def input_line
|
|
34
|
+
@input_lines.fetch(input_target || name)
|
|
35
|
+
end
|
|
36
|
+
|
|
26
37
|
def select(index)
|
|
27
38
|
@selected = index if index.between?(0, tabs.size - 1)
|
|
28
39
|
end
|
data/lib/foruiman/tui/theme.rb
CHANGED
|
@@ -16,8 +16,8 @@ module Foruiman::TUI
|
|
|
16
16
|
stopped: :muted, exited: :muted, failed: :red
|
|
17
17
|
}.freeze
|
|
18
18
|
STATUS_MARKS = {
|
|
19
|
-
pending: "○", running: "
|
|
20
|
-
stopped: "■", exited: "✓", failed: "
|
|
19
|
+
pending: "○", running: "▶", restarting: "↻", stopping: "◼",
|
|
20
|
+
stopped: "■", exited: "✓", failed: "✕"
|
|
21
21
|
}.freeze
|
|
22
22
|
|
|
23
23
|
attr_reader :enabled
|
data/lib/foruiman/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: foruiman
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Foruiman contributors
|
|
@@ -75,11 +75,13 @@ files:
|
|
|
75
75
|
- lib/foruiman/env.rb
|
|
76
76
|
- lib/foruiman/log_store.rb
|
|
77
77
|
- lib/foruiman/output.rb
|
|
78
|
+
- lib/foruiman/output_line.rb
|
|
78
79
|
- lib/foruiman/plain.rb
|
|
79
80
|
- lib/foruiman/process.rb
|
|
80
81
|
- lib/foruiman/procfile.rb
|
|
81
82
|
- lib/foruiman/ring.rb
|
|
82
83
|
- lib/foruiman/tui/application.rb
|
|
84
|
+
- lib/foruiman/tui/input_line.rb
|
|
83
85
|
- lib/foruiman/tui/keyboard.rb
|
|
84
86
|
- lib/foruiman/tui/log_formatter.rb
|
|
85
87
|
- lib/foruiman/tui/renderer.rb
|