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 +4 -4
- data/README.md +95 -72
- data/lib/red_dot/app.rb +298 -83
- data/lib/red_dot/cli.rb +6 -0
- data/lib/red_dot/config.rb +8 -0
- data/lib/red_dot/example_discovery.rb +4 -0
- data/lib/red_dot/rspec_result.rb +2 -0
- data/lib/red_dot/rspec_runner.rb +3 -0
- data/lib/red_dot/spec_discovery.rb +5 -0
- data/lib/red_dot/version.rb +1 -1
- data/lib/red_dot.rb +4 -1
- metadata +6 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 428cfca728336aee1bdd374460f9ca755414745a01e122efe33fbb455c8e375f
|
|
4
|
+
data.tar.gz: a2260e440bf5eb4a1fcba63634df295e2a745cb10515f24c57766984a3a103ef
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
|
70
|
-
| `--
|
|
71
|
-
| `--
|
|
72
|
-
| `--
|
|
73
|
-
| `--
|
|
74
|
-
| `--
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
|
90
|
-
|
|
|
91
|
-
|
|
|
92
|
-
|
|
|
93
|
-
|
|
|
94
|
-
|
|
|
95
|
-
|
|
|
96
|
-
|
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
|
140
|
-
| **
|
|
141
|
-
| **
|
|
142
|
-
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
|
148
|
-
|
|
|
149
|
-
|
|
|
150
|
-
|
|
|
151
|
-
|
|
|
152
|
-
|
|
|
153
|
-
|
|
|
154
|
-
|
|
|
155
|
-
|
|
|
156
|
-
|
|
|
157
|
-
|
|
|
158
|
-
|
|
|
159
|
-
|
|
|
160
|
-
|
|
|
161
|
-
|
|
|
162
|
-
|
|
|
163
|
-
|
|
164
|
-
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
|
170
|
-
|
|
|
171
|
-
|
|
172
|
-
|
|
|
173
|
-
|
|
174
|
-
|
|
|
175
|
-
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
|
180
|
-
|
|
|
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.
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 '
|
|
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'
|
|
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'
|
|
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'
|
|
479
|
+
when 'home'
|
|
455
480
|
@cursor = 0
|
|
456
481
|
@file_list_scroll_offset = 0
|
|
457
482
|
return [self, nil]
|
|
458
|
-
when 'end'
|
|
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
|
-
|
|
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
|
-
|
|
567
|
+
case field
|
|
568
|
+
when :fail_fast
|
|
530
569
|
@options[:fail_fast] = !@options[:fail_fast]
|
|
531
|
-
|
|
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],
|
|
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],
|
|
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
|
-
|
|
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 (
|
|
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',
|
|
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
|
-
|
|
1229
|
+
*window.map { |l| " #{l}" },
|
|
1059
1230
|
'',
|
|
1060
|
-
@help_style.render(' q:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
data/lib/red_dot/config.rb
CHANGED
|
@@ -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)
|
data/lib/red_dot/rspec_result.rb
CHANGED
|
@@ -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
|
|
data/lib/red_dot/rspec_runner.rb
CHANGED
|
@@ -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)
|
data/lib/red_dot/version.rb
CHANGED
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.
|
|
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-
|
|
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
|
-
|
|
43
|
-
|
|
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.
|
|
78
|
+
version: 3.3.0
|
|
79
79
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
80
80
|
requirements:
|
|
81
81
|
- - ">="
|