foruiman 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +37 -0
- data/LICENSE +19 -0
- data/README.md +102 -0
- data/bin/foruiman +6 -0
- data/docs/ARCHITECTURE.md +84 -0
- data/docs/ASSUMPTIONS.md +23 -0
- data/docs/COMPATIBILITY.md +27 -0
- data/docs/RAILS.md +54 -0
- data/docs/UPSTREAM.md +25 -0
- data/docs/terminal-preview.png +0 -0
- data/lib/foruiman/ansi.rb +147 -0
- data/lib/foruiman/cli.rb +83 -0
- data/lib/foruiman/diagnostics.rb +27 -0
- data/lib/foruiman/engine.rb +403 -0
- data/lib/foruiman/env.rb +35 -0
- data/lib/foruiman/log_store.rb +31 -0
- data/lib/foruiman/output.rb +76 -0
- data/lib/foruiman/plain.rb +22 -0
- data/lib/foruiman/process.rb +19 -0
- data/lib/foruiman/procfile.rb +81 -0
- data/lib/foruiman/ring.rb +53 -0
- data/lib/foruiman/tui/application.rb +105 -0
- data/lib/foruiman/tui/keyboard.rb +66 -0
- data/lib/foruiman/tui/log_formatter.rb +47 -0
- data/lib/foruiman/tui/renderer.rb +306 -0
- data/lib/foruiman/tui/state.rb +34 -0
- data/lib/foruiman/tui/terminal.rb +44 -0
- data/lib/foruiman/tui/text.rb +45 -0
- data/lib/foruiman/tui/theme.rb +71 -0
- data/lib/foruiman/tui/viewport.rb +56 -0
- data/lib/foruiman/version.rb +5 -0
- data/lib/foruiman.rb +11 -0
- metadata +116 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Foruiman::Ring
|
|
4
|
+
include Enumerable
|
|
5
|
+
|
|
6
|
+
attr_reader :capacity, :size
|
|
7
|
+
|
|
8
|
+
def initialize(capacity)
|
|
9
|
+
raise Foruiman::Error, "log-lines must be a positive integer" unless capacity.is_a?(Integer) && capacity.positive?
|
|
10
|
+
|
|
11
|
+
@capacity = capacity
|
|
12
|
+
@slots = []
|
|
13
|
+
@positions = {}
|
|
14
|
+
@head = 0
|
|
15
|
+
@size = 0
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def append(record)
|
|
19
|
+
index = (@head + @size) % capacity
|
|
20
|
+
if size == capacity
|
|
21
|
+
@positions.delete(@slots[index].sequence)
|
|
22
|
+
@head = (@head + 1) % capacity
|
|
23
|
+
else
|
|
24
|
+
@size += 1
|
|
25
|
+
end
|
|
26
|
+
@slots[index] = record
|
|
27
|
+
@positions[record.sequence] = index
|
|
28
|
+
record
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def replace(record)
|
|
32
|
+
index = @positions[record.sequence]
|
|
33
|
+
@slots[index] = record if index
|
|
34
|
+
record
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def [](index)
|
|
38
|
+
return unless index >= 0 && index < size
|
|
39
|
+
|
|
40
|
+
@slots[(@head + index) % capacity]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def index_of(sequence)
|
|
44
|
+
position = @positions[sequence]
|
|
45
|
+
(position - @head) % capacity if position
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def each
|
|
49
|
+
return enum_for(:each) unless block_given?
|
|
50
|
+
|
|
51
|
+
size.times { |index| yield self[index] }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "terminal"
|
|
4
|
+
require_relative "state"
|
|
5
|
+
require_relative "keyboard"
|
|
6
|
+
require_relative "renderer"
|
|
7
|
+
|
|
8
|
+
module Foruiman::TUI
|
|
9
|
+
class Application
|
|
10
|
+
attr_reader :state
|
|
11
|
+
|
|
12
|
+
def initialize(engine, terminal: Terminal.new, renderer: Renderer.new)
|
|
13
|
+
@engine = engine
|
|
14
|
+
@terminal = terminal
|
|
15
|
+
@renderer = renderer
|
|
16
|
+
@keyboard = Keyboard.new
|
|
17
|
+
@state = State.new(engine.processes.map(&:name))
|
|
18
|
+
@last_frame = nil
|
|
19
|
+
@next_frame_at = 0
|
|
20
|
+
@was_shutting_down = false
|
|
21
|
+
@feedback_until = 0
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def run
|
|
25
|
+
@terminal.session do
|
|
26
|
+
@engine.run(keep_open: true) do
|
|
27
|
+
rows, columns = @terminal.size
|
|
28
|
+
input = @terminal.read
|
|
29
|
+
@engine.shutdown if input.nil?
|
|
30
|
+
height = [Renderer.log_height(rows: rows, columns: columns), 1].max
|
|
31
|
+
@keyboard.feed(input.is_a?(String) ? input : "").each { |key| handle(key, height) }
|
|
32
|
+
state.feedback = nil if monotonic >= @feedback_until
|
|
33
|
+
next if monotonic < @next_frame_at && @was_shutting_down == @engine.shutting_down?
|
|
34
|
+
|
|
35
|
+
@was_shutting_down = @engine.shutting_down?
|
|
36
|
+
|
|
37
|
+
@next_frame_at = monotonic + (1.0 / 30)
|
|
38
|
+
frame = @renderer.render(state, @engine, rows: rows, columns: columns)
|
|
39
|
+
if frame != @last_frame
|
|
40
|
+
@terminal.draw(frame)
|
|
41
|
+
@last_frame = frame
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
ensure
|
|
46
|
+
@engine.close
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def handle(key, height)
|
|
50
|
+
return if @engine.shutting_down?
|
|
51
|
+
|
|
52
|
+
if key.is_a?(Integer)
|
|
53
|
+
state.select(key.zero? ? state.tabs.size - 1 : key - 1)
|
|
54
|
+
return
|
|
55
|
+
end
|
|
56
|
+
buffer = @engine.logs[state.name]
|
|
57
|
+
case key
|
|
58
|
+
when :next then state.move(1)
|
|
59
|
+
when :previous then state.move(-1)
|
|
60
|
+
when :up then state.viewport.scroll(-1, buffer, height)
|
|
61
|
+
when :down then state.viewport.scroll(1, buffer, height)
|
|
62
|
+
when :page_up then state.viewport.scroll(-height, buffer, height)
|
|
63
|
+
when :page_down then state.viewport.scroll(height, buffer, height)
|
|
64
|
+
when :home then state.viewport.home(buffer)
|
|
65
|
+
when :end, :follow then state.viewport.follow
|
|
66
|
+
when :toggle_follow then state.viewport.toggle(buffer, height)
|
|
67
|
+
when :help then state.help = !state.help
|
|
68
|
+
when :escape then state.help = false
|
|
69
|
+
when :restart, :stop then control(key)
|
|
70
|
+
when :restart_all, :stop_all
|
|
71
|
+
control_all(key == :restart_all ? :restart : :stop)
|
|
72
|
+
when :quit then @engine.shutdown
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def control(action)
|
|
79
|
+
if state.name == "all"
|
|
80
|
+
if action == :restart
|
|
81
|
+
control_all(action)
|
|
82
|
+
else
|
|
83
|
+
feedback("Select a process first; S stops all processes")
|
|
84
|
+
end
|
|
85
|
+
else
|
|
86
|
+
@engine.public_send(action, state.name)
|
|
87
|
+
feedback("#{action == :restart ? 'Restarting' : 'Stopping'} #{state.name}")
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def control_all(action)
|
|
92
|
+
@engine.processes.each { |entry| @engine.public_send(action, entry.name) }
|
|
93
|
+
feedback("#{action == :restart ? 'Restarting' : 'Stopping'} all processes")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def feedback(message)
|
|
97
|
+
state.feedback = message
|
|
98
|
+
@feedback_until = monotonic + 2
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def monotonic
|
|
102
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Foruiman::TUI
|
|
4
|
+
class Keyboard
|
|
5
|
+
SEQUENCES = {
|
|
6
|
+
"\e[A" => :up, "\e[B" => :down, "\e[C" => :next, "\e[D" => :previous,
|
|
7
|
+
"\e[5~" => :page_up, "\e[6~" => :page_down, "\e[H" => :home, "\e[F" => :end,
|
|
8
|
+
"\e[1~" => :home, "\e[4~" => :end, "\eOH" => :home, "\eOF" => :end,
|
|
9
|
+
"\e[Z" => :previous
|
|
10
|
+
}.freeze
|
|
11
|
+
KEYS = {
|
|
12
|
+
"\t" => :next, "h" => :previous, "l" => :next, "k" => :up, "j" => :down,
|
|
13
|
+
"g" => :home, "G" => :end, "f" => :follow, " " => :toggle_follow,
|
|
14
|
+
"r" => :restart, "R" => :restart_all, "s" => :stop, "S" => :stop_all,
|
|
15
|
+
"?" => :help, "q" => :quit, "\x03" => :quit,
|
|
16
|
+
"\x15" => :page_up, "\x04" => :page_down
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
def initialize
|
|
20
|
+
@buffer = +"".b
|
|
21
|
+
@escape_at = nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def feed(bytes, now: monotonic)
|
|
25
|
+
@buffer << bytes
|
|
26
|
+
actions = []
|
|
27
|
+
until @buffer.empty?
|
|
28
|
+
if @buffer.start_with?("\e")
|
|
29
|
+
sequence = SEQUENCES.keys.find { |key| @buffer.start_with?(key) }
|
|
30
|
+
if sequence
|
|
31
|
+
actions << SEQUENCES.fetch(sequence)
|
|
32
|
+
@buffer.slice!(0, sequence.bytesize)
|
|
33
|
+
elsif incomplete_escape?
|
|
34
|
+
@escape_at ||= now
|
|
35
|
+
break if now - @escape_at < 0.1 && @buffer.bytesize < 64
|
|
36
|
+
|
|
37
|
+
actions << :escape if @buffer == "\e"
|
|
38
|
+
@buffer.clear
|
|
39
|
+
else
|
|
40
|
+
# Ignore an unknown complete escape as a unit; its final byte must
|
|
41
|
+
# never accidentally become a destructive shortcut such as R.
|
|
42
|
+
match = @buffer.match(%r{\A\e(?:\[[0-?]*[ -/]*[@-~]|O.|.)}m)
|
|
43
|
+
@buffer.slice!(0, match ? match[0].bytesize : 1)
|
|
44
|
+
end
|
|
45
|
+
else
|
|
46
|
+
key = @buffer.slice!(0, 1)
|
|
47
|
+
actions << (key.match?(/[0-9]/) ? key.to_i : KEYS[key])
|
|
48
|
+
end
|
|
49
|
+
@escape_at = nil
|
|
50
|
+
end
|
|
51
|
+
actions.compact
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def incomplete_escape?
|
|
57
|
+
return true if @buffer == "\e" || @buffer == "\eO"
|
|
58
|
+
|
|
59
|
+
@buffer.start_with?("\e[") && !@buffer.match?(%r{\A\e\[[0-?]*[ -/]*[@-~]})
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def monotonic
|
|
63
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "text"
|
|
4
|
+
require_relative "theme"
|
|
5
|
+
|
|
6
|
+
module Foruiman::TUI
|
|
7
|
+
class LogFormatter
|
|
8
|
+
def initialize(theme, names)
|
|
9
|
+
@theme = theme
|
|
10
|
+
@colors = names.each_with_index.to_h { |name, index| [name, theme.process(index)] }
|
|
11
|
+
@name_width = names.map { |name| Text.width(name) }.max.to_i.clamp(3, 14)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def row(record, aggregate:, width:)
|
|
15
|
+
prefix = +""
|
|
16
|
+
prefix << @theme.paint("#{record.time.strftime('%H:%M:%S')} ", :faint) if width >= 55
|
|
17
|
+
if aggregate
|
|
18
|
+
name_width = [@name_width, width / 5].min
|
|
19
|
+
name = Text.pad(Text.clip(record.name, name_width, ellipsis: true), name_width)
|
|
20
|
+
prefix << @theme.paint(name, @colors.fetch(record.name, :cyan)) << " "
|
|
21
|
+
end
|
|
22
|
+
marker, color = stream_style(record.stream)
|
|
23
|
+
prefix << @theme.paint(marker, color) << @theme.paint(" │ ", :border)
|
|
24
|
+
content = record.stream == :lifecycle ? record.text.delete_prefix("--- ").delete_suffix(" ---") : record.text
|
|
25
|
+
content = Text.clip(content, [width - Text.width(prefix), 0].max, ellipsis: true)
|
|
26
|
+
prefix + @theme.log(content, color: body_color(record.stream))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def stream_style(stream)
|
|
32
|
+
case stream
|
|
33
|
+
when :stderr then ["err", :red]
|
|
34
|
+
when :lifecycle then ["sys", :amber]
|
|
35
|
+
else ["out", :faint]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def body_color(stream)
|
|
40
|
+
case stream
|
|
41
|
+
when :stderr then :red
|
|
42
|
+
when :lifecycle then :muted
|
|
43
|
+
else :text
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "text"
|
|
4
|
+
require_relative "theme"
|
|
5
|
+
require_relative "log_formatter"
|
|
6
|
+
require "pathname"
|
|
7
|
+
|
|
8
|
+
module Foruiman::TUI
|
|
9
|
+
class Renderer
|
|
10
|
+
HELP = [
|
|
11
|
+
["↔ NAVIGATE", "Tab / Shift-Tab", "Change tab", "← → or h l"],
|
|
12
|
+
[nil, "1–9 / 0", "Select a process / all logs", nil],
|
|
13
|
+
["≡ READ LOGS", "↑ ↓ or k j", "Scroll a line", nil],
|
|
14
|
+
[nil, "PgUp / PgDn", "Scroll a page", "Ctrl-U / Ctrl-D"],
|
|
15
|
+
[nil, "g / Home", "Oldest retained line", nil],
|
|
16
|
+
[nil, "f / G / End", "Follow newest output", nil],
|
|
17
|
+
[nil, "Space", "Pause / resume following", nil],
|
|
18
|
+
["⚙ PROCESSES", "r / R", "Restart selected / all", "r on all restarts all"],
|
|
19
|
+
[nil, "s / S", "Stop selected / all", nil],
|
|
20
|
+
["◇ SESSION", "? / Escape", "Close help", nil],
|
|
21
|
+
[nil, "q / Ctrl-C", "Stop processes and quit", nil]
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
def initialize(theme: Theme.new)
|
|
25
|
+
@theme = theme
|
|
26
|
+
@first_tab = 0
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.expanded?(rows:, columns:)
|
|
30
|
+
rows >= 14 && columns >= 60
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.log_height(rows:, columns:)
|
|
34
|
+
return 0 if rows < 6 || columns < 24
|
|
35
|
+
|
|
36
|
+
rows - (expanded?(rows: rows, columns: columns) ? 7 : 5)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def render(state, engine, rows:, columns:)
|
|
40
|
+
@width = [columns - 1, 1].max
|
|
41
|
+
@inside = [@width - 4, 0].max
|
|
42
|
+
@height = self.class.log_height(rows: rows, columns: columns)
|
|
43
|
+
return tiny_frame(state, engine, rows) if @height.zero?
|
|
44
|
+
|
|
45
|
+
lines = [header(engine)]
|
|
46
|
+
if self.class.expanded?(rows: rows, columns: columns)
|
|
47
|
+
hint = @inside >= 70 ? " Tab switch · 1–9 / 0 select " : ""
|
|
48
|
+
lines << border(@theme.paint(" processes ", :muted, bold: true), @theme.paint(hint, :faint))
|
|
49
|
+
lines << panel(tab_row(state, engine, @inside - 2))
|
|
50
|
+
lines << border("", "", bottom: true)
|
|
51
|
+
else
|
|
52
|
+
lines << " #{@theme.paint('│', :border)} #{tab_row(state, engine, @inside - 2)}"
|
|
53
|
+
end
|
|
54
|
+
lines << log_header(state, engine)
|
|
55
|
+
lines.concat(state.help ? help_rows : log_rows(state, engine))
|
|
56
|
+
lines << log_footer(state, engine)
|
|
57
|
+
lines << controls(state, engine)
|
|
58
|
+
frame(lines, rows)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.width(text)
|
|
62
|
+
Text.width(text)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.truncate(text, columns)
|
|
66
|
+
Text.clip(text, columns) + Foruiman::ANSI::RESET
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def header(engine)
|
|
72
|
+
brand = @width >= 45 ? "▰ FORUIMAN" : "▰"
|
|
73
|
+
left = " #{@theme.paint(brand, :accent, bold: true)}"
|
|
74
|
+
right = if engine.shutting_down?
|
|
75
|
+
@theme.paint("◌ shutting down", :amber)
|
|
76
|
+
elsif @width >= 60
|
|
77
|
+
summary(engine)
|
|
78
|
+
else
|
|
79
|
+
""
|
|
80
|
+
end
|
|
81
|
+
source = if engine.procfile_path
|
|
82
|
+
Text.clean(Pathname.new(engine.procfile_path).relative_path_from(Pathname.new(engine.root)).to_s)
|
|
83
|
+
end
|
|
84
|
+
project = @theme.paint(" / #{Text.clean(File.basename(engine.root))}", :muted)
|
|
85
|
+
left += project if @width >= 70 && Text.width(left + project + source.to_s + right) + 5 <= @width
|
|
86
|
+
if source
|
|
87
|
+
available = [@width - Text.width(left + right) - 4, 0].max
|
|
88
|
+
source = "…/#{File.basename(source)}" if Text.width(source) > available && source.include?("/")
|
|
89
|
+
left += @theme.paint(" #{Text.clip(source, available, ellipsis: true)}")
|
|
90
|
+
end
|
|
91
|
+
distribute(left, "#{right} ", @width)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def summary(engine)
|
|
95
|
+
running = engine.processes.count { |entry| entry.status == :running }
|
|
96
|
+
failed = engine.processes.count { |entry| entry.status == :failed }
|
|
97
|
+
summary = @theme.paint("● #{running} running", running.positive? ? :green : :muted)
|
|
98
|
+
summary += @theme.paint(" × #{failed} failed", :red) if failed.positive? && @width >= 55
|
|
99
|
+
summary
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def tab_row(state, engine, width)
|
|
103
|
+
@first_tab = state.selected if state.selected < @first_tab
|
|
104
|
+
labels = state.tabs.each_with_index.map { |name, index| tab(name, index, state, engine, width) }
|
|
105
|
+
@first_tab += 1 while @first_tab < state.selected && tab_span(labels, @first_tab, state.selected) > width
|
|
106
|
+
output = +(@first_tab.positive? ? @theme.paint("‹ ", :muted) : "")
|
|
107
|
+
index = @first_tab
|
|
108
|
+
while index < labels.size
|
|
109
|
+
separator = index == @first_tab ? "" : " "
|
|
110
|
+
more = index < labels.size - 1 ? 2 : 0
|
|
111
|
+
available = width - Text.width(output) - more - separator.size
|
|
112
|
+
break if Text.width(labels[index]) > available && index > @first_tab
|
|
113
|
+
|
|
114
|
+
output << separator << Text.clip(labels[index], [available, 0].max) << @theme.reset
|
|
115
|
+
index += 1
|
|
116
|
+
end
|
|
117
|
+
output << @theme.paint(" ›", :muted) if index < labels.size
|
|
118
|
+
Text.clip(output, width)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def tab_span(labels, first, last)
|
|
122
|
+
Text.width(labels[first..last].join(" ")) + (first.positive? ? 2 : 0) + (last < labels.size - 1 ? 2 : 0)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def tab(name, index, state, engine, width)
|
|
126
|
+
selected = index == state.selected
|
|
127
|
+
entry = name == "all" ? nil : engine.processes.find { |process| process.name == name }
|
|
128
|
+
mark = name == "all" ? "≡" : Theme::STATUS_MARKS.fetch(entry&.status, "○")
|
|
129
|
+
number = if name == "all"
|
|
130
|
+
"0"
|
|
131
|
+
else
|
|
132
|
+
(index < 9 ? (index + 1).to_s : "·")
|
|
133
|
+
end
|
|
134
|
+
name = Text.clip(name, [width - 12, 6].max.clamp(6, 22), ellipsis: true)
|
|
135
|
+
color = name == "all" ? :accent : @theme.process(index)
|
|
136
|
+
mark_color = entry ? Theme::STATUS_COLORS.fetch(entry.status, :muted) : :muted
|
|
137
|
+
@theme.paint(" #{number} ", selected ? :muted : :faint, selected: selected) +
|
|
138
|
+
@theme.paint("#{mark} ", mark_color, selected: selected) +
|
|
139
|
+
@theme.paint("#{name} ", color, selected: selected, bold: selected)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def log_header(state, engine)
|
|
143
|
+
if state.help
|
|
144
|
+
return border(@theme.paint(" ⌨ keyboard shortcuts ", :text, bold: true),
|
|
145
|
+
@theme.paint(" ? / Escape close help ", :muted))
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
title = state.name == "all" ? " ≡ all logs " : " › #{state.name} "
|
|
149
|
+
detail = if state.name == "all"
|
|
150
|
+
"#{engine.processes.size} processes"
|
|
151
|
+
else
|
|
152
|
+
entry = engine.state(state.name)
|
|
153
|
+
process_detail(entry)
|
|
154
|
+
end
|
|
155
|
+
active = if state.name == "all"
|
|
156
|
+
engine.processes.any? { |entry| entry.status == :running }
|
|
157
|
+
else
|
|
158
|
+
engine.state(state.name).status == :running
|
|
159
|
+
end
|
|
160
|
+
mode = if !state.viewport.following
|
|
161
|
+
@theme.paint(" Ⅱ PAUSED ", :amber)
|
|
162
|
+
elsif active
|
|
163
|
+
@theme.paint(" ● LIVE ", :green)
|
|
164
|
+
else
|
|
165
|
+
@theme.paint(" ↓ FOLLOW ", :muted)
|
|
166
|
+
end
|
|
167
|
+
title = @theme.paint(title, :text, bold: true)
|
|
168
|
+
if @inside >= 45
|
|
169
|
+
title += @theme.paint(" #{detail} ", :muted)
|
|
170
|
+
elsif state.name != "all" && @inside >= 28
|
|
171
|
+
entry = engine.state(state.name)
|
|
172
|
+
title += @theme.paint(" #{entry.status} ", Theme::STATUS_COLORS.fetch(entry.status, :muted))
|
|
173
|
+
end
|
|
174
|
+
border(title, mode)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def process_detail(entry)
|
|
178
|
+
detail = "#{entry.status} · PID #{entry.pid || '-'}"
|
|
179
|
+
if entry.exit_status
|
|
180
|
+
code = entry.exit_status.exitstatus || "signal #{entry.exit_status.termsig}"
|
|
181
|
+
detail += " · exit #{code}"
|
|
182
|
+
elsif entry.port && @inside >= 75
|
|
183
|
+
detail += " · PORT #{entry.port}"
|
|
184
|
+
end
|
|
185
|
+
detail
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def log_rows(state, engine)
|
|
189
|
+
@formatter ||= LogFormatter.new(@theme, engine.process_names)
|
|
190
|
+
buffer = engine.logs[state.name]
|
|
191
|
+
records = state.viewport.rows(buffer, @height)
|
|
192
|
+
@visible = records
|
|
193
|
+
first = records.empty? ? 0 : buffer.index_of(records.first.sequence).to_i
|
|
194
|
+
thumb_size = buffer.none? ? @height : [(@height * @height / buffer.size), 1].max.clamp(1, @height)
|
|
195
|
+
travel = @height - thumb_size
|
|
196
|
+
thumb_start = buffer.size <= @height ? 0 : (first * travel / (buffer.size - @height))
|
|
197
|
+
Array.new(@height) do |index|
|
|
198
|
+
content = if records[index]
|
|
199
|
+
@formatter.row(records[index], aggregate: state.name == "all", width: @inside - 2)
|
|
200
|
+
elsif records.empty? && index == @height / 2
|
|
201
|
+
@theme.paint("Waiting for output…", :faint)
|
|
202
|
+
else
|
|
203
|
+
""
|
|
204
|
+
end
|
|
205
|
+
thumb = buffer.size > @height && index.between?(thumb_start, thumb_start + thumb_size - 1)
|
|
206
|
+
panel(content, scroll: if thumb
|
|
207
|
+
state.viewport.following ? :green : :amber
|
|
208
|
+
end)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def log_footer(state, engine)
|
|
213
|
+
return border("", "", bottom: true) if state.help
|
|
214
|
+
|
|
215
|
+
buffer = engine.logs[state.name]
|
|
216
|
+
first = @visible&.first
|
|
217
|
+
last = @visible&.last
|
|
218
|
+
range = first ? "#{buffer.index_of(first.sequence) + 1}–#{buffer.index_of(last.sequence) + 1}" : "0"
|
|
219
|
+
count = " #{range} / #{buffer.size} lines "
|
|
220
|
+
location = if state.viewport.following
|
|
221
|
+
" following "
|
|
222
|
+
else
|
|
223
|
+
" f resume "
|
|
224
|
+
end
|
|
225
|
+
location = " PID #{engine.state(state.name).pid || '-'} " if state.name != "all" && @inside < 45
|
|
226
|
+
border(@theme.paint(count, :faint), @theme.paint(location, state.viewport.following ? :faint : :amber),
|
|
227
|
+
bottom: true)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def help_rows
|
|
231
|
+
rows = []
|
|
232
|
+
HELP.each do |section, key, description, alias_keys|
|
|
233
|
+
if section && @height >= 15
|
|
234
|
+
rows << panel("") if !rows.empty? && @height >= 18
|
|
235
|
+
rows << panel(@theme.paint(section, :accent, bold: true))
|
|
236
|
+
end
|
|
237
|
+
line = @theme.paint(Text.pad(key, 18), :amber, bold: true) + @theme.paint(description)
|
|
238
|
+
line += @theme.paint(" #{alias_keys}", :faint) if alias_keys && @inside >= 75
|
|
239
|
+
rows << panel(line)
|
|
240
|
+
end
|
|
241
|
+
rows = rows.first(@height)
|
|
242
|
+
rows << panel("") while rows.size < @height
|
|
243
|
+
rows
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def controls(state, engine)
|
|
247
|
+
quit = @theme.paint(" q ", :accent, bold: true) + @theme.paint(" × quit ", :muted)
|
|
248
|
+
left = if engine.shutting_down?
|
|
249
|
+
@theme.paint(" ◌ Stopping process groups · TERM → KILL after 5s", :amber)
|
|
250
|
+
elsif state.feedback
|
|
251
|
+
@theme.paint(" #{state.feedback}", :amber)
|
|
252
|
+
elsif state.help
|
|
253
|
+
@theme.paint(" ? / Escape close help", :muted)
|
|
254
|
+
else
|
|
255
|
+
shortcuts(state)
|
|
256
|
+
end
|
|
257
|
+
distribute(left, quit, @width)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def shortcuts(state)
|
|
261
|
+
restart = ["r", state.name == "all" ? "↻ restart all" : "↻ restart"]
|
|
262
|
+
pairs = [%w[Tab switch], ["↑↓", "scroll"], %w[f follow], restart, ["?", "help"]]
|
|
263
|
+
pairs = [restart, ["?", "help"]] if @width < 65
|
|
264
|
+
pairs.map do |key, label|
|
|
265
|
+
@theme.paint(" #{key} ", :accent, bold: true) + @theme.paint(" #{label} ", :muted)
|
|
266
|
+
end.join
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def border(left, right = "", bottom: false)
|
|
270
|
+
corners = bottom ? %w[╰ ╯] : %w[╭ ╮]
|
|
271
|
+
middle = distribute(left, right, @inside, fill: "─")
|
|
272
|
+
" #{@theme.paint(corners.first, :border)}#{@theme.paint(middle, :border)}#{@theme.paint(corners.last, :border)} "
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def panel(content, scroll: nil)
|
|
276
|
+
inside = Text.pad(Text.clip(content, @inside - 2), @inside - 2)
|
|
277
|
+
edge = @theme.paint(scroll ? "┃" : "│", scroll || :border)
|
|
278
|
+
" #{@theme.paint('│', :border)} #{inside}#{@theme.reset} #{edge} "
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def distribute(left, right, width, fill: " ")
|
|
282
|
+
right = Text.clip(right, [width / 2, Text.width(right)].min)
|
|
283
|
+
available = [width - Text.width(right), 0].max
|
|
284
|
+
left = Text.clip(left, available, ellipsis: true)
|
|
285
|
+
gap = [width - Text.width(left) - Text.width(right), 0].max
|
|
286
|
+
left + @theme.reset + @theme.paint(fill * gap, :border) + right + @theme.reset
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def tiny_frame(state, engine, rows)
|
|
290
|
+
title = @theme.paint(" FORUIMAN", :accent, bold: true)
|
|
291
|
+
message = engine.shutting_down? ? " Stopping…" : " #{state.name} · enlarge terminal"
|
|
292
|
+
lines = [title, @theme.paint(message, :muted)]
|
|
293
|
+
lines[rows - 1] = @theme.paint(" q quit", :accent) if rows > 2
|
|
294
|
+
frame(lines, rows)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def frame(lines, rows)
|
|
298
|
+
rendered = Array.new(rows) do |index|
|
|
299
|
+
# Erase before drawing, then restore the theme defaults. Reserve the last
|
|
300
|
+
# cell to avoid terminal autowrap.
|
|
301
|
+
"\e[2K#{@theme.base}#{self.class.truncate(lines[index].to_s, @width)}"
|
|
302
|
+
end.join("\r\n")
|
|
303
|
+
"\e[H#{rendered}"
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "viewport"
|
|
4
|
+
|
|
5
|
+
module Foruiman::TUI
|
|
6
|
+
class State
|
|
7
|
+
attr_reader :tabs, :selected, :viewports
|
|
8
|
+
attr_accessor :help, :feedback
|
|
9
|
+
|
|
10
|
+
def initialize(names)
|
|
11
|
+
@tabs = [*names, "all"].freeze
|
|
12
|
+
@selected = tabs.size - 1
|
|
13
|
+
@viewports = tabs.to_h { |name| [name, Viewport.new] }
|
|
14
|
+
@help = false
|
|
15
|
+
@feedback = nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def name
|
|
19
|
+
tabs[selected]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def viewport
|
|
23
|
+
viewports.fetch(name)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def select(index)
|
|
27
|
+
@selected = index if index.between?(0, tabs.size - 1)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def move(delta)
|
|
31
|
+
@selected = (selected + delta) % tabs.size
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
|
|
5
|
+
module Foruiman::TUI
|
|
6
|
+
class Terminal
|
|
7
|
+
ENTER = "\e[?1049h\e[?25l\e[0m"
|
|
8
|
+
LEAVE = "\e[0m\e[?25h\e[?1049l"
|
|
9
|
+
|
|
10
|
+
def initialize(input: $stdin, output: $stdout)
|
|
11
|
+
@input = input
|
|
12
|
+
@output = output
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def session(&)
|
|
16
|
+
@output.write(ENTER)
|
|
17
|
+
@output.flush
|
|
18
|
+
@input.raw(intr: false, &)
|
|
19
|
+
ensure
|
|
20
|
+
begin
|
|
21
|
+
@output.write(LEAVE)
|
|
22
|
+
@output.flush
|
|
23
|
+
rescue IOError, SystemCallError
|
|
24
|
+
# Input's raw block has already restored its exact prior terminal mode.
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def size
|
|
29
|
+
rows, columns = @output.winsize
|
|
30
|
+
[[rows, 1].max, [columns, 1].max]
|
|
31
|
+
rescue IOError, SystemCallError
|
|
32
|
+
[24, 80]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def read
|
|
36
|
+
@input.read_nonblock(4096, exception: false)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def draw(frame)
|
|
40
|
+
@output.write(frame)
|
|
41
|
+
@output.flush
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|