red_dot 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.
@@ -0,0 +1,1169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require 'bubbletea'
5
+ require 'lipgloss'
6
+
7
+ module RedDot
8
+ DisplayRow = Struct.new(:type, :path, :line_number, :full_description, keyword_init: true) do
9
+ def file_row?
10
+ type == :file
11
+ end
12
+
13
+ def example_row?
14
+ type == :example
15
+ end
16
+
17
+ def runnable_path
18
+ example_row? ? "#{path}:#{line_number}" : path
19
+ end
20
+
21
+ def row_id
22
+ example_row? ? "#{path}:#{line_number}" : path
23
+ end
24
+ end
25
+
26
+ class RspecStartedMessage < Bubbletea::Message
27
+ attr_reader :pid, :stdout_io, :json_path, :component_root
28
+
29
+ def initialize(pid:, stdout_io:, json_path:, component_root: nil)
30
+ @pid = pid
31
+ @stdout_io = stdout_io
32
+ @json_path = json_path
33
+ @component_root = component_root
34
+ end
35
+ end
36
+
37
+ class TickMessage < Bubbletea::Message; end
38
+
39
+ class App
40
+ include Bubbletea::Model
41
+
42
+ LEFT_PANEL_RATIO = 0.38
43
+ OPTIONS_BAR_HEIGHT = 5
44
+ STATUS_HEIGHT = 1
45
+
46
+ def initialize(working_dir: Dir.pwd, option_overrides: {})
47
+ @working_dir = File.expand_path(working_dir)
48
+ @discovery = SpecDiscovery.new(working_dir: @working_dir)
49
+ @spec_files = @discovery.discover
50
+ @grouped = @discovery.discover_grouped_by_dir
51
+ @cursor = 0
52
+ @selected = {}
53
+ @expanded_files = Set.new
54
+ @examples_by_file = {}
55
+ @screen = :file_list
56
+ @options = Config.load(working_dir: @working_dir)
57
+ apply_option_overrides(option_overrides)
58
+ @run_pid = nil
59
+ @run_stdout = nil
60
+ @run_json_path = nil
61
+ @run_output = []
62
+ @run_output_scroll = 0
63
+ @last_result = nil
64
+ @last_run_paths = nil
65
+ @last_run_component_root = nil
66
+ @run_failed_paths = nil
67
+ @run_queue = nil
68
+ @width = 80
69
+ @height = 24
70
+ @options_cursor = 0
71
+ @options_editing = nil
72
+ @options_edit_buffer = ''
73
+ @options_field_keys = %i[line_number seed format fail_fast tags_str out_path example_filter editor]
74
+ @results_cursor = 0
75
+ @file_list_scroll_offset = 0
76
+ @results_scroll_offset = 0
77
+ @results_failed_line_indices = []
78
+ @results_total_lines = 0
79
+ @input_prompt = nil
80
+ @find_buffer = nil
81
+ @options_focus = false
82
+ @index_files = []
83
+ @index_current = 0
84
+ @index_total = 0
85
+ @find_index_hint_shown = false
86
+ setup_styles
87
+ end
88
+
89
+ def init
90
+ [self, nil]
91
+ end
92
+
93
+ def apply_option_overrides(overrides)
94
+ return if overrides.nil? || overrides.empty?
95
+
96
+ o = overrides
97
+ if o.key?(:tags) && o[:tags].is_a?(Array)
98
+ @options[:tags] = o[:tags].map(&:to_s).reject(&:empty?)
99
+ @options[:tags_str] = @options[:tags].join(', ')
100
+ end
101
+ @options[:tags_str] = o[:tags_str].to_s if o.key?(:tags_str)
102
+ @options[:format] = o[:format].to_s.strip if o.key?(:format) && !o[:format].to_s.strip.empty?
103
+ @options[:out_path] = o[:out_path].to_s if o.key?(:out_path)
104
+ @options[:example_filter] = o[:example_filter].to_s if o.key?(:example_filter)
105
+ @options[:line_number] = o[:line_number].to_s if o.key?(:line_number)
106
+ @options[:fail_fast] = o[:fail_fast] ? true : false if o.key?(:fail_fast)
107
+ @options[:seed] = o[:seed].to_s if o.key?(:seed)
108
+ if o.key?(:editor) && RedDot::Config::VALID_EDITORS.include?(o[:editor].to_s.strip.downcase)
109
+ @options[:editor] = o[:editor].to_s.strip.downcase
110
+ end
111
+ end
112
+
113
+ def update(message)
114
+ case message
115
+ when Bubbletea::WindowSizeMessage
116
+ @width = message.width
117
+ @height = message.height
118
+ [self, nil]
119
+ when RspecStartedMessage
120
+ @run_pid = message.pid
121
+ @run_stdout = message.stdout_io
122
+ @run_json_path = message.json_path
123
+ @last_run_component_root = message.component_root if message.respond_to?(:component_root) && message.component_root
124
+ @run_output = []
125
+ @run_output_scroll = 0
126
+ @screen = :running
127
+ [self, schedule_tick]
128
+ when TickMessage
129
+ if @screen == :indexing && @index_current < @index_total
130
+ path = @index_files[@index_current]
131
+ load_examples_for(path)
132
+ @index_current += 1
133
+ if @index_current >= @index_total
134
+ @screen = :file_list
135
+ [self, nil]
136
+ else
137
+ [self, schedule_tick]
138
+ end
139
+ elsif @screen == :running && @run_pid
140
+ read_run_output
141
+ pid_done = Process.wait(@run_pid, Process::WNOHANG) rescue nil
142
+ if pid_done
143
+ @run_stdout&.close
144
+ @run_stdout = nil
145
+ @run_pid = nil
146
+ if @run_queue&.any?
147
+ next_group = @run_queue.shift
148
+ opts = { working_dir: next_group[:run_cwd], paths: next_group[:rspec_paths],
149
+ tags: @options[:tags].empty? ? parse_tags(@options[:tags_str]) : @options[:tags],
150
+ format: @options[:format], out_path: @options[:out_path].to_s.strip,
151
+ example_filter: @options[:example_filter].to_s.strip, fail_fast: @options[:fail_fast],
152
+ seed: @options[:seed].to_s.strip }
153
+ opts[:out_path] = nil if opts[:out_path].to_s.empty?
154
+ opts[:example_filter] = nil if opts[:example_filter].to_s.empty?
155
+ opts[:seed] = nil if opts[:seed].to_s.empty?
156
+ data = RspecRunner.spawn(**opts)
157
+ @run_pid = data[:pid]
158
+ @run_stdout = data[:stdout_io]
159
+ @run_json_path = data[:json_path]
160
+ @last_run_component_root = next_group[:component_root]
161
+ [self, schedule_tick]
162
+ else
163
+ @run_queue = nil
164
+ @last_result = RspecResult.from_json_path(@run_json_path)
165
+ raw_failures = @last_result&.failure_locations || []
166
+ @run_failed_paths = normalize_failure_paths(raw_failures)
167
+ @screen = :results
168
+ @results_cursor = 0
169
+ @results_scroll_offset = 0
170
+ [self, nil]
171
+ end
172
+ else
173
+ [self, schedule_tick]
174
+ end
175
+ else
176
+ [self, nil]
177
+ end
178
+ when Bubbletea::KeyMessage
179
+ handle_key(message)
180
+ else
181
+ [self, nil]
182
+ end
183
+ end
184
+
185
+ def view
186
+ content_h = [@height - STATUS_HEIGHT - OPTIONS_BAR_HEIGHT, 5].max
187
+ left_w = [(LEFT_PANEL_RATIO * @width).floor, 24].max
188
+ center_w = [@width - left_w - 1, 20].max
189
+
190
+ options_bar_lines = build_options_bar_lines
191
+ left_lines = build_file_list_lines(content_h)
192
+ center_lines = build_center_panel_lines(content_h)
193
+
194
+ options_bar = options_bar_lines.map { |line| pad_line(truncate_line(line, @width), @width) }.first(OPTIONS_BAR_HEIGHT)
195
+ options_bar += Array.new([OPTIONS_BAR_HEIGHT - options_bar.size, 0].max) { ' ' * @width }
196
+
197
+ left_block = block_to_size(left_lines, left_w, content_h)
198
+ center_block = block_to_size(center_lines, center_w, content_h)
199
+
200
+ sep_char = '│'
201
+ sep = [2, 3].include?(focused_panel) ? @active_title_style.render(sep_char) : @inactive_title_style.render(sep_char)
202
+ left_arr = left_block.split("\n")
203
+ center_arr = center_block.split("\n")
204
+ main_rows = content_h.times.map do |i|
205
+ l = left_arr[i] || ''.ljust(left_w)
206
+ c = center_arr[i] || ''.ljust(center_w)
207
+ "#{l}#{sep}#{c}"
208
+ end
209
+ status = @help_style.render(truncate_plain(status_line, @width).ljust(@width))
210
+ [options_bar.join("\n"), main_rows.join("\n"), status].join("\n")
211
+ end
212
+
213
+ private
214
+
215
+ def setup_styles
216
+ @active_title_style = Lipgloss::Style.new.bold(true).foreground('2')
217
+ @inactive_title_style = Lipgloss::Style.new.foreground('241')
218
+ @help_style = Lipgloss::Style.new.foreground('12')
219
+ @pass_style = Lipgloss::Style.new.foreground('2').bold(true)
220
+ @fail_style = Lipgloss::Style.new.foreground('9')
221
+ @warn_style = Lipgloss::Style.new.foreground('11').bold(true)
222
+ @muted_style = Lipgloss::Style.new.foreground('241')
223
+ @selected_style = Lipgloss::Style.new.foreground('255').background('4')
224
+ @cursor_style = Lipgloss::Style.new.foreground('255').background('4').bold(true)
225
+ end
226
+
227
+ def focused_panel
228
+ return 1 if @options_focus
229
+ return 3 if @screen == :results || @screen == :running || @screen == :indexing
230
+
231
+ 2
232
+ end
233
+
234
+ def schedule_tick
235
+ Bubbletea.tick(0.05) { TickMessage.new }
236
+ end
237
+
238
+ def handle_key(message)
239
+ return handle_input_prompt_key(message) if @input_prompt
240
+ return handle_find_key(message) if @find_buffer
241
+
242
+ key = message.to_s
243
+ unless @options_editing
244
+ case key
245
+ when '1'
246
+ @options_focus = true
247
+ @screen = :file_list
248
+ return [self, nil]
249
+ when '2'
250
+ @options_focus = false
251
+ @screen = :file_list
252
+ return [self, nil]
253
+ when '3'
254
+ @options_focus = false
255
+ if @last_result
256
+ @screen = :results
257
+ end
258
+ return [self, nil]
259
+ end
260
+ end
261
+ return handle_options_key(key, message) if @options_focus
262
+
263
+ case @screen
264
+ when :file_list
265
+ handle_file_list_key(key, message)
266
+ when :indexing
267
+ if ['q', 'ctrl+c', 'esc'].include?(key)
268
+ @screen = :file_list
269
+ @index_current = @index_total
270
+ end
271
+ [self, nil]
272
+ when :running
273
+ if ['q', 'ctrl+c'].include?(key)
274
+ kill_run
275
+ @screen = :file_list
276
+ end
277
+ [self, nil]
278
+ when :results
279
+ handle_results_key(key)
280
+ else
281
+ [self, nil]
282
+ end
283
+ end
284
+
285
+ def file_list_visible_height(content_h)
286
+ header_count = 2
287
+ header_count += 1 if @find_buffer
288
+ header_count += 1 if @find_buffer && index_empty? && !@find_index_hint_shown
289
+ [content_h - header_count, 1].max
290
+ end
291
+
292
+ def handle_file_list_key(key, _message = nil)
293
+ list = display_rows
294
+ row = list[@cursor]
295
+ content_h = [@height - STATUS_HEIGHT - OPTIONS_BAR_HEIGHT, 5].max
296
+ visible_list_height = file_list_visible_height(content_h)
297
+ max_scroll = [list.size - visible_list_height, 0].max
298
+ case key
299
+ when 'q', 'ctrl+c', 'esc'
300
+ kill_run if @run_pid
301
+ [self, Bubbletea.quit]
302
+ when '/'
303
+ @find_buffer = ''
304
+ @cursor = 0
305
+ @file_list_scroll_offset = 0
306
+ [self, nil]
307
+ when 'up', 'k'
308
+ max_idx = [list.size - 1, 0].max
309
+ @cursor = [[@cursor - 1, 0].max, max_idx].min
310
+ [self, nil]
311
+ when 'down', 'j'
312
+ max_idx = [list.size - 1, 0].max
313
+ @cursor = [[@cursor + 1, max_idx].min, 0].max
314
+ [self, nil]
315
+ when 'pgup', 'ctrl+u'
316
+ @cursor = [@cursor - visible_list_height, 0].max
317
+ @file_list_scroll_offset = [@file_list_scroll_offset - visible_list_height, 0].max
318
+ [self, nil]
319
+ when 'pgdown', 'ctrl+d'
320
+ @cursor = [@cursor + visible_list_height, [list.size - 1, 0].max].min
321
+ @file_list_scroll_offset = [@file_list_scroll_offset + visible_list_height, max_scroll].min
322
+ [self, nil]
323
+ when 'home', 'g'
324
+ @cursor = 0
325
+ @file_list_scroll_offset = 0
326
+ [self, nil]
327
+ when 'end', 'G'
328
+ @cursor = [list.size - 1, 0].max
329
+ @file_list_scroll_offset = max_scroll
330
+ [self, nil]
331
+ when 'right'
332
+ if row&.file_row?
333
+ path = row.path
334
+ if @expanded_files.include?(path)
335
+ @expanded_files.delete(path)
336
+ else
337
+ load_examples_for(path)
338
+ @expanded_files.add(path)
339
+ end
340
+ list = display_rows
341
+ @cursor = [@cursor, list.size - 1].min
342
+ end
343
+ [self, nil]
344
+ when 'left'
345
+ if row&.example_row?
346
+ path = row.path
347
+ parent_idx = (0...@cursor).to_a.rindex { |i| list[i].file_row? && list[i].path == path }
348
+ @cursor = parent_idx if parent_idx
349
+ row = list[@cursor]
350
+ end
351
+ if row&.file_row?
352
+ @expanded_files.delete(row.path)
353
+ list = display_rows
354
+ @cursor = [@cursor, list.size - 1].min
355
+ end
356
+ [self, nil]
357
+ when ']'
358
+ flat_spec_list.each { |path| @expanded_files.add(path) }
359
+ list = display_rows
360
+ @cursor = [@cursor, list.size - 1].min
361
+ [self, nil]
362
+ when '['
363
+ @expanded_files.clear
364
+ list = display_rows
365
+ @cursor = [@cursor, list.size - 1].min
366
+ [self, nil]
367
+ when ' ', 'space'
368
+ if row&.file_row?
369
+ @selected[row.path] = !@selected[row.path]
370
+ end
371
+ [self, nil]
372
+ when 'enter'
373
+ run_specs(paths_for_run)
374
+ when 'a'
375
+ run_specs(flat_spec_list.empty? ? @discovery.default_run_all_paths : flat_spec_list)
376
+ when 's'
377
+ run_specs(paths_for_run)
378
+ when 'f'
379
+ return run_specs(@run_failed_paths) if @run_failed_paths&.any?
380
+
381
+ [self, nil]
382
+ when 'e'
383
+ if row&.example_row?
384
+ run_specs([row.runnable_path])
385
+ elsif row&.file_row?
386
+ @input_prompt = { message: 'Line number (e.g. 42): ', buffer: '', file: row.path }
387
+ end
388
+ [self, nil]
389
+ when 'O'
390
+ open_in_editor(row&.path, row&.example_row? ? row.line_number : nil) if row
391
+ [self, nil]
392
+ when 'o'
393
+ @options_focus = true
394
+ [self, nil]
395
+ when 'R'
396
+ refresh_spec_list
397
+ [self, nil]
398
+ when 'I'
399
+ files = flat_spec_list
400
+ if files.empty?
401
+ [self, nil]
402
+ else
403
+ @screen = :indexing
404
+ @index_files = files
405
+ @index_current = 0
406
+ @index_total = files.size
407
+ [self, schedule_tick]
408
+ end
409
+ else
410
+ [self, nil]
411
+ end
412
+ end
413
+
414
+ def handle_find_key(message)
415
+ list = display_rows
416
+ if message.respond_to?(:backspace?) && message.backspace?
417
+ @find_buffer = @find_buffer[0, [@find_buffer.length - 1, 0].max]
418
+ @cursor = [@cursor, list.size - 1].min
419
+ @cursor = 0 if list.size.positive? && @cursor.negative?
420
+ return [self, nil]
421
+ end
422
+ if message.respond_to?(:enter?) && message.enter?
423
+ row = list[@cursor]
424
+ return run_specs([row.runnable_path]) if row
425
+ return [self, nil]
426
+ end
427
+ key = message.to_s
428
+ if (message.respond_to?(:esc?) && message.esc?) || key == 'ctrl+b'
429
+ sync_cursor_to_full_list
430
+ @expanded_files.clear
431
+ @find_buffer = nil
432
+ return [self, nil]
433
+ end
434
+ content_h = [@height - STATUS_HEIGHT - OPTIONS_BAR_HEIGHT, 5].max
435
+ visible_list_height = file_list_visible_height(content_h)
436
+ max_scroll = [list.size - visible_list_height, 0].max
437
+ case key
438
+ when 'up', 'k'
439
+ max_idx = [list.size - 1, 0].max
440
+ @cursor = [[@cursor - 1, 0].max, max_idx].min
441
+ return [self, nil]
442
+ when 'down', 'j'
443
+ max_idx = [list.size - 1, 0].max
444
+ @cursor = [[@cursor + 1, max_idx].min, 0].max
445
+ return [self, nil]
446
+ when 'pgup', 'ctrl+u'
447
+ @cursor = [@cursor - visible_list_height, 0].max
448
+ @file_list_scroll_offset = [@file_list_scroll_offset - visible_list_height, 0].max
449
+ return [self, nil]
450
+ when 'pgdown', 'ctrl+d'
451
+ @cursor = [@cursor + visible_list_height, [list.size - 1, 0].max].min
452
+ @file_list_scroll_offset = [@file_list_scroll_offset + visible_list_height, max_scroll].min
453
+ return [self, nil]
454
+ when 'home', 'g'
455
+ @cursor = 0
456
+ @file_list_scroll_offset = 0
457
+ return [self, nil]
458
+ when 'end', 'G'
459
+ @cursor = [list.size - 1, 0].max
460
+ @file_list_scroll_offset = max_scroll
461
+ return [self, nil]
462
+ end
463
+ if message.respond_to?(:char) && (c = message.char) && c.is_a?(String) && !c.empty?
464
+ @find_buffer += c
465
+ list = display_rows
466
+ @cursor = 0 if list.any? && (@cursor >= list.size || @cursor.negative?)
467
+ @cursor = [@cursor, list.size - 1].min
468
+ return [self, nil]
469
+ end
470
+ [self, nil]
471
+ end
472
+
473
+ def sync_cursor_to_full_list
474
+ return if @find_buffer.nil?
475
+
476
+ row = display_rows[@cursor]
477
+ return unless row
478
+
479
+ full = build_full_display_rows
480
+ idx = full.find_index { |r| r.row_id == row.row_id }
481
+ @cursor = idx if idx
482
+ max_idx = [full.size - 1, 0].max
483
+ @cursor = [[@cursor, max_idx].min, 0].max
484
+ end
485
+
486
+ def handle_input_prompt_key(message)
487
+ if message.respond_to?(:backspace?) && message.backspace?
488
+ buf = @input_prompt[:buffer]
489
+ @input_prompt = @input_prompt.merge(buffer: buf[0, buf.length - 1].to_s)
490
+ return [self, nil]
491
+ end
492
+ if message.respond_to?(:enter?) && message.enter?
493
+ line = @input_prompt[:buffer].strip
494
+ file = @input_prompt[:file]
495
+ @input_prompt = nil
496
+ path = line.to_i.positive? ? "#{file}:#{line}" : file
497
+ return run_specs([path])
498
+ end
499
+ if message.respond_to?(:esc?) && message.esc?
500
+ @input_prompt = nil
501
+ return [self, nil]
502
+ end
503
+ if message.respond_to?(:char) && (c = message.char) && c.is_a?(String) && !c.empty?
504
+ @input_prompt = @input_prompt.merge(buffer: @input_prompt[:buffer] + c)
505
+ return [self, nil]
506
+ end
507
+ [self, nil]
508
+ end
509
+
510
+ def handle_options_key(key, message)
511
+ return handle_options_edit_key(key, message) if @options_editing
512
+
513
+ case key
514
+ when 'q', 'ctrl+c'
515
+ [self, Bubbletea.quit]
516
+ when 'esc', 'b'
517
+ @options_focus = false
518
+ [self, nil]
519
+ when 'left', 'up', 'k'
520
+ max_opt = @options_field_keys.length - 1
521
+ @options_cursor = [[@options_cursor - 1, 0].max, max_opt].min
522
+ [self, nil]
523
+ when 'right', 'down', 'j'
524
+ max_opt = @options_field_keys.length - 1
525
+ @options_cursor = [[@options_cursor + 1, max_opt].min, 0].max
526
+ [self, nil]
527
+ when 'enter'
528
+ field = @options_field_keys[@options_cursor]
529
+ if field == :fail_fast
530
+ @options[:fail_fast] = !@options[:fail_fast]
531
+ elsif field == :editor
532
+ idx = RedDot::Config::VALID_EDITORS.index(@options[:editor].to_s) || 0
533
+ @options[:editor] = RedDot::Config::VALID_EDITORS[(idx + 1) % RedDot::Config::VALID_EDITORS.size]
534
+ else
535
+ @options_editing = field
536
+ @options_edit_buffer = @options[field].to_s.dup
537
+ end
538
+ [self, nil]
539
+ else
540
+ [self, nil]
541
+ end
542
+ end
543
+
544
+ def handle_options_edit_key(key, message)
545
+ if message.respond_to?(:backspace?) && message.backspace?
546
+ @options_edit_buffer = @options_edit_buffer[0, [@options_edit_buffer.length - 1, 0].max].to_s
547
+ return [self, nil]
548
+ end
549
+ if message.respond_to?(:enter?) && message.enter?
550
+ field = @options_editing
551
+ @options_editing = nil
552
+ @options[field] = @options_edit_buffer.dup
553
+ @options[:tags] = parse_tags(@options[:tags_str]) if field == :tags_str
554
+ [self, nil]
555
+ end
556
+ if message.respond_to?(:esc?) && message.esc?
557
+ @options_editing = nil
558
+ [self, nil]
559
+ end
560
+ if message.respond_to?(:char) && (c = message.char) && c.is_a?(String) && !c.empty?
561
+ @options_edit_buffer += c
562
+ [self, nil]
563
+ end
564
+ [self, nil]
565
+ end
566
+
567
+ def handle_results_key(key)
568
+ failed = @last_result&.failed_examples || []
569
+ content_h = [@height - STATUS_HEIGHT - OPTIONS_BAR_HEIGHT, 5].max
570
+ case key
571
+ when 'q', 'ctrl+c'
572
+ [self, Bubbletea.quit]
573
+ when 'esc', 'b'
574
+ @screen = :file_list
575
+ [self, nil]
576
+ when 'up', 'k'
577
+ @results_cursor = [[@results_cursor - 1, 0].max, [failed.length - 1, 0].max].min
578
+ [self, nil]
579
+ when 'down', 'j'
580
+ @results_cursor = [[@results_cursor + 1, failed.length - 1].min, 0].max
581
+ [self, nil]
582
+ when 'pgup', 'ctrl+u'
583
+ @results_scroll_offset = [@results_scroll_offset - content_h, 0].max
584
+ [self, nil]
585
+ when 'pgdown', 'ctrl+d'
586
+ max_scroll = [@results_total_lines - content_h, 0].max
587
+ @results_scroll_offset = [@results_scroll_offset + content_h, max_scroll].min
588
+ [self, nil]
589
+ when 'home', 'g'
590
+ @results_scroll_offset = 0
591
+ @results_cursor = 0
592
+ [self, nil]
593
+ when 'end', 'G'
594
+ max_scroll = [@results_total_lines - content_h, 0].max
595
+ @results_scroll_offset = max_scroll
596
+ @results_cursor = [failed.length - 1, 0].max
597
+ [self, nil]
598
+ when 'e'
599
+ ex = failed[@results_cursor]
600
+ if ex&.line_number
601
+ display_path = display_path_for_result_file(ex.file_path)
602
+ return run_specs(["#{display_path}:#{ex.line_number}"])
603
+ end
604
+ [self, nil]
605
+ when 'O'
606
+ ex = failed[@results_cursor]
607
+ if ex
608
+ display_path = display_path_for_result_file(ex.file_path)
609
+ open_in_editor(display_path, ex.line_number)
610
+ end
611
+ [self, nil]
612
+ when 'r'
613
+ run_specs(@last_run_paths || @discovery.default_run_all_paths)
614
+ when 'f'
615
+ return run_specs(@run_failed_paths) if @run_failed_paths&.any?
616
+
617
+ [self, nil]
618
+ else
619
+ [self, nil]
620
+ end
621
+ end
622
+
623
+ def flat_spec_list
624
+ @grouped.flat_map { |_dir, files| files }
625
+ end
626
+
627
+ def fuzzy_match(paths, query)
628
+ return paths if query.to_s.strip.empty?
629
+
630
+ q = query.to_s.downcase
631
+ paths.select { |path| fuzzy_match_string(path, q) }
632
+ end
633
+
634
+ def fuzzy_match_string(str, query)
635
+ return true if query.to_s.strip.empty?
636
+
637
+ q = query.to_s.downcase
638
+ s = str.to_s.downcase
639
+ j = 0
640
+ s.each_char do |c|
641
+ j += 1 if j < q.length && c == q[j]
642
+ end
643
+ j == q.length
644
+ end
645
+
646
+ def load_examples_for(path)
647
+ return @examples_by_file[path] if @examples_by_file.key?(path)
648
+
649
+ ctx = @discovery.run_context_for(path)
650
+ run_cwd = ctx[:run_cwd]
651
+ rspec_path = ctx[:rspec_path]
652
+ examples = ExampleDiscovery.get_cached_examples(run_cwd, rspec_path)
653
+ examples = ExampleDiscovery.discover(working_dir: run_cwd, path: rspec_path) if examples.nil?
654
+ @examples_by_file[path] = examples
655
+ examples
656
+ end
657
+
658
+ def build_full_display_rows
659
+ rows = []
660
+ flat_spec_list.each do |path|
661
+ rows << DisplayRow.new(type: :file, path: path, line_number: nil, full_description: nil)
662
+ next unless @expanded_files.include?(path)
663
+
664
+ load_examples_for(path).each do |ex|
665
+ rows << DisplayRow.new(
666
+ type: :example,
667
+ path: path,
668
+ line_number: ex.line_number,
669
+ full_description: ex.full_description
670
+ )
671
+ end
672
+ end
673
+ rows
674
+ end
675
+
676
+ def cached_examples_for(path)
677
+ return @examples_by_file[path] if @examples_by_file.key?(path)
678
+
679
+ ctx = @discovery.run_context_for(path)
680
+ examples = ExampleDiscovery.get_cached_examples(ctx[:run_cwd], ctx[:rspec_path])
681
+ examples || []
682
+ end
683
+
684
+ def build_filtered_display_rows
685
+ q = @find_buffer.to_s.strip.downcase
686
+ return build_full_display_rows if q.empty?
687
+
688
+ rows = []
689
+ flat_spec_list.each do |path|
690
+ file_matches = fuzzy_match_string(path, q)
691
+ examples = cached_examples_for(path)
692
+ example_matches = examples.select { |ex| fuzzy_match_string(ex.full_description.to_s, q) }
693
+ show_file = file_matches || example_matches.any?
694
+ next unless show_file
695
+
696
+ if example_matches.any?
697
+ @expanded_files.add(path)
698
+ end
699
+ rows << DisplayRow.new(type: :file, path: path, line_number: nil, full_description: nil)
700
+ examples_to_show = file_matches ? examples : example_matches
701
+ examples_to_show.each do |ex|
702
+ rows << DisplayRow.new(
703
+ type: :example,
704
+ path: path,
705
+ line_number: ex.line_number,
706
+ full_description: ex.full_description
707
+ )
708
+ end
709
+ end
710
+ rows
711
+ end
712
+
713
+ def display_rows
714
+ if @find_buffer.nil? || @find_buffer.to_s.strip.empty?
715
+ build_full_display_rows
716
+ else
717
+ build_filtered_display_rows
718
+ end
719
+ end
720
+
721
+ def current_spec_list
722
+ display_rows
723
+ end
724
+
725
+ def paths_for_run
726
+ selected = @selected.select { |_, v| v }.keys
727
+ if selected.any?
728
+ selected.sort
729
+ else
730
+ row = display_rows[@cursor]
731
+ if row
732
+ [row.runnable_path]
733
+ else
734
+ @discovery.default_run_all_paths
735
+ end
736
+ end
737
+ end
738
+
739
+ def apply_line_number_to_paths(paths)
740
+ line = @options[:line_number].to_s.strip
741
+ return paths if line.empty?
742
+ return paths unless paths.size == 1 && paths[0].to_s !~ /:\d+\z/
743
+ return paths unless line.match?(/\A\d+\z/)
744
+
745
+ ["#{paths[0]}:#{line}"]
746
+ end
747
+
748
+ def run_specs(paths)
749
+ paths = apply_line_number_to_paths(paths)
750
+ @last_run_paths = paths
751
+ tags = @options[:tags].empty? ? parse_tags(@options[:tags_str]) : @options[:tags]
752
+ format = @options[:format]
753
+ out_path = @options[:out_path].to_s.strip
754
+ out_path = nil if out_path.empty?
755
+ example_filter = @options[:example_filter].to_s.strip
756
+ example_filter = nil if example_filter.empty?
757
+ seed = @options[:seed].to_s.strip
758
+ seed = nil if seed.empty?
759
+
760
+ groups = group_paths_by_run_context(paths)
761
+ if groups.size == 1
762
+ g = groups.first
763
+ opts = { working_dir: g[:run_cwd], paths: g[:rspec_paths], tags: tags, format: format,
764
+ out_path: out_path, example_filter: example_filter, fail_fast: @options[:fail_fast], seed: seed }
765
+ proc = lambda do
766
+ data = RspecRunner.spawn(**opts)
767
+ RspecStartedMessage.new(pid: data[:pid], stdout_io: data[:stdout_io], json_path: data[:json_path], component_root: g[:component_root])
768
+ end
769
+ return [self, proc]
770
+ end
771
+
772
+ @run_queue = groups
773
+ first = @run_queue.shift
774
+ opts = { working_dir: first[:run_cwd], paths: first[:rspec_paths], tags: tags, format: format,
775
+ out_path: out_path, example_filter: example_filter, fail_fast: @options[:fail_fast], seed: seed }
776
+ proc = lambda do
777
+ data = RspecRunner.spawn(**opts)
778
+ RspecStartedMessage.new(pid: data[:pid], stdout_io: data[:stdout_io], json_path: data[:json_path], component_root: first[:component_root])
779
+ end
780
+ [self, proc]
781
+ end
782
+
783
+ def group_paths_by_run_context(paths)
784
+ by_cwd = Hash.new { |h, k| h[k] = { run_cwd: k, rspec_paths: [], component_root: nil } }
785
+ paths.each do |display_path|
786
+ ctx = @discovery.run_context_for(display_path)
787
+ run_cwd = ctx[:run_cwd]
788
+ rspec_path = ctx[:rspec_path]
789
+ component_root = @discovery.umbrella? ? relative_component_root(run_cwd) : nil
790
+ by_cwd[run_cwd][:rspec_paths] << rspec_path
791
+ by_cwd[run_cwd][:component_root] = component_root
792
+ end
793
+ by_cwd.values
794
+ end
795
+
796
+ def relative_component_root(run_cwd)
797
+ return '' if run_cwd == @working_dir
798
+
799
+ Pathname.new(run_cwd).relative_path_from(Pathname.new(@working_dir)).to_s
800
+ end
801
+
802
+ def normalize_failure_paths(locations)
803
+ return locations if locations.nil? || locations.empty?
804
+ return locations unless @last_run_component_root
805
+
806
+ locations.map do |loc|
807
+ if loc.to_s.include?(':')
808
+ file, line = loc.to_s.split(':', 2)
809
+ display_file = @last_run_component_root.empty? ? file : "#{@last_run_component_root}/#{file}"
810
+ "#{display_file}:#{line}"
811
+ else
812
+ @last_run_component_root.empty? ? loc : "#{@last_run_component_root}/#{loc}"
813
+ end
814
+ end
815
+ end
816
+
817
+ def display_path_for_result_file(file_path)
818
+ return file_path.to_s if file_path.to_s.strip.empty?
819
+ return file_path.to_s unless @last_run_component_root
820
+
821
+ if @last_run_component_root.empty?
822
+ file_path.to_s
823
+ else
824
+ "#{@last_run_component_root}/#{file_path}"
825
+ end
826
+ end
827
+
828
+ def parse_tags(str)
829
+ return [] if str.to_s.strip.empty?
830
+
831
+ str.split(/[\s,]+/).map(&:strip).reject(&:empty?)
832
+ end
833
+
834
+ def read_run_output
835
+ return unless @run_stdout
836
+
837
+ @run_stdout.read_nonblock(4096).each_line do |line|
838
+ @run_output << line.chomp
839
+ end
840
+ rescue IO::WaitReadable, EOFError
841
+ # no more data
842
+ end
843
+
844
+ def kill_run
845
+ return unless @run_pid
846
+
847
+ Process.kill('TERM', @run_pid) rescue nil
848
+ Process.wait(@run_pid) rescue nil
849
+ @run_stdout&.close
850
+ @run_stdout = nil
851
+ @run_pid = nil
852
+ @run_queue = nil
853
+ end
854
+
855
+ def refresh_spec_list
856
+ @spec_files = @discovery.discover
857
+ @grouped = @discovery.discover_grouped_by_dir
858
+ @expanded_files = Set.new
859
+ @examples_by_file = {}
860
+ @find_buffer = nil
861
+ @cursor = 0
862
+ [self, nil]
863
+ end
864
+
865
+ def open_in_editor(path, line = nil)
866
+ return if path.to_s.strip.empty?
867
+
868
+ full_path = File.expand_path(path, @working_dir)
869
+ return unless File.exist?(full_path)
870
+
871
+ editor = (@options[:editor] || 'cursor').to_s.downcase
872
+ editor = 'cursor' unless RedDot::Config::VALID_EDITORS.include?(editor)
873
+
874
+ args = case editor
875
+ when 'vscode'
876
+ line ? ['code', '-g', "#{full_path}:#{line}"] : ['code', full_path]
877
+ when 'cursor'
878
+ line ? ['cursor', '-g', "#{full_path}:#{line}"] : ['cursor', full_path]
879
+ when 'textmate'
880
+ line ? ['mate', '-l', line.to_s, full_path] : ['mate', full_path]
881
+ else
882
+ ['cursor', full_path]
883
+ end
884
+
885
+ pid = Process.spawn(*args, out: File::NULL, err: File::NULL)
886
+ Process.detach(pid)
887
+ end
888
+
889
+ def visible_length(str)
890
+ str.to_s.gsub(/\e\[[0-9;]*m/, '').length
891
+ end
892
+
893
+ def truncate_line(str, max_w)
894
+ return str.to_s if visible_length(str) <= max_w
895
+
896
+ out = +''
897
+ len = 0
898
+ i = 0
899
+ s = str.to_s
900
+ while i < s.length
901
+ if s[i] == "\e" && s[i + 1] == '['
902
+ j = s.index('m', i)
903
+ i = j ? j + 1 : s.length
904
+ else
905
+ break if len >= max_w
906
+
907
+ len += 1
908
+ out << s[i]
909
+ i += 1
910
+ end
911
+ end
912
+ out
913
+ end
914
+
915
+ def pad_line(str, width)
916
+ str.to_s + (' ' * [width - visible_length(str), 0].max)
917
+ end
918
+
919
+ def block_to_size(lines, width, height)
920
+ truncated = lines.first(height).map { |line| pad_line(truncate_line(line, width), width) }
921
+ padding = [height - truncated.size, 0].max
922
+ (truncated + Array.new(padding) { ' ' * width }).join("\n")
923
+ end
924
+
925
+ def index_empty?
926
+ @examples_by_file.empty? && ExampleDiscovery.read_cache_file(@working_dir).empty?
927
+ end
928
+
929
+ def build_file_list_lines(content_h)
930
+ header_lines = []
931
+ title = @find_buffer ? ' 2 Find ' : ' 2 Spec files '
932
+ header_lines << (focused_panel == 2 ? @active_title_style.render(title) : @inactive_title_style.render(title))
933
+ header_lines << @muted_style.render(" Find: #{@find_buffer}_") if @find_buffer
934
+ if @find_buffer && index_empty? && !@find_index_hint_shown
935
+ header_lines << @muted_style.render(' No index yet — press I to index for test name search.')
936
+ @find_index_hint_shown = true
937
+ end
938
+ header_lines << ''
939
+ list = display_rows
940
+ if list.empty?
941
+ header_lines << (@find_buffer.to_s.strip.empty? ? @muted_style.render(" #{@discovery.empty_state_message}") : @muted_style.render(' No matches'))
942
+ return header_lines
943
+ end
944
+ @cursor = [@cursor, list.size - 1].min
945
+ visible_list_height = [content_h - header_lines.size, 1].max
946
+ max_scroll = [list.size - visible_list_height, 0].max
947
+ @file_list_scroll_offset = [[@file_list_scroll_offset, max_scroll].min, 0].max
948
+ @file_list_scroll_offset = @cursor if @cursor < @file_list_scroll_offset
949
+ @file_list_scroll_offset = @cursor - visible_list_height + 1 if @cursor >= @file_list_scroll_offset + visible_list_height
950
+ visible_rows = list[@file_list_scroll_offset, visible_list_height] || []
951
+ list_lines = visible_rows.each_with_index.map do |row, i|
952
+ idx = @file_list_scroll_offset + i
953
+ cursor_here = idx == @cursor
954
+ line_style = cursor_here ? @cursor_style : (row.file_row? && @selected[row.path] ? @selected_style : Lipgloss::Style.new)
955
+ if row.file_row?
956
+ prefix = cursor_here ? '> ' : ' '
957
+ expand_icon = @expanded_files.include?(row.path) ? '▼ ' : '▶ '
958
+ check = @selected[row.path] ? @pass_style.render('[x] ') : '[ ] '
959
+ prefix + expand_icon + check + line_style.render(row.path)
960
+ else
961
+ prefix = cursor_here ? ' > ' : ' '
962
+ desc = row.full_description.to_s
963
+ prefix + line_style.render(desc)
964
+ end
965
+ end
966
+ header_lines + list_lines
967
+ end
968
+
969
+ def build_center_panel_lines(content_h)
970
+ return build_input_prompt_lines if @input_prompt
971
+
972
+ case @screen
973
+ when :indexing then build_indexing_lines
974
+ when :running then build_running_lines
975
+ when :results then build_results_lines(content_h)
976
+ else build_idle_lines
977
+ end
978
+ end
979
+
980
+ def build_indexing_lines
981
+ total = [@index_total, 1].max
982
+ current = @index_current
983
+ bar_width = 40
984
+ filled = [(current.to_f / total * bar_width).round, bar_width].min
985
+ bar = '=' * filled
986
+ bar += '>' if current < total && filled < bar_width
987
+ bar = bar.ljust(bar_width)
988
+ path = @index_files[current] if current < @index_files.size
989
+ [
990
+ @active_title_style.render(' 3 Indexing specs '),
991
+ '',
992
+ " #{current}/#{total} [#{bar}]",
993
+ '',
994
+ path ? @muted_style.render(" #{path}") : '',
995
+ '',
996
+ @help_style.render(' q / Esc: cancel')
997
+ ]
998
+ end
999
+
1000
+ def build_input_prompt_lines
1001
+ [
1002
+ @active_title_style.render(' 3 Run example at line '),
1003
+ '',
1004
+ " #{@input_prompt[:message]}#{@input_prompt[:buffer]}_",
1005
+ '',
1006
+ @help_style.render(' Enter: run Esc: cancel')
1007
+ ]
1008
+ end
1009
+
1010
+ def build_idle_lines
1011
+ title = ' 3 Output / Results '
1012
+ [
1013
+ (focused_panel == 3 ? @active_title_style.render(title) : @inactive_title_style.render(title)),
1014
+ '',
1015
+ @muted_style.render('Select files (Space), then Enter or s to run.'),
1016
+ @muted_style.render('a = run all f = run failed (after failures)'),
1017
+ '',
1018
+ (@last_result ? " Last: #{@last_result.summary_line}" : '')
1019
+ ].compact
1020
+ end
1021
+
1022
+ def build_options_bar_lines
1023
+ labels = {
1024
+ tags_str: 'Tags', format: 'Format', out_path: 'Output',
1025
+ example_filter: 'Example', line_number: 'Line', fail_fast: 'Fail-fast', seed: 'Seed', editor: 'Editor'
1026
+ }
1027
+ max_val = 14
1028
+ segments = @options_field_keys.each_with_index.map do |key, i|
1029
+ if @options_editing == key
1030
+ val = "#{@options_edit_buffer}_"
1031
+ elsif key == :fail_fast
1032
+ val = @options[:fail_fast].to_s
1033
+ elsif key == :editor
1034
+ val = @options[:editor].to_s
1035
+ elsif %i[line_number seed].include?(key)
1036
+ val = @options[key].to_s
1037
+ val = (val.length > max_val ? "#{val[0, max_val - 2]}.." : val)
1038
+ else
1039
+ val = @options[key].to_s
1040
+ val = (val.length > max_val ? "#{val[0, max_val - 2]}.." : val)
1041
+ end
1042
+ str = "#{labels[key]}: #{val}"
1043
+ str = @cursor_style.render(str) if @options_focus && i == @options_cursor && @options_editing.nil?
1044
+ str
1045
+ end
1046
+ title = (focused_panel == 1 ? @active_title_style : @inactive_title_style).render(' 1 Options ')
1047
+ options_row = " #{segments.join(' │ ')}"
1048
+ help = @options_editing ? ' Enter: save Esc: cancel' : ' o: focus ←/→: move Enter: edit/toggle b: unfocus q: quit'
1049
+ help_row = @help_style.render(help)
1050
+ [title, '', options_row, help_row, '']
1051
+ end
1052
+
1053
+ def build_running_lines
1054
+ title = ' 3 Running RSpec '
1055
+ [
1056
+ (focused_panel == 3 ? @active_title_style.render(title) : @inactive_title_style.render(title)),
1057
+ '',
1058
+ *@run_output.last(50).map { |l| " #{l}" },
1059
+ '',
1060
+ @help_style.render(' q: quit (kill run)')
1061
+ ]
1062
+ end
1063
+
1064
+ def build_results_lines(content_h)
1065
+ title = ' 3 Results '
1066
+ lines = [(focused_panel == 3 ? @active_title_style.render(title) : @inactive_title_style.render(title)), '']
1067
+ @results_failed_line_indices = []
1068
+ if @last_result
1069
+ r = @last_result
1070
+ lines << " #{r.summary_line}"
1071
+ total = r.examples.size
1072
+ pass_pct = total.positive? ? ((r.passed_count.to_f / total) * 100).round : 0
1073
+ metrics = ["Pass: #{r.passed_count}/#{total} (#{pass_pct}%)"]
1074
+ metrics << "Total: #{format_run_time(r.duration)}" if r.duration.is_a?(Numeric)
1075
+ metrics << "Seed: #{r.seed}" if r.seed
1076
+ lines << @muted_style.render(" #{metrics.join(' | ')}")
1077
+ if r.errors_outside_of_examples.positive?
1078
+ lines << @warn_style.render(" #{r.errors_outside_of_examples} error(s) outside examples (e.g. load/hook failures)")
1079
+ end
1080
+ lines << ''
1081
+ if r.examples_with_run_time.any?
1082
+ lines << @muted_style.render(' Slowest:')
1083
+ r.slowest_examples(5).each do |ex|
1084
+ loc = [ex.file_path, ex.line_number].compact.join(':')
1085
+ lines << " #{format_run_time(ex.run_time)} #{loc} #{ex.description}"
1086
+ end
1087
+ lines << ''
1088
+ lines << @muted_style.render(' Fastest:')
1089
+ r.fastest_examples(5).each do |ex|
1090
+ loc = [ex.file_path, ex.line_number].compact.join(':')
1091
+ lines << " #{format_run_time(ex.run_time)} #{loc} #{ex.description}"
1092
+ end
1093
+ lines << ''
1094
+ end
1095
+ if r.pending_count.positive?
1096
+ lines << @muted_style.render(' Pending:')
1097
+ r.pending_examples.each do |ex|
1098
+ loc = [ex.file_path, ex.line_number].compact.join(':')
1099
+ lines << " #{loc} #{ex.description}"
1100
+ lines << @muted_style.render(" #{ex.pending_message}") if ex.pending_message.to_s.strip != ''
1101
+ end
1102
+ lines << ''
1103
+ end
1104
+ if r.examples_with_run_time.any? && r.slowest_files(5).any?
1105
+ lines << @muted_style.render(' Slowest files:')
1106
+ r.slowest_files(5).each do |path, total_sec|
1107
+ lines << " #{format_run_time(total_sec)} #{path}"
1108
+ end
1109
+ lines << ''
1110
+ end
1111
+ if r.failed_count.positive?
1112
+ lines << @muted_style.render(' Failed:')
1113
+ r.failed_examples.each_with_index do |ex, i|
1114
+ @results_failed_line_indices << lines.size
1115
+ prefix = i == @results_cursor ? '> ' : ' '
1116
+ loc_path = display_path_for_result_file(ex.file_path)
1117
+ lines << @fail_style.render("#{prefix}#{loc_path}:#{ex.line_number} #{ex.description}")
1118
+ lines << @muted_style.render(" #{ex.exception_message&.lines&.first&.strip}") if ex.exception_message
1119
+ end
1120
+ end
1121
+ else
1122
+ lines << @muted_style.render(' No result data.')
1123
+ end
1124
+ lines << ''
1125
+ lines << @help_style.render(' j/k: move PgUp/PgDn: scroll g/G: top/bottom e: run O: open b: back r: rerun f: failed q: quit')
1126
+ @results_total_lines = lines.size
1127
+ max_scroll = [@results_total_lines - content_h, 0].max
1128
+ @results_scroll_offset = [[@results_scroll_offset, max_scroll].min, 0].max
1129
+ if @results_failed_line_indices.any? && @results_cursor < @results_failed_line_indices.size
1130
+ cursor_line = @results_failed_line_indices[@results_cursor]
1131
+ if cursor_line < @results_scroll_offset
1132
+ @results_scroll_offset = cursor_line
1133
+ elsif cursor_line >= @results_scroll_offset + content_h
1134
+ @results_scroll_offset = cursor_line - content_h + 1
1135
+ end
1136
+ end
1137
+ lines[@results_scroll_offset, content_h] || []
1138
+ end
1139
+
1140
+ def format_run_time(seconds)
1141
+ return '' unless seconds.is_a?(Numeric)
1142
+
1143
+ seconds < 1 ? "#{(seconds * 1000).round}ms" : "#{seconds.round(2)}s"
1144
+ end
1145
+
1146
+ def status_line
1147
+ return ' Enter line number, Enter: run Esc: cancel ' if @input_prompt
1148
+ return ' j/k: move PgUp/PgDn: scroll g/G: top/bottom Enter: run Esc or Ctrl+B: exit find ' if @find_buffer
1149
+
1150
+ case @screen
1151
+ when :file_list
1152
+ if @options_focus
1153
+ ' 1/2/3: panels j/k: move Enter: edit b: back q: quit '
1154
+ else
1155
+ ' 1/2/3: panels /: find I: index j/k: move PgUp/PgDn g/G: top/bottom ]/[: expand a: all s: selected e: run O: open f: failed o: options R: refresh q: quit '
1156
+ end
1157
+ when :indexing then ' Indexing specs for search... q / Esc: cancel '
1158
+ when :running then ' 1/2/3: panels q: kill run '
1159
+ when :results then ' 1/2/3: panels j/k: move PgUp/PgDn: scroll g/G: top/bottom e: run O: open b: back r: rerun f: failed q: quit '
1160
+ else ' 1/2/3: panels q: quit '
1161
+ end
1162
+ end
1163
+
1164
+ def truncate_plain(str, max_w)
1165
+ s = str.to_s.gsub(/\e\[[0-9;]*m/, '')
1166
+ s.length <= max_w ? s : s[0, max_w]
1167
+ end
1168
+ end
1169
+ end