red_dot 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e48a83c891b5b2979651d8c6f766f47b8f25718e4c303ebca0068764bca0226e
4
- data.tar.gz: aa3bf5f6074d7a126336e2292e7c8803698ed62d50eaba4aeae7f65254704d94
3
+ metadata.gz: 428cfca728336aee1bdd374460f9ca755414745a01e122efe33fbb455c8e375f
4
+ data.tar.gz: a2260e440bf5eb4a1fcba63634df295e2a745cb10515f24c57766984a3a103ef
5
5
  SHA512:
6
- metadata.gz: c811553942cc8d55e78b802e5fe056f57130e56de9956dd1d32a0cf147e0f63a379ae044f06a36465ce382ea0512bfa14bfeb6cc143f725845cf6da29e5eb664
7
- data.tar.gz: 5325b9d2fb1cb15902c398893077e4d9eb55ecf98ddae1c3af7a9b7ae912b5f25e86f37f40f2c0ebf7367cdaff472215f405dbb157961bcd1556f21f24481fa1
6
+ metadata.gz: 14ac6880020bb4811a193fabf4dc1760b60b99901e7ac44359dcc8197788a58204d6306bd91f799de6a2bbd3f34ed4c6b3732e6eaac3a68a4464f6b4e31cc171
7
+ data.tar.gz: 980c05e314bee952e432ae9f05d765d5ad18324a3c98fc85b1b8a7e9354e8208a19c98fe31e719143cd1b058860e77e686addecd697d6d1fcdc48e3cb1f42b9d
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Red Dot
2
2
 
3
- A terminal UI for running RSpec tests. Start it from your project directory, leave it open, and run specs whenever you want.
3
+ A terminal UI for running RSpec tests (Inspired by VScode Test Explorer). Start it from your project directory, leave it open, and run any combination of specs whenever you want.
4
4
 
5
5
  ## Installation
6
6
 
@@ -36,7 +36,7 @@ bundle install
36
36
  gem install --local .
37
37
  ```
38
38
 
39
- Requires Ruby 3.2+ and `bundle install` will pull in dependencies (bubbletea, lipgloss).
39
+ Requires Ruby 3.3+ and `bundle install` will pull in dependencies (bubbletea, lipgloss).
40
40
 
41
41
  ## Usage
42
42
 
@@ -53,7 +53,6 @@ shift + i
53
53
 
54
54
  **Umbrella / component projects**: From the repo root that has a root-level `components/` directory, run `rdot` to see and run specs from the root (if it has a `spec/` dir) and from each direct child of `components/` that has a `spec/` subdirectory. Each component’s specs run in that component’s directory (using its Gemfile). You can still run `rdot` from inside a component (e.g. `cd components/auth && rdot`) for single-component mode.
55
55
 
56
-
57
56
  ### One-off options (CLI)
58
57
 
59
58
  Override options for a single run without editing config or the TUI:
@@ -64,14 +63,17 @@ rdot --tag slow --fail-fast /path/to/project/root
64
63
  rdot -f progress -t focus -o /tmp/out.txt
65
64
  ```
66
65
 
67
- | Option | Short | Description |
68
- |--------|-------|-------------|
69
- | `--format` | `-f` | RSpec formatter (e.g. progress, documentation) |
70
- | `--tag` | `-t` | Tag filter (repeatable) |
71
- | `--output` | `-o` | Output file path |
72
- | `--example` | `-e` | Example filter (e.g. regex) |
73
- | `--line` | `-l` | Line number (run single example at path:line when running one file) |
74
- | `--fail-fast` | | Stop on first failure |
66
+
67
+ | Option | Short | Description |
68
+ | --------------- | ----- | ----------------------------------------------------------------------------------------------------------- |
69
+ | `--format` | `-f` | RSpec formatter (e.g. progress, documentation) |
70
+ | `--tag` | `-t` | Tag filter (repeatable) |
71
+ | `--output` | `-o` | Output file path |
72
+ | `--example` | `-e` | Example filter (e.g. regex) |
73
+ | `--line` | `-l` | Line number (run single example at path:line when running one file) |
74
+ | `--fail-fast` | — | Stop on first failure |
75
+ | `--full-output` | — | After a run, show captured RSpec stdout in the results panel (same as toggling **Full output** in options). |
76
+
75
77
 
76
78
  ### Configuration
77
79
 
@@ -84,16 +86,19 @@ Options are merged in this order (later overrides earlier):
84
86
 
85
87
  All of these can be overridden via config YAML (user or project). In the TUI, press `o` to focus the options bar and edit any field (Enter to edit or toggle).
86
88
 
87
- | Option | Default | Config key | Notes |
88
- |--------|--------|------------|-------|
89
- | Tags | *(empty)* | `tags` or `tags_str` | RSpec tag filter. Use `tags:` (array) or `tags_str:` (string, e.g. `"~slow, focus"`). |
90
- | Format | `progress` | `format` | RSpec formatter: e.g. `progress`, `documentation`. |
91
- | Output | *(empty)* | `output` | File path for RSpec output. Also accepts legacy key `out_path`. |
92
- | Example | *(empty)* | `example_filter` | Example filter (e.g. regex) passed to RSpec. |
93
- | Line | *(empty)* | `line_number` | Line number for single-file runs (path:line). |
94
- | Fail-fast | `false` | `fail_fast` | Stop on first failure. Use `true` or `false`. |
95
- | Seed | *(empty)* | `seed` | RSpec random seed (e.g. `12345`) for reproducibility. |
96
- | Editor | `cursor` | `editor` | Editor for “open file” (O): `vscode`, `cursor`, or `textmate`. |
89
+
90
+ | Option | Default | Config key | Notes |
91
+ | ----------- | ---------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
92
+ | Tags | *(empty)* | `tags` or `tags_str` | RSpec tag filter. Use `tags:` (array) or `tags_str:` (string, e.g. `"~slow, focus"`). |
93
+ | Format | `progress` | `format` | RSpec formatter: e.g. `progress`, `documentation`. |
94
+ | Output | *(empty)* | `output` | File path for RSpec output. Also accepts legacy key `out_path`. |
95
+ | Example | *(empty)* | `example_filter` | Example filter (e.g. regex) passed to RSpec. |
96
+ | Line | *(empty)* | `line_number` | Line number for single-file runs (path:line). |
97
+ | Fail-fast | `false` | `fail_fast` | Stop on first failure. Use `true` or `false`. |
98
+ | Full output | `false` | `full_output` | Results panel shows captured RSpec stdout (scrollable) instead of the structured summary. Toggle with Enter on **Full output** in the options bar. |
99
+ | Seed | *(empty)* | `seed` | RSpec random seed (e.g. `12345`) for reproducibility. |
100
+ | Editor | `cursor` | `editor` | Editor for “open file” (O): `vscode`, `cursor`, or `textmate`. |
101
+
97
102
 
98
103
  | Components | *(auto)* | `components` | Umbrella only: list of component root paths (relative to project root). Overrides automatic discovery. Use `"."` or `""` for root, e.g. `[".", "components/auth", "apps/web"]`. |
99
104
 
@@ -103,7 +108,7 @@ Example `~/.config/red_dot/config.yml`:
103
108
  format: documentation
104
109
  tags_str: "~slow"
105
110
  fail_fast: false
106
- editor: cursor # vscode, cursor, or textmate — used when opening file (O)
111
+ editor: cursor # vscode, cursor, or textmate — path executable used when opening file (O)
107
112
  ```
108
113
 
109
114
  Example `.red_dot.yml` in project root:
@@ -121,70 +126,88 @@ You can edit any option in the TUI: press `o` for Options, then ←/→ or j/k t
121
126
  The TUI uses a layout with numbered panels and a shared status bar:
122
127
 
123
128
  - **Panel 1 — Options**: Top bar (Tags, Format, Output, Example, Line, Fail-fast, Editor). Press `1` or `o` to focus; ←/→ or j/k to move, Enter to edit or toggle (Editor cycles vscode/cursor/textmate); `b` or Esc to unfocus.
124
- - **Panel 2 — Spec files**: Left panel (browse, →/← expand/collapse to show tests per file, Space to select files, Enter/s to run, `e` to run at line or run focused example, `O` to open selected file in editor).
129
+ - **Panel 2 — Spec files**: Left panel (browse, →/← expand/collapse to show tests per file, Ctrl+T to select files, Enter/s to run, `e` to run at line or run focused example, `O` to open selected file in editor).
125
130
  - **Panel 3 — Output/Results**: Right panel (idle message, live RSpec output, or results). Press `3` to focus when results are available.
126
- - **Status bar**: Key hints at the bottom. Press **`1`**, **`2`**, or **`3`** from anywhere to switch focus to that panel.
131
+ - **Status bar**: Key hints at the bottom. Press `**1`**, `**2`**, or `**3**` from anywhere to switch focus to that panel.
127
132
 
128
133
  The TUI stays open until you press `q` or Ctrl+C. You can:
129
134
 
130
- - **File list**: Browse spec files; press **→** to expand a file (list its tests) and **←** to collapse. Press **I** to **index** all spec files (builds a searchable cache with a progress bar; run once so find can match test names without expanding). Select files with Space, run with Enter or `s` (selected), `a` (all), `e` (run at line on a file, or run the focused example when on an example row), `f` (failed, after a run with failures). Use **/** to find; search matches file paths and test names for indexed (or expanded) files — put the cursor on a matched example and press Enter to run just that test. **Esc** or **Enter** exits find and collapses all files. Use **]** to expand all files and **\[** to collapse all.
135
+ - **File list**: Browse spec files; press **→** to expand a file (list its tests) and **←** to collapse. Press **I** to **index** all spec files (builds a searchable cache with a progress bar; run once so find can match test names without expanding). Select files with **Ctrl+T** (works in find mode too; Space types a space in the find query), run with Enter or `s` (selected), `a` (all), `e` (run at line on a file, or run the focused example when on an example row), `f` (failed, after a run with failures). Use **/** to find; search matches file paths and test names for indexed (or expanded) files — put the cursor on a matched example and press Enter to run just that test. **Esc** or **Enter** exits find and collapses all files. Use **]** to expand all files and to collapse all.
131
136
  - **Options** (top bar): Always visible. Press `o` to focus; ←/→ or j/k to move, Enter to edit a field or toggle fail-fast; `b` or Esc to unfocus.
132
- - **Running**: See live RSpec output in the right panel. Press `q` to kill the run and return to the file list.
137
+ - **Running**: See live RSpec output in the right panel. Press `**3`** to focus the output pane (if you switched to the file list). Use **j/k**, **PgUp/PgDn**, **g/G** to scroll the output; `**2`** to switch back to the file list. Press `**q`** to kill the run and return to the file list.
133
138
  - **Results**: In the right panel; j/k to move over failures, `e` to run that single example (path:line), `O` to open that file in your configured editor, `r` to rerun same scope, `f` to rerun only failed examples.
134
139
 
135
140
  ### Key bindings
136
141
 
137
- | Key | Action |
138
- |-----|--------|
139
- | **1** | Focus panel 1 (Options) |
140
- | **2** | Focus panel 2 (Spec files) |
141
- | **3** | Focus panel 3 (Output/Results; only when results exist) |
142
- | q / Esc | Quit |
143
-
144
- | Key | Action (file list — panel 2) |
145
- |-----|------------------------------|
146
- | j / ↓ | Move down |
147
- | k / | Move up |
148
- | | Expand file (show its tests) |
149
- | | Collapse file (or move to parent file and collapse) |
150
- | ] | Expand all files |
151
- | [ | Collapse all files |
152
- | Space | Toggle selection (files only) |
153
- | Enter | Run selected (or current file/example if none selected) |
154
- | a | Run all specs |
155
- | s | Run selected specs |
156
- | e | Run at line (prompt for line number on a file) or run this example (when on an example row) |
157
- | O | Open selected file (or example’s file at line) in configured editor (vscode/cursor/textmate) |
158
- | f | Run failed only (after a run with failures) |
159
- | I | Index all spec files (build cache for find; shows progress bar) |
160
- | o | Focus options (panel 1) |
161
- | R | Refresh file list |
162
- | q / Esc | Quit |
163
-
164
- | Key | Action (options bar — panel 1) |
165
- |-----|---------------------------------|
166
- | 2 | Focus panel 2 (Spec files) |
167
- | ← / → or j / k | Move between fields |
168
- | Enter | Edit field, toggle fail-fast, or cycle editor (vscode/cursor/textmate) |
169
- | b / Esc | Unfocus options, back to file list |
170
- | q | Quit |
171
-
172
- | Key | Action (results panel 3) |
173
- |-----|----------------------------|
174
- | j / k | Move between failed examples |
175
- | e | Run this example only (path:line) |
176
- | O | Open this file in configured editor |
177
- | 2 / b / Esc | Back to file list |
178
- | r | Rerun same scope |
179
- | f | Rerun failed only |
180
- | q | Quit |
142
+
143
+ | Key | Action |
144
+ | ------- | ------------------------------------------------------------------------------------------- |
145
+ | **1** | Focus panel 1 (Options) |
146
+ | **2** | Focus panel 2 (Spec files) |
147
+ | **3** | Focus panel 3 (Output/Results when results exist; Running output when a run is in progress) |
148
+ | q / Esc | Quit |
149
+
150
+
151
+
152
+ | Key | Action (file list panel 2) |
153
+ | ------- | -------------------------------------------------------------------------------------------- |
154
+ | j / ↓ | Move down |
155
+ | k / ↑ | Move up |
156
+ | | Expand file (show its tests) |
157
+ | | Collapse file (or move to parent file and collapse) |
158
+ | ] | Expand all files |
159
+ | [ | Collapse all files |
160
+ | Ctrl+T | Toggle selection (files only; use in find mode too) |
161
+ | Enter | Run selected (or current file/example if none selected) |
162
+ | a | Run all specs |
163
+ | s | Run selected specs |
164
+ | e | Run at line (prompt for line number on a file) or run this example (when on an example row) |
165
+ | O | Open selected file (or example’s file at line) in configured editor (vscode/cursor/textmate) |
166
+ | f | Run failed only (after a run with failures) |
167
+ | I | Index all spec files (build cache for find; shows progress bar) |
168
+ | o | Focus options (panel 1) |
169
+ | R | Refresh file list |
170
+ | q / Esc | Quit |
171
+
172
+
173
+
174
+ | Key | Action (options bar panel 1) |
175
+ | -------------- | ---------------------------------------------------------------------- |
176
+ | 2 | Focus panel 2 (Spec files) |
177
+ | / or j / k | Move between fields |
178
+ | Enter | Edit field, toggle fail-fast, or cycle editor (vscode/cursor/textmate) |
179
+ | b / Esc | Unfocus options, back to file list |
180
+ | q | Quit |
181
+
182
+
183
+
184
+ | Key | Action (running output — panel 3, during a run) |
185
+ | ----------- | ------------------------------------------------- |
186
+ | j / k | Scroll output up/down |
187
+ | PgUp / PgDn | Page scroll |
188
+ | g / G | Jump to top/bottom of output |
189
+ | 2 | Switch to file list (run continues in background) |
190
+ | q | Kill run and return to file list |
191
+
192
+
193
+
194
+ | Key | Action (results — panel 3) |
195
+ | ----------- | ----------------------------------- |
196
+ | j / k | Move between failed examples |
197
+ | e | Run this example only (path:line) |
198
+ | O | Open this file in configured editor |
199
+ | 2 / b / Esc | Back to file list |
200
+ | r | Rerun same scope |
201
+ | f | Rerun failed only |
202
+ | q | Quit |
203
+
181
204
 
182
205
  ## Requirements
183
206
 
184
- - Ruby 3.2+
207
+ - Ruby 3.3+
185
208
  - A terminal (TTY)
186
209
  - RSpec in your project (the gem invokes `bundle exec rspec` or `rspec` via the CLI)
187
210
 
188
211
  ## License
189
212
 
190
- MIT
213
+ MIT
data/lib/red_dot/app.rb CHANGED
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'set'
4
3
  require 'bubbletea'
5
4
  require 'lipgloss'
6
5
 
7
6
  module RedDot
7
+ # One row in the file/example list. { type: :file | :example, path, line_number, full_description }.
8
8
  DisplayRow = Struct.new(:type, :path, :line_number, :full_description, keyword_init: true) do
9
9
  def file_row?
10
10
  type == :file
@@ -14,6 +14,7 @@ module RedDot
14
14
  type == :example
15
15
  end
16
16
 
17
+ # Path to run: path for file, path:line for example.
17
18
  def runnable_path
18
19
  example_row? ? "#{path}:#{line_number}" : path
19
20
  end
@@ -23,6 +24,7 @@ module RedDot
23
24
  end
24
25
  end
25
26
 
27
+ # Message sent when an RSpec run has started. Carries pid, stdout IO, JSON path, optional component_root.
26
28
  class RspecStartedMessage < Bubbletea::Message
27
29
  attr_reader :pid, :stdout_io, :json_path, :component_root
28
30
 
@@ -36,6 +38,7 @@ module RedDot
36
38
 
37
39
  class TickMessage < Bubbletea::Message; end
38
40
 
41
+ # Bubbletea model: TUI for browsing and running RSpec.
39
42
  class App
40
43
  include Bubbletea::Model
41
44
 
@@ -43,6 +46,8 @@ module RedDot
43
46
  OPTIONS_BAR_HEIGHT = 5
44
47
  STATUS_HEIGHT = 1
45
48
 
49
+ # @param working_dir [String] project root
50
+ # @param option_overrides [Hash] merged into loaded config (e.g. :tags, :format, :fail_fast)
46
51
  def initialize(working_dir: Dir.pwd, option_overrides: {})
47
52
  @working_dir = File.expand_path(working_dir)
48
53
  @discovery = SpecDiscovery.new(working_dir: @working_dir)
@@ -70,7 +75,7 @@ module RedDot
70
75
  @options_cursor = 0
71
76
  @options_editing = nil
72
77
  @options_edit_buffer = ''
73
- @options_field_keys = %i[line_number seed format fail_fast tags_str out_path example_filter editor]
78
+ @options_field_keys = %i[line_number seed format fail_fast full_output tags_str out_path example_filter editor]
74
79
  @results_cursor = 0
75
80
  @file_list_scroll_offset = 0
76
81
  @results_scroll_offset = 0
@@ -86,10 +91,12 @@ module RedDot
86
91
  setup_styles
87
92
  end
88
93
 
94
+ # @return [Array<(self, nil)>]
89
95
  def init
90
96
  [self, nil]
91
97
  end
92
98
 
99
+ # Merges overrides into @options (tags, format, out_path, fail_fast, seed, editor, etc.).
93
100
  def apply_option_overrides(overrides)
94
101
  return if overrides.nil? || overrides.empty?
95
102
 
@@ -104,12 +111,14 @@ module RedDot
104
111
  @options[:example_filter] = o[:example_filter].to_s if o.key?(:example_filter)
105
112
  @options[:line_number] = o[:line_number].to_s if o.key?(:line_number)
106
113
  @options[:fail_fast] = o[:fail_fast] ? true : false if o.key?(:fail_fast)
114
+ @options[:full_output] = o[:full_output] ? true : false if o.key?(:full_output)
107
115
  @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
116
+ return unless o.key?(:editor) && RedDot::Config::VALID_EDITORS.include?(o[:editor].to_s.strip.downcase)
117
+
118
+ @options[:editor] = o[:editor].to_s.strip.downcase
111
119
  end
112
120
 
121
+ # Handles WindowSizeMessage, RspecStartedMessage, TickMessage, KeyMessage, MouseMessage. Returns [self, nil] or [self, cmd].
113
122
  def update(message)
114
123
  case message
115
124
  when Bubbletea::WindowSizeMessage
@@ -140,35 +149,11 @@ module RedDot
140
149
  read_run_output
141
150
  pid_done = Process.wait(@run_pid, Process::WNOHANG) rescue nil
142
151
  if pid_done
152
+ drain_run_output
143
153
  @run_stdout&.close
144
154
  @run_stdout = nil
145
155
  @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
156
+ after_run_process_exits
172
157
  else
173
158
  [self, schedule_tick]
174
159
  end
@@ -177,11 +162,14 @@ module RedDot
177
162
  end
178
163
  when Bubbletea::KeyMessage
179
164
  handle_key(message)
165
+ when Bubbletea::MouseMessage
166
+ handle_mouse(message)
180
167
  else
181
168
  [self, nil]
182
169
  end
183
170
  end
184
171
 
172
+ # Full rendered TUI string (options bar + main panels + status).
185
173
  def view
186
174
  content_h = [@height - STATUS_HEIGHT - OPTIONS_BAR_HEIGHT, 5].max
187
175
  left_w = [(LEFT_PANEL_RATIO * @width).floor, 24].max
@@ -235,26 +223,40 @@ module RedDot
235
223
  Bubbletea.tick(0.05) { TickMessage.new }
236
224
  end
237
225
 
226
+ # @param digit [Integer] 1, 2, or 3 — same semantics as pressing that key for panel focus.
227
+ def apply_panel_focus_digit(digit)
228
+ case digit
229
+ when 1
230
+ @options_focus = true
231
+ @screen = :file_list
232
+ when 2
233
+ @options_focus = false
234
+ @screen = :file_list
235
+ when 3
236
+ @options_focus = false
237
+ if @run_pid
238
+ @screen = :running
239
+ elsif @last_result
240
+ @screen = :results
241
+ end
242
+ end
243
+ end
244
+
238
245
  def handle_key(message)
239
246
  return handle_input_prompt_key(message) if @input_prompt
240
- return handle_find_key(message) if @find_buffer
241
247
 
242
248
  key = message.to_s
249
+ if @screen == :running && @run_pid && ['q', 'ctrl+c'].include?(key)
250
+ kill_run
251
+ @screen = :file_list
252
+ return [self, nil]
253
+ end
254
+ return handle_find_key(message) if @find_buffer && @screen == :file_list
255
+
243
256
  unless @options_editing
244
257
  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
+ when '1', '2', '3'
259
+ apply_panel_focus_digit(key.to_i)
258
260
  return [self, nil]
259
261
  end
260
262
  end
@@ -270,11 +272,7 @@ module RedDot
270
272
  end
271
273
  [self, nil]
272
274
  when :running
273
- if ['q', 'ctrl+c'].include?(key)
274
- kill_run
275
- @screen = :file_list
276
- end
277
- [self, nil]
275
+ handle_running_key(key)
278
276
  when :results
279
277
  handle_results_key(key)
280
278
  else
@@ -282,6 +280,34 @@ module RedDot
282
280
  end
283
281
  end
284
282
 
283
+ def handle_mouse(message)
284
+ return [self, nil] unless message.wheel?
285
+
286
+ content_h = [@height - STATUS_HEIGHT - OPTIONS_BAR_HEIGHT, 5].max
287
+ left_w = [(LEFT_PANEL_RATIO * @width).floor, 24].max
288
+ in_spec_list = message.x < left_w && message.y >= OPTIONS_BAR_HEIGHT && message.y < @height - STATUS_HEIGHT
289
+ return [self, nil] unless in_spec_list
290
+
291
+ list = display_rows
292
+ visible_list_height = file_list_visible_height(content_h)
293
+ max_scroll = [list.size - visible_list_height, 0].max
294
+ return [self, nil] if max_scroll <= 0
295
+
296
+ case message.button
297
+ when Bubbletea::MouseMessage::BUTTON_WHEEL_UP
298
+ @cursor = [[@cursor - 1, 0].max, list.size - 1].min
299
+ @file_list_scroll_offset = [@file_list_scroll_offset - 1, 0].max
300
+ @file_list_scroll_offset = @cursor if @cursor < @file_list_scroll_offset
301
+ when Bubbletea::MouseMessage::BUTTON_WHEEL_DOWN
302
+ @cursor = [[@cursor + 1, list.size - 1].min, 0].max
303
+ @file_list_scroll_offset = [@file_list_scroll_offset + 1, max_scroll].min
304
+ if @cursor >= @file_list_scroll_offset + visible_list_height
305
+ @file_list_scroll_offset = @cursor - visible_list_height + 1
306
+ end
307
+ end
308
+ [self, nil]
309
+ end
310
+
285
311
  def file_list_visible_height(content_h)
286
312
  header_count = 2
287
313
  header_count += 1 if @find_buffer
@@ -364,10 +390,8 @@ module RedDot
364
390
  list = display_rows
365
391
  @cursor = [@cursor, list.size - 1].min
366
392
  [self, nil]
367
- when ' ', 'space'
368
- if row&.file_row?
369
- @selected[row.path] = !@selected[row.path]
370
- end
393
+ when 'ctrl+t'
394
+ @selected[row.path] = !@selected[row.path] if row&.file_row?
371
395
  [self, nil]
372
396
  when 'enter'
373
397
  run_specs(paths_for_run)
@@ -422,6 +446,7 @@ module RedDot
422
446
  if message.respond_to?(:enter?) && message.enter?
423
447
  row = list[@cursor]
424
448
  return run_specs([row.runnable_path]) if row
449
+
425
450
  return [self, nil]
426
451
  end
427
452
  key = message.to_s
@@ -435,11 +460,11 @@ module RedDot
435
460
  visible_list_height = file_list_visible_height(content_h)
436
461
  max_scroll = [list.size - visible_list_height, 0].max
437
462
  case key
438
- when 'up', 'k'
463
+ when 'up'
439
464
  max_idx = [list.size - 1, 0].max
440
465
  @cursor = [[@cursor - 1, 0].max, max_idx].min
441
466
  return [self, nil]
442
- when 'down', 'j'
467
+ when 'down'
443
468
  max_idx = [list.size - 1, 0].max
444
469
  @cursor = [[@cursor + 1, max_idx].min, 0].max
445
470
  return [self, nil]
@@ -451,16 +476,26 @@ module RedDot
451
476
  @cursor = [@cursor + visible_list_height, [list.size - 1, 0].max].min
452
477
  @file_list_scroll_offset = [@file_list_scroll_offset + visible_list_height, max_scroll].min
453
478
  return [self, nil]
454
- when 'home', 'g'
479
+ when 'home'
455
480
  @cursor = 0
456
481
  @file_list_scroll_offset = 0
457
482
  return [self, nil]
458
- when 'end', 'G'
483
+ when 'end'
459
484
  @cursor = [list.size - 1, 0].max
460
485
  @file_list_scroll_offset = max_scroll
461
486
  return [self, nil]
487
+ when 'ctrl+t'
488
+ row = list[@cursor]
489
+ @selected[row.path] = !@selected[row.path] if row&.file_row?
490
+ return [self, nil]
462
491
  end
463
- if message.respond_to?(:char) && (c = message.char) && c.is_a?(String) && !c.empty?
492
+ c = nil
493
+ if message.respond_to?(:char) && (ch = message.char) && ch.is_a?(String) && !ch.empty?
494
+ c = ch
495
+ elsif key.length == 1 && key.match?(/\S/)
496
+ c = key
497
+ end
498
+ if c
464
499
  @find_buffer += c
465
500
  list = display_rows
466
501
  @cursor = 0 if list.any? && (@cursor >= list.size || @cursor.negative?)
@@ -513,6 +548,9 @@ module RedDot
513
548
  case key
514
549
  when 'q', 'ctrl+c'
515
550
  [self, Bubbletea.quit]
551
+ when 'R'
552
+ refresh_spec_list
553
+ [self, nil]
516
554
  when 'esc', 'b'
517
555
  @options_focus = false
518
556
  [self, nil]
@@ -526,9 +564,12 @@ module RedDot
526
564
  [self, nil]
527
565
  when 'enter'
528
566
  field = @options_field_keys[@options_cursor]
529
- if field == :fail_fast
567
+ case field
568
+ when :fail_fast
530
569
  @options[:fail_fast] = !@options[:fail_fast]
531
- elsif field == :editor
570
+ when :full_output
571
+ @options[:full_output] = !@options[:full_output]
572
+ when :editor
532
573
  idx = RedDot::Config::VALID_EDITORS.index(@options[:editor].to_s) || 0
533
574
  @options[:editor] = RedDot::Config::VALID_EDITORS[(idx + 1) % RedDot::Config::VALID_EDITORS.size]
534
575
  else
@@ -564,9 +605,62 @@ module RedDot
564
605
  [self, nil]
565
606
  end
566
607
 
608
+ def run_output_visible_height(content_h)
609
+ [content_h - 4, 1].max
610
+ end
611
+
612
+ def handle_running_key(key)
613
+ content_h = [@height - STATUS_HEIGHT - OPTIONS_BAR_HEIGHT, 5].max
614
+ run_visible = run_output_visible_height(content_h)
615
+ max_scroll = [@run_output.size - run_visible, 0].max
616
+ case key
617
+ when 'q', 'ctrl+c'
618
+ kill_run
619
+ @screen = :file_list
620
+ when '2'
621
+ @screen = :file_list
622
+ when 'up', 'k'
623
+ @run_output_scroll = [@run_output_scroll - 1, 0].max
624
+ when 'down', 'j'
625
+ @run_output_scroll = [@run_output_scroll + 1, max_scroll].min
626
+ when 'pgup', 'ctrl+u'
627
+ @run_output_scroll = [@run_output_scroll - run_visible, 0].max
628
+ when 'pgdown', 'ctrl+d'
629
+ @run_output_scroll = [@run_output_scroll + run_visible, max_scroll].min
630
+ when 'home', 'g'
631
+ @run_output_scroll = 0
632
+ when 'end', 'G'
633
+ @run_output_scroll = max_scroll
634
+ end
635
+ [self, nil]
636
+ end
637
+
567
638
  def handle_results_key(key)
568
639
  failed = @last_result&.failed_examples || []
569
640
  content_h = [@height - STATUS_HEIGHT - OPTIONS_BAR_HEIGHT, 5].max
641
+ if @options[:full_output]
642
+ max_scroll = [@results_total_lines - content_h, 0].max
643
+ case key
644
+ when 'up', 'k'
645
+ @results_scroll_offset = [@results_scroll_offset - 1, 0].max
646
+ return [self, nil]
647
+ when 'down', 'j'
648
+ @results_scroll_offset = [@results_scroll_offset + 1, max_scroll].min
649
+ return [self, nil]
650
+ when 'pgup', 'ctrl+u'
651
+ @results_scroll_offset = [@results_scroll_offset - content_h, 0].max
652
+ return [self, nil]
653
+ when 'pgdown', 'ctrl+d'
654
+ @results_scroll_offset = [@results_scroll_offset + content_h, max_scroll].min
655
+ return [self, nil]
656
+ when 'home', 'g'
657
+ @results_scroll_offset = 0
658
+ return [self, nil]
659
+ when 'end', 'G'
660
+ @results_scroll_offset = max_scroll
661
+ return [self, nil]
662
+ end
663
+ end
570
664
  case key
571
665
  when 'q', 'ctrl+c'
572
666
  [self, Bubbletea.quit]
@@ -628,10 +722,10 @@ module RedDot
628
722
  return paths if query.to_s.strip.empty?
629
723
 
630
724
  q = query.to_s.downcase
631
- paths.select { |path| fuzzy_match_string(path, q) }
725
+ paths.select { |path| fuzzy_match_string?(path, q) }
632
726
  end
633
727
 
634
- def fuzzy_match_string(str, query)
728
+ def fuzzy_match_string?(str, query)
635
729
  return true if query.to_s.strip.empty?
636
730
 
637
731
  q = query.to_s.downcase
@@ -687,15 +781,13 @@ module RedDot
687
781
 
688
782
  rows = []
689
783
  flat_spec_list.each do |path|
690
- file_matches = fuzzy_match_string(path, q)
784
+ file_matches = fuzzy_match_string?(path, q)
691
785
  examples = cached_examples_for(path)
692
- example_matches = examples.select { |ex| fuzzy_match_string(ex.full_description.to_s, q) }
786
+ example_matches = examples.select { |ex| fuzzy_match_string?(ex.full_description.to_s, q) }
693
787
  show_file = file_matches || example_matches.any?
694
788
  next unless show_file
695
789
 
696
- if example_matches.any?
697
- @expanded_files.add(path)
698
- end
790
+ @expanded_files.add(path) if example_matches.any?
699
791
  rows << DisplayRow.new(type: :file, path: path, line_number: nil, full_description: nil)
700
792
  examples_to_show = file_matches ? examples : example_matches
701
793
  examples_to_show.each do |ex|
@@ -764,7 +856,8 @@ module RedDot
764
856
  out_path: out_path, example_filter: example_filter, fail_fast: @options[:fail_fast], seed: seed }
765
857
  proc = lambda do
766
858
  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])
859
+ RspecStartedMessage.new(pid: data[:pid], stdout_io: data[:stdout_io], json_path: data[:json_path],
860
+ component_root: g[:component_root])
768
861
  end
769
862
  return [self, proc]
770
863
  end
@@ -775,7 +868,8 @@ module RedDot
775
868
  out_path: out_path, example_filter: example_filter, fail_fast: @options[:fail_fast], seed: seed }
776
869
  proc = lambda do
777
870
  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])
871
+ RspecStartedMessage.new(pid: data[:pid], stdout_io: data[:stdout_io], json_path: data[:json_path],
872
+ component_root: first[:component_root])
779
873
  end
780
874
  [self, proc]
781
875
  end
@@ -834,18 +928,83 @@ module RedDot
834
928
  def read_run_output
835
929
  return unless @run_stdout
836
930
 
931
+ content_h = [@height - STATUS_HEIGHT - OPTIONS_BAR_HEIGHT, 5].max
932
+ run_visible = run_output_visible_height(content_h)
933
+ was_at_bottom = @run_output.size <= run_visible || @run_output_scroll >= [@run_output.size - run_visible, 0].max
934
+
837
935
  @run_stdout.read_nonblock(4096).each_line do |line|
838
936
  @run_output << line.chomp
839
937
  end
938
+
939
+ return unless was_at_bottom
940
+
941
+ max_scroll = [@run_output.size - run_visible, 0].max
942
+ @run_output_scroll = max_scroll
840
943
  rescue IO::WaitReadable, EOFError
841
944
  # no more data
842
945
  end
843
946
 
947
+ def drain_run_output
948
+ return unless @run_stdout
949
+
950
+ content_h = [@height - STATUS_HEIGHT - OPTIONS_BAR_HEIGHT, 5].max
951
+ run_visible = run_output_visible_height(content_h)
952
+ was_at_bottom = @run_output.size <= run_visible || @run_output_scroll >= [@run_output.size - run_visible, 0].max
953
+
954
+ loop do
955
+ chunk = @run_stdout.read(4096)
956
+ break if chunk.nil? || chunk.empty?
957
+
958
+ chunk.each_line do |line|
959
+ @run_output << line.chomp
960
+ end
961
+ end
962
+
963
+ return unless was_at_bottom
964
+
965
+ max_scroll = [@run_output.size - run_visible, 0].max
966
+ @run_output_scroll = max_scroll
967
+ rescue IOError, Errno::EPIPE
968
+ # pipe closed or broken
969
+ end
970
+
971
+ def after_run_process_exits
972
+ return spawn_next_queued_rspec if @run_queue&.any?
973
+
974
+ @run_queue = nil
975
+ @last_result = RspecResult.from_json_path(@run_json_path)
976
+ raw_failures = @last_result&.failure_locations || []
977
+ @run_failed_paths = normalize_failure_paths(raw_failures)
978
+ @screen = :results
979
+ @results_cursor = 0
980
+ @results_scroll_offset = 0
981
+ [self, nil]
982
+ end
983
+
984
+ def spawn_next_queued_rspec
985
+ next_group = @run_queue.shift
986
+ opts = { working_dir: next_group[:run_cwd], paths: next_group[:rspec_paths],
987
+ tags: @options[:tags].empty? ? parse_tags(@options[:tags_str]) : @options[:tags],
988
+ format: @options[:format], out_path: @options[:out_path].to_s.strip,
989
+ example_filter: @options[:example_filter].to_s.strip, fail_fast: @options[:fail_fast],
990
+ seed: @options[:seed].to_s.strip }
991
+ opts[:out_path] = nil if opts[:out_path].to_s.empty?
992
+ opts[:example_filter] = nil if opts[:example_filter].to_s.empty?
993
+ opts[:seed] = nil if opts[:seed].to_s.empty?
994
+ data = RspecRunner.spawn(**opts)
995
+ @run_pid = data[:pid]
996
+ @run_stdout = data[:stdout_io]
997
+ @run_json_path = data[:json_path]
998
+ @last_run_component_root = next_group[:component_root]
999
+ [self, schedule_tick]
1000
+ end
1001
+
844
1002
  def kill_run
845
1003
  return unless @run_pid
846
1004
 
847
1005
  Process.kill('TERM', @run_pid) rescue nil
848
1006
  Process.wait(@run_pid) rescue nil
1007
+ drain_run_output
849
1008
  @run_stdout&.close
850
1009
  @run_stdout = nil
851
1010
  @run_pid = nil
@@ -938,7 +1097,12 @@ module RedDot
938
1097
  header_lines << ''
939
1098
  list = display_rows
940
1099
  if list.empty?
941
- header_lines << (@find_buffer.to_s.strip.empty? ? @muted_style.render(" #{@discovery.empty_state_message}") : @muted_style.render(' No matches'))
1100
+ empty_line = if @find_buffer.to_s.strip.empty?
1101
+ @muted_style.render(" #{@discovery.empty_state_message}")
1102
+ else
1103
+ @muted_style.render(' No matches')
1104
+ end
1105
+ header_lines << empty_line
942
1106
  return header_lines
943
1107
  end
944
1108
  @cursor = [@cursor, list.size - 1].min
@@ -971,7 +1135,7 @@ module RedDot
971
1135
 
972
1136
  case @screen
973
1137
  when :indexing then build_indexing_lines
974
- when :running then build_running_lines
1138
+ when :running then build_running_lines(content_h)
975
1139
  when :results then build_results_lines(content_h)
976
1140
  else build_idle_lines
977
1141
  end
@@ -1012,7 +1176,7 @@ module RedDot
1012
1176
  [
1013
1177
  (focused_panel == 3 ? @active_title_style.render(title) : @inactive_title_style.render(title)),
1014
1178
  '',
1015
- @muted_style.render('Select files (Space), then Enter or s to run.'),
1179
+ @muted_style.render('Select files (Ctrl+T), then Enter or s to run.'),
1016
1180
  @muted_style.render('a = run all f = run failed (after failures)'),
1017
1181
  '',
1018
1182
  (@last_result ? " Last: #{@last_result.summary_line}" : '')
@@ -1022,7 +1186,8 @@ module RedDot
1022
1186
  def build_options_bar_lines
1023
1187
  labels = {
1024
1188
  tags_str: 'Tags', format: 'Format', out_path: 'Output',
1025
- example_filter: 'Example', line_number: 'Line', fail_fast: 'Fail-fast', seed: 'Seed', editor: 'Editor'
1189
+ example_filter: 'Example', line_number: 'Line', fail_fast: 'Fail-fast', full_output: 'Full output',
1190
+ seed: 'Seed', editor: 'Editor'
1026
1191
  }
1027
1192
  max_val = 14
1028
1193
  segments = @options_field_keys.each_with_index.map do |key, i|
@@ -1030,6 +1195,8 @@ module RedDot
1030
1195
  val = "#{@options_edit_buffer}_"
1031
1196
  elsif key == :fail_fast
1032
1197
  val = @options[:fail_fast].to_s
1198
+ elsif key == :full_output
1199
+ val = @options[:full_output].to_s
1033
1200
  elsif key == :editor
1034
1201
  val = @options[:editor].to_s
1035
1202
  elsif %i[line_number seed].include?(key)
@@ -1050,18 +1217,50 @@ module RedDot
1050
1217
  [title, '', options_row, help_row, '']
1051
1218
  end
1052
1219
 
1053
- def build_running_lines
1220
+ def build_running_lines(content_h)
1221
+ run_visible = run_output_visible_height(content_h)
1222
+ max_scroll = [@run_output.size - run_visible, 0].max
1223
+ @run_output_scroll = [@run_output_scroll, max_scroll].min
1224
+ window = @run_output[@run_output_scroll, run_visible] || []
1054
1225
  title = ' 3 Running RSpec '
1055
1226
  [
1056
1227
  (focused_panel == 3 ? @active_title_style.render(title) : @inactive_title_style.render(title)),
1057
1228
  '',
1058
- *@run_output.last(50).map { |l| " #{l}" },
1229
+ *window.map { |l| " #{l}" },
1059
1230
  '',
1060
- @help_style.render(' q: quit (kill run)')
1231
+ @help_style.render(' j/k: scroll PgUp/PgDn g/G: top/bottom 2: file list q: kill run')
1061
1232
  ]
1062
1233
  end
1063
1234
 
1235
+ def build_results_lines_full_output(content_h)
1236
+ title = ' 3 Results '
1237
+ lines = [(focused_panel == 3 ? @active_title_style.render(title) : @inactive_title_style.render(title)), '']
1238
+ @results_failed_line_indices = []
1239
+ lines << if @last_result
1240
+ " #{@last_result.summary_line}"
1241
+ else
1242
+ @muted_style.render(' No result data.')
1243
+ end
1244
+ lines << ''
1245
+ if @run_output.any?
1246
+ lines << @muted_style.render(' Full output:')
1247
+ @run_output.each { |out_line| lines << " #{out_line}" }
1248
+ else
1249
+ lines << @muted_style.render(' (No captured stdout from this run.)')
1250
+ end
1251
+ lines << ''
1252
+ lines << @help_style.render(
1253
+ ' j/k: scroll PgUp/PgDn g/G: top/bottom e: run O: open b: back r: rerun f: failed q: quit'
1254
+ )
1255
+ @results_total_lines = lines.size
1256
+ max_scroll = [@results_total_lines - content_h, 0].max
1257
+ @results_scroll_offset = [[@results_scroll_offset, max_scroll].min, 0].max
1258
+ lines[@results_scroll_offset, content_h] || []
1259
+ end
1260
+
1064
1261
  def build_results_lines(content_h)
1262
+ return build_results_lines_full_output(content_h) if @options[:full_output]
1263
+
1065
1264
  title = ' 3 Results '
1066
1265
  lines = [(focused_panel == 3 ? @active_title_style.render(title) : @inactive_title_style.render(title)), '']
1067
1266
  @results_failed_line_indices = []
@@ -1076,6 +1275,12 @@ module RedDot
1076
1275
  lines << @muted_style.render(" #{metrics.join(' | ')}")
1077
1276
  if r.errors_outside_of_examples.positive?
1078
1277
  lines << @warn_style.render(" #{r.errors_outside_of_examples} error(s) outside examples (e.g. load/hook failures)")
1278
+ if @run_output.any?
1279
+ lines << ''
1280
+ lines << @muted_style.render(' Output:')
1281
+ @run_output.each { |out_line| lines << " #{out_line}" }
1282
+ lines << ''
1283
+ end
1079
1284
  end
1080
1285
  lines << ''
1081
1286
  if r.examples_with_run_time.any?
@@ -1122,7 +1327,9 @@ module RedDot
1122
1327
  lines << @muted_style.render(' No result data.')
1123
1328
  end
1124
1329
  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')
1330
+ results_help = ' j/k: move PgUp/PgDn: scroll g/G: top/bottom e: run O: open b: back ' \
1331
+ 'r: rerun f: failed q: quit'
1332
+ lines << @help_style.render(results_help)
1126
1333
  @results_total_lines = lines.size
1127
1334
  max_scroll = [@results_total_lines - content_h, 0].max
1128
1335
  @results_scroll_offset = [[@results_scroll_offset, max_scroll].min, 0].max
@@ -1145,18 +1352,26 @@ module RedDot
1145
1352
 
1146
1353
  def status_line
1147
1354
  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
1355
+
1356
+ if @find_buffer
1357
+ return ' ↑/↓: move PgUp/PgDn: scroll Home/End: top/bottom Ctrl+T: toggle Enter: run ' \
1358
+ 'Esc or Ctrl+B: exit find '
1359
+ end
1149
1360
 
1150
1361
  case @screen
1151
1362
  when :file_list
1152
1363
  if @options_focus
1153
- ' 1/2/3: panels j/k: move Enter: edit b: back q: quit '
1364
+ ' 1/2/3: panels j/k: move Enter: edit R: refresh b: back q: quit '
1154
1365
  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 '
1366
+ ' 1/2/3: panels /: find I: index j/k: move PgUp/PgDn g/G: top/bottom ]/[: expand ' \
1367
+ 'Ctrl+T: select a: all s: selected e: run O: open f: failed o: options R: refresh q: quit '
1156
1368
  end
1157
1369
  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 '
1370
+ when :running then ' 1/2/3: panels j/k PgUp/PgDn g/G: scroll output 2: file list q: kill run '
1371
+ when :results
1372
+ move_or_scroll = @options[:full_output] ? 'scroll' : 'move'
1373
+ " 1/2/3: panels j/k: #{move_or_scroll} PgUp/PgDn: scroll g/G: top/bottom e: run O: open b: back " \
1374
+ 'r: rerun f: failed q: quit '
1160
1375
  else ' 1/2/3: panels q: quit '
1161
1376
  end
1162
1377
  end
data/lib/red_dot/cli.rb CHANGED
@@ -1,7 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RedDot
4
+ # Parses argv into working_dir and option_overrides (format, tags, out_path, example_filter, line_number,
5
+ # fail_fast, full_output).
4
6
  class Cli
7
+ # @return [Hash] { working_dir:, option_overrides: }
5
8
  def self.parse(argv = ARGV)
6
9
  args = argv.dup
7
10
  overrides = {}
@@ -28,6 +31,9 @@ module RedDot
28
31
  when '--fail-fast'
29
32
  overrides[:fail_fast] = true
30
33
  i += 1
34
+ when '--full-output'
35
+ overrides[:full_output] = true
36
+ i += 1
31
37
  when /^[^-]/
32
38
  dir = arg
33
39
  i += 1
@@ -3,6 +3,7 @@
3
3
  require 'yaml'
4
4
 
5
5
  module RedDot
6
+ # Loads and merges options from user and project config.
6
7
  class Config
7
8
  VALID_EDITORS = %w[vscode cursor textmate].freeze
8
9
 
@@ -14,19 +15,23 @@ module RedDot
14
15
  example_filter: '',
15
16
  line_number: '',
16
17
  fail_fast: false,
18
+ full_output: false,
17
19
  seed: '',
18
20
  editor: 'cursor'
19
21
  }.freeze
20
22
 
23
+ # @return [String] XDG/config or ~/.config/red_dot/config.yml
21
24
  def self.user_config_path
22
25
  base = ENV.fetch('XDG_CONFIG_HOME', File.join(Dir.home, '.config'))
23
26
  File.join(base, 'red_dot', 'config.yml')
24
27
  end
25
28
 
29
+ # @return [String] working_dir/.red_dot.yml
26
30
  def self.project_config_path(working_dir)
27
31
  File.join(File.expand_path(working_dir), '.red_dot.yml')
28
32
  end
29
33
 
34
+ # Merges user + project config. @return [Hash] options (tags, format, out_path, etc.)
30
35
  def self.load(working_dir: Dir.pwd)
31
36
  opts = DEFAULT_OPTIONS.dup
32
37
  project_path = project_config_path(working_dir)
@@ -37,6 +42,7 @@ module RedDot
37
42
  opts
38
43
  end
39
44
 
45
+ # Merges YAML at path into opts. @return [Hash]
40
46
  def self.merge_file(opts, path)
41
47
  return opts unless path && File.readable?(path)
42
48
 
@@ -53,6 +59,7 @@ module RedDot
53
59
  opts[:example_filter] = raw['example_filter'].to_s if raw.key?('example_filter')
54
60
  opts[:line_number] = raw['line_number'].to_s if raw.key?('line_number')
55
61
  opts[:fail_fast] = raw['fail_fast'] ? true : false if raw.key?('fail_fast')
62
+ opts[:full_output] = raw['full_output'] ? true : false if raw.key?('full_output')
56
63
  opts[:seed] = raw['seed'].to_s.strip if raw.key?('seed')
57
64
  if raw.key?('editor')
58
65
  val = raw['editor'].to_s.strip.downcase
@@ -71,6 +78,7 @@ module RedDot
71
78
  end
72
79
  end
73
80
 
81
+ # @return [Array<String>, nil] component root dirs from .red_dot.yml, or nil
74
82
  def self.component_roots(working_dir: Dir.pwd)
75
83
  path = project_config_path(working_dir)
76
84
  return nil unless File.readable?(path)
@@ -6,11 +6,13 @@ require 'json'
6
6
  require 'tmpdir'
7
7
 
8
8
  module RedDot
9
+ # Discovers and caches example names (e.g. for find). ExampleInfo: path, line_number, full_description.
9
10
  class ExampleDiscovery
10
11
  ExampleInfo = Struct.new(:path, :line_number, :full_description, keyword_init: true)
11
12
 
12
13
  CACHE_DIR = File.join(Dir.tmpdir, 'red_dot').freeze
13
14
 
15
+ # @return [String] path to JSON cache for working_dir
14
16
  def self.cache_file_path(working_dir)
15
17
  hash = Digest::SHA256.hexdigest(File.expand_path(working_dir))[0, 16]
16
18
  File.join(CACHE_DIR, "cache_#{hash}.json")
@@ -29,6 +31,7 @@ module RedDot
29
31
  {}
30
32
  end
31
33
 
34
+ # @return [Array<ExampleInfo>, nil] cached examples if mtime matches, else nil
32
35
  def self.get_cached_examples(working_dir, path)
33
36
  full_path = File.join(working_dir, path)
34
37
  return nil unless File.exist?(full_path)
@@ -63,6 +66,7 @@ module RedDot
63
66
  File.write(cache_file_path(working_dir), JSON.generate({ 'entries' => entries }))
64
67
  end
65
68
 
69
+ # Runs rspec --dry-run, parses JSON, caches and returns ExampleInfo list.
66
70
  def self.discover(working_dir:, path:)
67
71
  json_path = RspecRunner.run_dry_run(working_dir: working_dir, paths: [path])
68
72
  return [] unless json_path && File.readable?(json_path)
@@ -3,6 +3,7 @@
3
3
  require 'json'
4
4
 
5
5
  module RedDot
6
+ # Parsed RSpec JSON result. Example: description, status, file_path, line_number, run_time, etc.
6
7
  class RspecResult
7
8
  Example = Struct.new(
8
9
  :description, :full_description, :status, :file_path, :line_number, :exception_message,
@@ -19,6 +20,7 @@ module RedDot
19
20
  @seed = seed
20
21
  end
21
22
 
23
+ # @return [RspecResult, nil] parsed from JSON file
22
24
  def self.from_json_path(path)
23
25
  return nil unless path && File.readable?(path)
24
26
 
@@ -3,6 +3,7 @@
3
3
  require 'tempfile'
4
4
 
5
5
  module RedDot
6
+ # Spawns RSpec and returns pid, stdout pipe, and JSON path. Uses bundle exec if Gemfile present.
6
7
  class RspecRunner
7
8
  def self.spawn(working_dir:, paths:, tags: [], format: 'progress', out_path: nil,
8
9
  example_filter: nil, fail_fast: false, seed: nil)
@@ -35,6 +36,7 @@ module RedDot
35
36
  { pid: pid, stdout_io: stdout_r, json_path: json_path }
36
37
  end
37
38
 
39
+ # @return [Array<String>] argv for rspec (paths, --format json --out path, tags, etc.)
38
40
  def self.build_argv(paths:, json_path:, format: 'progress', out_path: nil, tags: [],
39
41
  example_filter: nil, fail_fast: false, seed: nil)
40
42
  argv = paths.dup
@@ -53,6 +55,7 @@ module RedDot
53
55
  File.file?(gemfile) ? %w[bundle exec rspec] : ['rspec']
54
56
  end
55
57
 
58
+ # Runs rspec --dry-run --format json --out path; returns json path.
56
59
  def self.run_dry_run(working_dir:, paths:)
57
60
  json_file = Tempfile.new(['red_dot_list', '.json'])
58
61
  json_path = json_file.path
@@ -3,15 +3,18 @@
3
3
  require 'pathname'
4
4
 
5
5
  module RedDot
6
+ # Finds spec files and run context (single project or umbrella with components/).
6
7
  class SpecDiscovery
7
8
  DEFAULT_SPEC_DIR = 'spec'
8
9
  DEFAULT_PATTERN = '**/*_spec.rb'
9
10
  COMPONENTS_DIR = 'components'
10
11
 
12
+ # @param working_dir [String] project root
11
13
  def initialize(working_dir: Dir.pwd)
12
14
  @working_dir = File.expand_path(working_dir)
13
15
  end
14
16
 
17
+ # True if working_dir/components/ exists.
15
18
  def umbrella?
16
19
  components_dir = File.join(@working_dir, COMPONENTS_DIR)
17
20
  File.directory?(components_dir)
@@ -59,6 +62,7 @@ module RedDot
59
62
  read_default_path_from_rspec(@working_dir) || DEFAULT_SPEC_DIR
60
63
  end
61
64
 
65
+ # @return [Array<String>] relative paths to *_spec.rb files
62
66
  def discover
63
67
  if umbrella?
64
68
  discover_umbrella
@@ -72,6 +76,7 @@ module RedDot
72
76
  files.group_by { |f| File.dirname(f) }.transform_values(&:sort)
73
77
  end
74
78
 
79
+ # @return [Hash] { run_cwd:, rspec_path: } for running the given display path
75
80
  def run_context_for(display_path)
76
81
  if umbrella?
77
82
  run_context_umbrella(display_path)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RedDot
4
- VERSION = '0.1.0'
4
+ VERSION = '0.2.0'
5
5
  end
data/lib/red_dot.rb CHANGED
@@ -12,12 +12,15 @@ require_relative 'red_dot/app'
12
12
  module RedDot
13
13
  class Error < StandardError; end
14
14
 
15
+ # Runs the TUI. Requires TTY. Exits with 1 if not a TTY.
16
+ # @param working_dir [String] directory containing specs
17
+ # @param option_overrides [Hash] e.g. :tags, :format, :out_path, :fail_fast, :full_output, :seed, :editor
15
18
  def self.run(working_dir: Dir.pwd, option_overrides: {})
16
19
  unless $stdout.tty?
17
20
  warn 'Error: red_dot (rdot) requires a TTY. Run from a terminal.'
18
21
  exit 1
19
22
  end
20
23
  app = RedDot::App.new(working_dir: working_dir, option_overrides: option_overrides)
21
- Bubbletea.run(app, alt_screen: true)
24
+ Bubbletea.run(app, alt_screen: true, mouse_cell_motion: true)
22
25
  end
23
26
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: red_dot
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lovell McIlwain
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-03-04 00:00:00.000000000 Z
11
+ date: 2026-03-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bubbletea
@@ -38,9 +38,9 @@ dependencies:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
40
  version: '0.1'
41
- description: |-
42
- A lazy-like TUI for running RSpec tests: select files, set options (tags, format, output, seed), " \
43
- "run all/some/one, view results, and rerun.
41
+ description: 'A lazy-like TUI for running RSpec tests (similar to VSCode Test Explorer):
42
+ select files, set options (tags, format, output, seed), run all/some/one, view results,
43
+ and rerun.'
44
44
  email:
45
45
  - ''
46
46
  executables:
@@ -75,7 +75,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
75
75
  requirements:
76
76
  - - ">="
77
77
  - !ruby/object:Gem::Version
78
- version: 3.2.0
78
+ version: 3.3.0
79
79
  required_rubygems_version: !ruby/object:Gem::Requirement
80
80
  requirements:
81
81
  - - ">="