llv 0.1.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 +7 -0
- data/README.md +130 -0
- data/bin/llv +6 -0
- data/lib/llv/ansi.rb +154 -0
- data/lib/llv/cli.rb +121 -0
- data/lib/llv/group_store.rb +166 -0
- data/lib/llv/parser.rb +211 -0
- data/lib/llv/public/app.js +198 -0
- data/lib/llv/public/index.html +40 -0
- data/lib/llv/public/styles.css +239 -0
- data/lib/llv/tailer.rb +105 -0
- data/lib/llv/tui.rb +521 -0
- data/lib/llv/version.rb +5 -0
- data/lib/llv/web.rb +104 -0
- data/lib/llv.rb +8 -0
- metadata +153 -0
data/lib/llv/tailer.rb
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "listen"
|
|
4
|
+
|
|
5
|
+
module Llv
|
|
6
|
+
# Follows a log file from EOF (or from start). Emits each new line to the
|
|
7
|
+
# supplied block on a background thread. Handles truncation/rotation by
|
|
8
|
+
# re-opening the file when its size shrinks or its inode changes.
|
|
9
|
+
class Tailer
|
|
10
|
+
def initialize(path, from_start: false)
|
|
11
|
+
@path = File.expand_path(path)
|
|
12
|
+
@from_start = from_start
|
|
13
|
+
@buffer = +""
|
|
14
|
+
@position = 0
|
|
15
|
+
@inode = nil
|
|
16
|
+
@stop = false
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def each_line(&block)
|
|
20
|
+
@on_line = block
|
|
21
|
+
open_initial
|
|
22
|
+
emit_existing if @from_start
|
|
23
|
+
@position = File.size(@path)
|
|
24
|
+
@inode = File.stat(@path).ino
|
|
25
|
+
|
|
26
|
+
@listener = Listen.to(File.dirname(@path), only: Regexp.new("\\A#{Regexp.escape(File.basename(@path))}\\z")) do
|
|
27
|
+
check_for_changes
|
|
28
|
+
end
|
|
29
|
+
@listener.start
|
|
30
|
+
|
|
31
|
+
# Also poll occasionally as a fallback (some editors swap files in ways listen misses).
|
|
32
|
+
@poll = Thread.new do
|
|
33
|
+
until @stop
|
|
34
|
+
sleep 0.5
|
|
35
|
+
check_for_changes
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
self
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def stop
|
|
43
|
+
@stop = true
|
|
44
|
+
@listener&.stop
|
|
45
|
+
@poll&.join(1)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def open_initial
|
|
51
|
+
raise ArgumentError, "log file not found: #{@path}" unless File.exist?(@path)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def emit_existing
|
|
55
|
+
File.open(@path, "rb") do |io|
|
|
56
|
+
io.each_line { |line| dispatch(line) }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def check_for_changes
|
|
61
|
+
return unless File.exist?(@path)
|
|
62
|
+
|
|
63
|
+
stat = File.stat(@path)
|
|
64
|
+
if @inode && stat.ino != @inode
|
|
65
|
+
@position = 0
|
|
66
|
+
@inode = stat.ino
|
|
67
|
+
@buffer = +""
|
|
68
|
+
elsif stat.size < @position
|
|
69
|
+
# truncation
|
|
70
|
+
@position = 0
|
|
71
|
+
@buffer = +""
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
return if stat.size == @position
|
|
75
|
+
|
|
76
|
+
File.open(@path, "rb") do |io|
|
|
77
|
+
io.seek(@position)
|
|
78
|
+
chunk = io.read(stat.size - @position)
|
|
79
|
+
@position = io.pos
|
|
80
|
+
next unless chunk
|
|
81
|
+
|
|
82
|
+
@buffer << chunk
|
|
83
|
+
while (idx = @buffer.index("\n"))
|
|
84
|
+
line = @buffer.slice!(0..idx)
|
|
85
|
+
dispatch(line)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def dispatch(line)
|
|
91
|
+
return if line.nil? || line.empty?
|
|
92
|
+
|
|
93
|
+
# File reads use binary mode so chunk boundaries don't split multibyte
|
|
94
|
+
# codepoints, but downstream code (regexes for `↳`, ANSI translation,
|
|
95
|
+
# JSON.generate) all need a valid UTF-8 string. Force the encoding and
|
|
96
|
+
# scrub anything invalid.
|
|
97
|
+
utf8 = line.dup.force_encoding(Encoding::UTF_8)
|
|
98
|
+
utf8.scrub!("?") unless utf8.valid_encoding?
|
|
99
|
+
|
|
100
|
+
@on_line.call(utf8)
|
|
101
|
+
rescue StandardError => e
|
|
102
|
+
warn "llv tailer: #{e.class}: #{e.message}"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
data/lib/llv/tui.rb
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bubbletea"
|
|
4
|
+
require "lipgloss"
|
|
5
|
+
require "set"
|
|
6
|
+
|
|
7
|
+
module Llv
|
|
8
|
+
module Tui
|
|
9
|
+
class GroupChangeMessage < Bubbletea::Message
|
|
10
|
+
attr_reader :event_type, :summary
|
|
11
|
+
|
|
12
|
+
def initialize(event_type:, summary:)
|
|
13
|
+
super()
|
|
14
|
+
@event_type = event_type
|
|
15
|
+
@summary = summary
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class GroupBodyMessage < Bubbletea::Message
|
|
20
|
+
attr_reader :group
|
|
21
|
+
|
|
22
|
+
def initialize(group:)
|
|
23
|
+
super()
|
|
24
|
+
@group = group
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Bubbletea model: master/detail over Llv::GroupStore. Subscribes from a
|
|
29
|
+
# background thread that turns store events into messages on the runner.
|
|
30
|
+
class App
|
|
31
|
+
include Bubbletea::Model
|
|
32
|
+
|
|
33
|
+
KIND_GLYPHS = { http: "▶", job: "⚙", cable: "≋", untagged: "·" }.freeze
|
|
34
|
+
TOGGLEABLE_KINDS = %i[http job cable].freeze
|
|
35
|
+
|
|
36
|
+
# No padding here — each row already builds itself to the full
|
|
37
|
+
# list_width. Padding would shrink the content area and force lipgloss
|
|
38
|
+
# to wrap every row to two visual lines, which destroys the highlight.
|
|
39
|
+
ListStyle = Lipgloss::Style.new
|
|
40
|
+
DetailStyle = Lipgloss::Style.new.padding(0, 1)
|
|
41
|
+
HeaderStyle = Lipgloss::Style.new.bold(true).foreground("#6cbcff")
|
|
42
|
+
SubheaderStyle = Lipgloss::Style.new.foreground("#7b8190")
|
|
43
|
+
RowActiveStyle = Lipgloss::Style.new.foreground("#ffffff").background("#1d4ed8").bold(true)
|
|
44
|
+
RowActiveMarker = Lipgloss::Style.new.foreground("#6cbcff").bold(true)
|
|
45
|
+
RowStyle = Lipgloss::Style.new
|
|
46
|
+
RowInactiveMarker = Lipgloss::Style.new.foreground("#262b3a")
|
|
47
|
+
MutedStyle = Lipgloss::Style.new.foreground("#7b8190")
|
|
48
|
+
HelpStyle = Lipgloss::Style.new.foreground("#7b8190").italic(true)
|
|
49
|
+
DividerStyle = Lipgloss::Style.new.foreground("#262b3a")
|
|
50
|
+
|
|
51
|
+
VerbStyles = {
|
|
52
|
+
"GET" => Lipgloss::Style.new.foreground("#6fcf97"),
|
|
53
|
+
"POST" => Lipgloss::Style.new.foreground("#6cbcff"),
|
|
54
|
+
"PUT" => Lipgloss::Style.new.foreground("#f2c14e"),
|
|
55
|
+
"PATCH" => Lipgloss::Style.new.foreground("#f2c14e"),
|
|
56
|
+
"DELETE" => Lipgloss::Style.new.foreground("#ef6c6c"),
|
|
57
|
+
"HEAD" => Lipgloss::Style.new.foreground("#7b8190"),
|
|
58
|
+
"OPTIONS" => Lipgloss::Style.new.foreground("#7b8190")
|
|
59
|
+
}.freeze
|
|
60
|
+
|
|
61
|
+
StatusOkStyle = Lipgloss::Style.new.foreground("#6fcf97").bold(true)
|
|
62
|
+
StatusRedirStyle = Lipgloss::Style.new.foreground("#56b6c2")
|
|
63
|
+
StatusWarnStyle = Lipgloss::Style.new.foreground("#f2c14e")
|
|
64
|
+
StatusErrStyle = Lipgloss::Style.new.foreground("#ef6c6c").bold(true)
|
|
65
|
+
|
|
66
|
+
def initialize(store:)
|
|
67
|
+
@store = store
|
|
68
|
+
@order = []
|
|
69
|
+
@summaries = {}
|
|
70
|
+
@details = {}
|
|
71
|
+
@selected = nil
|
|
72
|
+
@width = 100
|
|
73
|
+
@height = 30
|
|
74
|
+
@show_sql_source = true
|
|
75
|
+
@focus = :left
|
|
76
|
+
@detail_scroll = 0
|
|
77
|
+
@enabled_kinds = TOGGLEABLE_KINDS.to_set
|
|
78
|
+
seed_from_store
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def width=(w); @width = w; end
|
|
82
|
+
def height=(h); @height = h; end
|
|
83
|
+
|
|
84
|
+
def init
|
|
85
|
+
[self, nil]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def update(message)
|
|
89
|
+
case message
|
|
90
|
+
when Bubbletea::WindowSizeMessage
|
|
91
|
+
@width = message.width
|
|
92
|
+
@height = message.height
|
|
93
|
+
[self, nil]
|
|
94
|
+
when Bubbletea::KeyMessage
|
|
95
|
+
handle_key(message)
|
|
96
|
+
when GroupChangeMessage
|
|
97
|
+
apply_change(message)
|
|
98
|
+
[self, nil]
|
|
99
|
+
when GroupBodyMessage
|
|
100
|
+
@details[message.group[:id]] = message.group
|
|
101
|
+
[self, nil]
|
|
102
|
+
else
|
|
103
|
+
[self, nil]
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def view
|
|
108
|
+
list = render_list
|
|
109
|
+
detail = render_detail
|
|
110
|
+
body = Lipgloss.join_horizontal(Lipgloss::TOP, list, divider, detail)
|
|
111
|
+
[header, body, footer].join("\n")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def seed_from_store
|
|
117
|
+
@store.list(limit: 500).each do |summary|
|
|
118
|
+
@summaries[summary[:id]] = summary
|
|
119
|
+
@order << summary[:id]
|
|
120
|
+
end
|
|
121
|
+
@selected = @order.first
|
|
122
|
+
request_body(@selected) if @selected
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def handle_key(msg)
|
|
126
|
+
name = msg.to_s
|
|
127
|
+
|
|
128
|
+
# Global keys (work regardless of focused pane).
|
|
129
|
+
case name
|
|
130
|
+
when "q", "ctrl+c"
|
|
131
|
+
return [self, Bubbletea.quit]
|
|
132
|
+
when "tab"
|
|
133
|
+
@focus = @focus == :left ? :right : :left
|
|
134
|
+
return [self, nil]
|
|
135
|
+
when "s"
|
|
136
|
+
@show_sql_source = !@show_sql_source
|
|
137
|
+
@detail_scroll = clamp_scroll(@detail_scroll)
|
|
138
|
+
return [self, nil]
|
|
139
|
+
when "H"
|
|
140
|
+
toggle_kind(:http)
|
|
141
|
+
return [self, nil]
|
|
142
|
+
when "J"
|
|
143
|
+
toggle_kind(:job)
|
|
144
|
+
return [self, nil]
|
|
145
|
+
when "C"
|
|
146
|
+
toggle_kind(:cable)
|
|
147
|
+
return [self, nil]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
if @focus == :left
|
|
151
|
+
handle_left_key(name)
|
|
152
|
+
else
|
|
153
|
+
handle_right_key(name)
|
|
154
|
+
end
|
|
155
|
+
[self, nil]
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def handle_left_key(name)
|
|
159
|
+
case name
|
|
160
|
+
when "up", "k" then move_selection(-1)
|
|
161
|
+
when "down", "j" then move_selection(1)
|
|
162
|
+
when "pgup" then move_selection(-list_height_visible_rows)
|
|
163
|
+
when "pgdown", "pgdn" then move_selection(list_height_visible_rows)
|
|
164
|
+
when "g", "home" then move_selection_to(0)
|
|
165
|
+
when "G", "end" then move_selection_to(visible_order.length - 1)
|
|
166
|
+
when "right", "l", "enter"
|
|
167
|
+
@focus = :right
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def handle_right_key(name)
|
|
172
|
+
case name
|
|
173
|
+
when "up", "k" then scroll_detail(-1)
|
|
174
|
+
when "down", "j" then scroll_detail(1)
|
|
175
|
+
when "pgup" then scroll_detail(-(detail_body_rows - 1))
|
|
176
|
+
when "pgdown", "pgdn", "space" then scroll_detail(detail_body_rows - 1)
|
|
177
|
+
when "ctrl+u" then scroll_detail(-(detail_body_rows / 2))
|
|
178
|
+
when "ctrl+d" then scroll_detail(detail_body_rows / 2)
|
|
179
|
+
when "g", "home" then @detail_scroll = 0
|
|
180
|
+
when "G", "end" then @detail_scroll = max_detail_scroll
|
|
181
|
+
when "left", "h", "esc"
|
|
182
|
+
@focus = :left
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def move_selection(delta)
|
|
187
|
+
order = visible_order
|
|
188
|
+
return if order.empty?
|
|
189
|
+
|
|
190
|
+
idx = order.index(@selected) || 0
|
|
191
|
+
move_selection_to((idx + delta).clamp(0, order.length - 1))
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def move_selection_to(idx)
|
|
195
|
+
order = visible_order
|
|
196
|
+
return if order.empty?
|
|
197
|
+
|
|
198
|
+
new_selection = order[idx.clamp(0, order.length - 1)]
|
|
199
|
+
return if new_selection == @selected
|
|
200
|
+
|
|
201
|
+
@selected = new_selection
|
|
202
|
+
@detail_scroll = 0
|
|
203
|
+
request_body(@selected)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def scroll_detail(delta)
|
|
207
|
+
@detail_scroll = clamp_scroll(@detail_scroll + delta)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def clamp_scroll(value)
|
|
211
|
+
value.clamp(0, max_detail_scroll)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def max_detail_scroll
|
|
215
|
+
return 0 unless @selected
|
|
216
|
+
|
|
217
|
+
group = @details[@selected]
|
|
218
|
+
return 0 unless group
|
|
219
|
+
|
|
220
|
+
lines = visible_detail_lines(group)
|
|
221
|
+
[lines.length - detail_body_rows, 0].max
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def visible_detail_lines(group)
|
|
225
|
+
(group[:lines] || []).reject { |l| l.type == :sql_source && !@show_sql_source }
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def visible_order
|
|
229
|
+
@order.select do |id|
|
|
230
|
+
summary = @summaries[id]
|
|
231
|
+
next true unless summary
|
|
232
|
+
kind = summary[:kind]
|
|
233
|
+
next true if kind == :untagged
|
|
234
|
+
@enabled_kinds.include?(kind)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def toggle_kind(kind)
|
|
239
|
+
if @enabled_kinds.include?(kind)
|
|
240
|
+
@enabled_kinds.delete(kind)
|
|
241
|
+
else
|
|
242
|
+
@enabled_kinds.add(kind)
|
|
243
|
+
end
|
|
244
|
+
# If the current selection is now filtered out, move to the nearest
|
|
245
|
+
# visible item (or clear it).
|
|
246
|
+
order = visible_order
|
|
247
|
+
unless order.include?(@selected)
|
|
248
|
+
@selected = order.first
|
|
249
|
+
@detail_scroll = 0
|
|
250
|
+
request_body(@selected) if @selected
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def apply_change(message)
|
|
255
|
+
summary = message.summary
|
|
256
|
+
case message.event_type
|
|
257
|
+
when :group_created
|
|
258
|
+
unless @summaries.key?(summary[:id])
|
|
259
|
+
@order.unshift(summary[:id])
|
|
260
|
+
end
|
|
261
|
+
@summaries[summary[:id]] = summary
|
|
262
|
+
@selected ||= summary[:id]
|
|
263
|
+
when :group_updated, :group_completed
|
|
264
|
+
@summaries[summary[:id]] = summary
|
|
265
|
+
# refresh body if user is currently viewing it
|
|
266
|
+
request_body(summary[:id]) if @selected == summary[:id]
|
|
267
|
+
when :group_evicted
|
|
268
|
+
@summaries.delete(summary[:id])
|
|
269
|
+
@details.delete(summary[:id])
|
|
270
|
+
@order.delete(summary[:id])
|
|
271
|
+
if @selected == summary[:id]
|
|
272
|
+
@selected = @order.first
|
|
273
|
+
request_body(@selected) if @selected
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def request_body(id)
|
|
279
|
+
return unless id
|
|
280
|
+
|
|
281
|
+
full = @store.fetch(id)
|
|
282
|
+
@details[id] = full if full
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def header
|
|
286
|
+
title = HeaderStyle.render("llv")
|
|
287
|
+
focus_hint = "[#{@focus == :left ? "LIST" : "DETAIL"}]"
|
|
288
|
+
keys = @focus == :left ? "↑/↓ select · → detail" : "↑/↓ scroll · ← back · PgUp/PgDn"
|
|
289
|
+
kinds = TOGGLEABLE_KINDS.map { |k| @enabled_kinds.include?(k) ? k.to_s : "·#{k}" }.join("/")
|
|
290
|
+
total = visible_order.length
|
|
291
|
+
status = SubheaderStyle.render(
|
|
292
|
+
"#{total}/#{@order.length} · #{focus_hint} · #{keys} · tab swap · H/J/C toggle #{kinds} · s ↳ · q quit"
|
|
293
|
+
)
|
|
294
|
+
line = "#{title} #{status}"
|
|
295
|
+
Lipgloss.place_horizontal(@width, Lipgloss::LEFT, line)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def footer
|
|
299
|
+
HelpStyle.render(@selected ? @selected.to_s : "")
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def divider
|
|
303
|
+
rows = body_height
|
|
304
|
+
glyph = @focus == :right ? "┃" : "│"
|
|
305
|
+
style = @focus == :right ? Lipgloss::Style.new.foreground("#6cbcff") : DividerStyle
|
|
306
|
+
Array.new(rows) { style.render(glyph) }.join("\n")
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def body_height
|
|
310
|
+
[@height - 3, 5].max
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def list_width
|
|
314
|
+
[(@width * 0.4).floor, 32].max.then { |w| [w, @width - 20].min }
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def detail_width
|
|
318
|
+
@width - list_width - 1
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def render_list
|
|
322
|
+
rows = list_height_visible_rows
|
|
323
|
+
ids = visible_window(rows)
|
|
324
|
+
lines = ids.map { |id| format_row(id) }
|
|
325
|
+
# Pad to fill height for stable layout. Use raw spaces — no styling
|
|
326
|
+
# so empty space below the last item doesn't get a highlight.
|
|
327
|
+
while lines.length < rows
|
|
328
|
+
lines << (" " * list_width)
|
|
329
|
+
end
|
|
330
|
+
lines.join("\n")
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def list_height_visible_rows
|
|
334
|
+
[body_height - 1, 3].max
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def visible_window(rows)
|
|
338
|
+
order = visible_order
|
|
339
|
+
return [] if order.empty?
|
|
340
|
+
|
|
341
|
+
idx = order.index(@selected) || 0
|
|
342
|
+
start = [idx - rows / 2, 0].max
|
|
343
|
+
finish = [start + rows, order.length].min
|
|
344
|
+
start = [finish - rows, 0].max
|
|
345
|
+
order[start...finish]
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def format_row(id)
|
|
349
|
+
summary = @summaries[id]
|
|
350
|
+
return " " * list_width unless summary
|
|
351
|
+
|
|
352
|
+
active = (id == @selected)
|
|
353
|
+
glyph = KIND_GLYPHS[summary[:kind]] || "?"
|
|
354
|
+
status_text = format_status(summary)
|
|
355
|
+
duration_text = format_duration(summary[:duration_ms])
|
|
356
|
+
title_raw = (summary[:title] || id).to_s
|
|
357
|
+
|
|
358
|
+
meta_parts = [status_text, duration_text].reject(&:empty?)
|
|
359
|
+
meta_plain = meta_parts.empty? ? "" : " #{meta_parts.join(" ")}"
|
|
360
|
+
|
|
361
|
+
# The first column is a 1-char focus marker (full block on the active
|
|
362
|
+
# row, nothing on the rest). The remaining list_width - 1 chars are the
|
|
363
|
+
# row body, padded out to fill so the background highlight covers the
|
|
364
|
+
# entire line.
|
|
365
|
+
marker_glyph = active ? "▌" : " "
|
|
366
|
+
marker = active ? RowActiveMarker.render(marker_glyph) : RowInactiveMarker.render(marker_glyph)
|
|
367
|
+
|
|
368
|
+
body_width = list_width - 1
|
|
369
|
+
# The row body shape is " <glyph> <title><meta>": leading space, glyph,
|
|
370
|
+
# space, title (variable), meta segment. 3 fixed chars plus title plus
|
|
371
|
+
# meta's visible length must equal body_width.
|
|
372
|
+
title_room = body_width - 3 - meta_plain.length
|
|
373
|
+
title_room = [title_room, 4].max
|
|
374
|
+
|
|
375
|
+
title_text = title_raw.length > title_room ? "#{title_raw[0, title_room - 1]}…" : title_raw
|
|
376
|
+
title_padded = title_text.ljust(title_room)
|
|
377
|
+
|
|
378
|
+
if active
|
|
379
|
+
# Active row: single bg-highlight covers the whole line. Inline
|
|
380
|
+
# foreground colours would fight the highlight, so we keep it flat.
|
|
381
|
+
body = " #{glyph} #{title_padded}#{meta_plain}"
|
|
382
|
+
body = body[0, body_width].ljust(body_width)
|
|
383
|
+
"#{marker}#{RowActiveStyle.render(body)}"
|
|
384
|
+
else
|
|
385
|
+
title_styled = colourise_verb_in_title(title_padded)
|
|
386
|
+
meta_styled = colourise_meta(status_text, duration_text)
|
|
387
|
+
|
|
388
|
+
styled_body = " #{glyph} #{title_styled}#{meta_styled}"
|
|
389
|
+
visible_len = 3 + title_room + meta_plain.length
|
|
390
|
+
padding = " " * [body_width - visible_len, 0].max
|
|
391
|
+
"#{marker}#{styled_body}#{padding}"
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def colourise_verb_in_title(title_padded)
|
|
396
|
+
m = title_padded.match(/\A([A-Z]+)(\s.*)\z/m)
|
|
397
|
+
return title_padded unless m
|
|
398
|
+
|
|
399
|
+
style = VerbStyles[m[1]]
|
|
400
|
+
return title_padded unless style
|
|
401
|
+
|
|
402
|
+
"#{style.render(m[1])}#{m[2]}"
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def colourise_meta(status_text, duration_text)
|
|
406
|
+
return "" if status_text.empty? && duration_text.empty?
|
|
407
|
+
|
|
408
|
+
parts = []
|
|
409
|
+
parts << status_style_for(status_text).render(status_text) unless status_text.empty?
|
|
410
|
+
parts << MutedStyle.render(duration_text) unless duration_text.empty?
|
|
411
|
+
" #{parts.join(" ")}"
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def status_style_for(text)
|
|
415
|
+
case text
|
|
416
|
+
when /\A2\d\d\z/ then StatusOkStyle
|
|
417
|
+
when /\A3\d\d\z/ then StatusRedirStyle
|
|
418
|
+
when /\A4\d\d\z/ then StatusWarnStyle
|
|
419
|
+
when /\A5\d\d\z/ then StatusErrStyle
|
|
420
|
+
when "ok" then StatusOkStyle
|
|
421
|
+
when "stopped" then StatusErrStyle
|
|
422
|
+
else MutedStyle
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def format_status(summary)
|
|
427
|
+
s = summary[:status]
|
|
428
|
+
return "…" if s.nil?
|
|
429
|
+
s.to_s
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def format_duration(ms)
|
|
433
|
+
return "" if ms.nil?
|
|
434
|
+
return "#{ms.round(1)}ms" if ms < 1000
|
|
435
|
+
"#{(ms / 1000.0).round(2)}s"
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def render_detail
|
|
439
|
+
group = @selected ? @details[@selected] : nil
|
|
440
|
+
return DetailStyle.width(detail_width).render(MutedStyle.render("(no selection)")) unless group
|
|
441
|
+
|
|
442
|
+
lines = visible_detail_lines(group)
|
|
443
|
+
rows = detail_body_rows
|
|
444
|
+
@detail_scroll = clamp_scroll(@detail_scroll)
|
|
445
|
+
|
|
446
|
+
first = @detail_scroll
|
|
447
|
+
last = [@detail_scroll + rows, lines.length].min
|
|
448
|
+
scroll_info =
|
|
449
|
+
if lines.length > rows
|
|
450
|
+
visible_from = first + 1
|
|
451
|
+
visible_to = last
|
|
452
|
+
pct = max_detail_scroll.zero? ? 100 : ((first.to_f / max_detail_scroll) * 100).round
|
|
453
|
+
" #{visible_from}-#{visible_to} of #{lines.length} (#{pct}%)"
|
|
454
|
+
else
|
|
455
|
+
""
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
content_width = [detail_width - 2, 10].max
|
|
459
|
+
head_text = (group[:title] || group[:id]).to_s
|
|
460
|
+
head = HeaderStyle.render(Ansi.truncate(head_text, content_width))
|
|
461
|
+
|
|
462
|
+
sub_text = [
|
|
463
|
+
group[:kind],
|
|
464
|
+
"#{group[:line_count]} lines",
|
|
465
|
+
format_duration(group[:duration_ms]),
|
|
466
|
+
group[:status] || "in flight",
|
|
467
|
+
group[:id]
|
|
468
|
+
].compact.join(" · ") + scroll_info
|
|
469
|
+
sub = SubheaderStyle.render(Ansi.truncate(sub_text, content_width))
|
|
470
|
+
|
|
471
|
+
visible = lines[first, rows] || []
|
|
472
|
+
# Detail content width: minus 2 for DetailStyle's left/right padding.
|
|
473
|
+
# We truncate each log line ANSI-aware so the rendered detail block
|
|
474
|
+
# has exactly one visual line per log entry; otherwise long SQL
|
|
475
|
+
# queries wrap and push everything else (including the list pane on
|
|
476
|
+
# the other side of join_horizontal) off the screen.
|
|
477
|
+
badge_width = 20
|
|
478
|
+
line_strs = visible.map do |line|
|
|
479
|
+
badge = MutedStyle.render("[#{line.type}]".ljust(badge_width))
|
|
480
|
+
body_room = content_width - badge_width - 1
|
|
481
|
+
body = body_room.positive? ? Ansi.truncate(line.raw, body_room) : ""
|
|
482
|
+
"#{badge} #{body}"
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# Pad to a stable height so the layout doesn't jiggle as we scroll.
|
|
486
|
+
while line_strs.length < rows
|
|
487
|
+
line_strs << ""
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
out = [head, sub, "", *line_strs].join("\n")
|
|
491
|
+
DetailStyle.width(detail_width).render(out)
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def detail_body_rows
|
|
495
|
+
# body_height minus head, sub, and the blank divider line.
|
|
496
|
+
[body_height - 3, 3].max
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
# Wraps Bubbletea::Runner + store subscription.
|
|
501
|
+
class Program
|
|
502
|
+
def initialize(store:)
|
|
503
|
+
@store = store
|
|
504
|
+
@model = App.new(store: store)
|
|
505
|
+
@runner = Bubbletea::Runner.new(@model, alt_screen: true)
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def run
|
|
509
|
+
subscription = @store.subscribe do |type, group|
|
|
510
|
+
msg = GroupChangeMessage.new(event_type: type, summary: group.summary)
|
|
511
|
+
@runner.send(msg)
|
|
512
|
+
end
|
|
513
|
+
begin
|
|
514
|
+
@runner.run
|
|
515
|
+
ensure
|
|
516
|
+
@store.unsubscribe(subscription)
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
end
|