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.
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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llv
4
+ VERSION = "0.1.0"
5
+ end