rooibos 0.5.0 → 0.6.1
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/.builds/ruby-3.2.yml +9 -5
- data/.builds/ruby-3.3.yml +9 -5
- data/.builds/ruby-3.4.yml +9 -5
- data/.builds/ruby-4.0.0.yml +9 -5
- data/AGENTS.md +1 -1
- data/CHANGELOG.md +57 -0
- data/README.md +2 -2
- data/README.rdoc +374 -0
- data/REUSE.toml +5 -0
- data/Rakefile +1 -1
- data/doc/best_practices/forms_and_validation.md +20 -0
- data/doc/best_practices/http_workflows.md +20 -0
- data/doc/best_practices/index.md +26 -0
- data/doc/best_practices/lists_and_tables.md +20 -0
- data/doc/best_practices/modal_dialogs.md +20 -0
- data/doc/best_practices/no_stateful_widgets.md +184 -0
- data/doc/best_practices/orchestration.md +20 -0
- data/doc/best_practices/streaming_data.md +20 -0
- data/doc/contributors/design/commands_and_outlets.md +1 -1
- data/doc/contributors/documentation_plan.md +616 -0
- data/doc/contributors/documentation_stub_audit.md +112 -0
- data/doc/contributors/documentation_style.md +275 -0
- data/doc/contributors/e2e_pty.md +168 -0
- data/doc/contributors/specs/earliest_tutorial_steps_per_story.md +70 -0
- data/doc/contributors/specs/file_browser.md +789 -0
- data/doc/contributors/specs/file_browser_stories.md +774 -0
- data/doc/contributors/specs/tutorials_to_stories.rb +167 -0
- data/doc/contributors/todo/scrollbar.md +118 -0
- data/doc/contributors/tutorial_old/01_project_setup.md +20 -0
- data/doc/contributors/tutorial_old/02_hello_world.md +24 -0
- data/doc/contributors/tutorial_old/03_adding_state.md +26 -0
- data/doc/contributors/tutorial_old/06_organizing_your_code.md +20 -0
- data/doc/contributors/tutorial_old/07_your_first_command.md +21 -0
- data/doc/contributors/tutorial_old/08_the_preview_pane.md +20 -0
- data/doc/contributors/tutorial_old/09_loading_states.md +20 -0
- data/doc/contributors/tutorial_old/10_testing_your_app.md +20 -0
- data/doc/contributors/tutorial_old/11_polish_and_refine.md +20 -0
- data/doc/contributors/tutorial_old/12_going_further.md +20 -0
- data/doc/contributors/tutorial_old/index.md +20 -0
- data/doc/essentials/commands.md +20 -0
- data/doc/essentials/index.md +31 -0
- data/doc/essentials/messages.md +21 -0
- data/doc/essentials/models.md +21 -0
- data/doc/essentials/shortcuts.md +19 -0
- data/doc/essentials/the_elm_architecture.md +24 -0
- data/doc/essentials/the_runtime.md +21 -0
- data/doc/essentials/update_functions.md +20 -0
- data/doc/essentials/views.md +22 -0
- data/doc/getting_started/for_go_developers.md +16 -0
- data/doc/getting_started/for_python_developers.md +16 -0
- data/doc/getting_started/for_rails_developers.md +17 -0
- data/doc/getting_started/for_ratatui_ruby_developers.md +17 -0
- data/doc/getting_started/for_react_developers.md +17 -0
- data/doc/getting_started/index.md +52 -0
- data/doc/getting_started/install.md +20 -0
- data/doc/getting_started/quickstart.md +9 -45
- data/doc/getting_started/ruby_primer.md +19 -0
- data/doc/getting_started/why_rooibos.md +20 -0
- data/doc/index.md +79 -11
- data/doc/scaling_up/async_patterns.md +20 -0
- data/doc/scaling_up/command_composition.md +20 -0
- data/doc/scaling_up/custom_commands.md +21 -0
- data/doc/scaling_up/fractal_architecture.md +20 -0
- data/doc/scaling_up/index.md +30 -0
- data/doc/scaling_up/message_routing.md +20 -0
- data/doc/scaling_up/ractor_safety.md +20 -0
- data/doc/scaling_up/testing.md +21 -0
- data/doc/troubleshooting/common_errors.md +20 -0
- data/doc/troubleshooting/debugging.md +21 -0
- data/doc/troubleshooting/index.md +23 -0
- data/doc/troubleshooting/performance.md +20 -0
- data/doc/tutorial/01_project_setup.md +44 -0
- data/doc/tutorial/02_hello_world.md +45 -0
- data/doc/tutorial/03_static_file_list.md +44 -0
- data/doc/tutorial/04_arrow_navigation.md +47 -0
- data/doc/tutorial/05_real_files.md +45 -0
- data/doc/tutorial/06_safe_refactoring.md +21 -0
- data/doc/tutorial/07_red_first_tdd.md +26 -0
- data/doc/tutorial/08_file_metadata.md +42 -0
- data/doc/tutorial/09_text_preview.md +44 -0
- data/doc/tutorial/10_directory_tree.md +42 -0
- data/doc/tutorial/11_pane_focus.md +40 -0
- data/doc/tutorial/12_sorting.md +41 -0
- data/doc/tutorial/13_filtering.md +43 -0
- data/doc/tutorial/14_toggle_hidden.md +41 -0
- data/doc/tutorial/15_text_input_widget.md +43 -0
- data/doc/tutorial/16_rename_files.md +42 -0
- data/doc/tutorial/17_confirmation_dialogs.md +43 -0
- data/doc/tutorial/18_progress_indicators.md +43 -0
- data/doc/tutorial/19_atomic_operations.md +42 -0
- data/doc/tutorial/20_external_editor.md +42 -0
- data/doc/tutorial/21_modal_overlays.md +41 -0
- data/doc/tutorial/22_error_handling.md +43 -0
- data/doc/tutorial/23_terminal_capabilities.md +53 -0
- data/doc/tutorial/24_mouse_events.md +43 -0
- data/doc/tutorial/25_resize_events.md +43 -0
- data/doc/tutorial/26_loading_states.md +42 -0
- data/doc/tutorial/27_performance.md +43 -0
- data/doc/tutorial/28_color_schemes.md +47 -0
- data/doc/tutorial/29_configuration.md +124 -0
- data/doc/tutorial/30_going_further.md +17 -0
- data/doc/tutorial/index.md +17 -0
- data/examples/app_file_browser/app.rb +40 -0
- data/examples/app_fractal_dashboard/dashboard/update_manual.rb +7 -7
- data/examples/app_fractal_dashboard/fragments/custom_shell_input.rb +5 -5
- data/examples/app_fractal_dashboard/fragments/custom_shell_modal.rb +1 -1
- data/examples/app_fractal_dashboard/fragments/disk_usage.rb +2 -2
- data/examples/app_fractal_dashboard/fragments/network_panel.rb +4 -4
- data/examples/app_fractal_dashboard/fragments/ping.rb +2 -2
- data/examples/app_fractal_dashboard/fragments/stats_panel.rb +4 -4
- data/examples/app_fractal_dashboard/fragments/system_info.rb +2 -2
- data/examples/app_fractal_dashboard/fragments/uptime.rb +2 -2
- data/examples/verify_website_first_app/app.rb +85 -0
- data/examples/verify_website_hello_mvu/app.rb +31 -0
- data/examples/widget_command_system/app.rb +15 -13
- data/exe/rooibos +10 -0
- data/generate_tutorial_stubs.rb +126 -0
- data/lib/rooibos/cli/commands/new.rb +373 -0
- data/lib/rooibos/cli/commands/run.rb +98 -0
- data/lib/rooibos/cli.rb +78 -0
- data/lib/rooibos/command/all.rb +76 -23
- data/lib/rooibos/command/batch.rb +61 -34
- data/lib/rooibos/command/custom.rb +84 -1
- data/lib/rooibos/command/http.rb +121 -55
- data/lib/rooibos/command/lifecycle.rb +5 -5
- data/lib/rooibos/command/open.rb +93 -0
- data/lib/rooibos/command/outlet.rb +105 -3
- data/lib/rooibos/command/wait.rb +9 -6
- data/lib/rooibos/command.rb +114 -89
- data/lib/rooibos/message/batch.rb +39 -0
- data/lib/rooibos/message/canceled.rb +51 -0
- data/lib/rooibos/message/error.rb +48 -0
- data/lib/rooibos/message/open.rb +30 -0
- data/lib/rooibos/message.rb +84 -4
- data/lib/rooibos/router.rb +11 -14
- data/lib/rooibos/runtime.rb +40 -43
- data/lib/rooibos/shortcuts.rb +47 -0
- data/lib/rooibos/test_helper.rb +71 -6
- data/lib/rooibos/version.rb +1 -1
- data/lib/rooibos/welcome.rb +237 -0
- data/lib/rooibos.rb +4 -3
- data/mise.toml +1 -1
- data/rbs_collection.lock.yaml +2 -2
- data/sig/concurrent.rbs +4 -0
- data/sig/gem.rbs +20 -0
- data/sig/rooibos/cli.rbs +42 -0
- data/sig/rooibos/command.rbs +59 -7
- data/sig/rooibos/message.rbs +66 -2
- data/sig/rooibos/shortcuts.rbs +14 -0
- data/sig/rooibos/test_helper.rbs +6 -2
- data/sig/rooibos/welcome.rbs +75 -0
- data/tasks/install.rake +29 -0
- data/tasks/resources/build.yml.erb +2 -0
- metadata +274 -38
- data/doc/concepts/application_architecture.md +0 -197
- data/doc/concepts/application_testing.md +0 -49
- data/doc/concepts/async_work.md +0 -164
- data/doc/concepts/commands.md +0 -530
- data/doc/concepts/message_processing.md +0 -51
- data/doc/contributors/WIP/decomposition_strategies_analysis.md +0 -258
- data/doc/contributors/WIP/implementation_plan.md +0 -409
- data/doc/contributors/WIP/init_callable_proposal.md +0 -344
- data/doc/contributors/WIP/runtime_refactoring_status.md +0 -47
- data/doc/contributors/WIP/task.md +0 -36
- data/doc/contributors/WIP/v0.4.0_todo.md +0 -468
- data/doc/contributors/kit-no-outlet.md +0 -238
- data/doc/contributors/priorities.md +0 -38
- data/doc/images/.gitkeep +0 -0
- data/exe/.gitkeep +0 -0
- /data/doc/contributors/{WIP → design}/mvu_tea_implementations_research.md +0 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
#--
|
|
4
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
|
|
5
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
6
|
+
#++
|
|
7
|
+
|
|
8
|
+
require "shellwords"
|
|
9
|
+
|
|
10
|
+
module Rooibos
|
|
11
|
+
module Command
|
|
12
|
+
# Opens a file or URL with the system's default application.
|
|
13
|
+
#
|
|
14
|
+
# Terminal applications often need to hand off to external programs.
|
|
15
|
+
# Opening a PDF, launching a URL, or viewing an image requires
|
|
16
|
+
# platform-specific commands.
|
|
17
|
+
#
|
|
18
|
+
# This command detects the platform and runs the appropriate opener:
|
|
19
|
+
# <tt>open</tt> on macOS, <tt>xdg-open</tt> on Linux, <tt>start</tt> on Windows.
|
|
20
|
+
#
|
|
21
|
+
# On success (exit 0), sends <tt>Message::Open</tt>.
|
|
22
|
+
# On failure (non-zero), sends <tt>Message::Error</tt>.
|
|
23
|
+
#
|
|
24
|
+
# Prefer the <tt>Command.open</tt> factory method for convenience.
|
|
25
|
+
#
|
|
26
|
+
# === Example
|
|
27
|
+
#
|
|
28
|
+
# # Using the factory method (recommended)
|
|
29
|
+
# Command.open(model.selected_file)
|
|
30
|
+
# Command.open("https://rooibos.run")
|
|
31
|
+
#
|
|
32
|
+
# # Using the class directly
|
|
33
|
+
# Open.new(path: model.selected_file, envelope: model.selected_file)
|
|
34
|
+
#
|
|
35
|
+
# # Pattern-match on the response
|
|
36
|
+
# def update(msg, model)
|
|
37
|
+
# case msg
|
|
38
|
+
# in { type: :open, envelope: path }
|
|
39
|
+
# model.with(status: "Opened #{path}")
|
|
40
|
+
# in { type: :error, envelope: path }
|
|
41
|
+
# model.with(error: "Could not open #{path}")
|
|
42
|
+
# end
|
|
43
|
+
# end
|
|
44
|
+
class Open < Data.define(:path, :envelope)
|
|
45
|
+
include Custom
|
|
46
|
+
|
|
47
|
+
# System commands are generally fast; no grace period needed.
|
|
48
|
+
def rooibos_cancellation_grace_period = 0
|
|
49
|
+
|
|
50
|
+
# Executes the open command and sends the result message.
|
|
51
|
+
def call(out, token)
|
|
52
|
+
return if token.canceled?
|
|
53
|
+
|
|
54
|
+
require "open3"
|
|
55
|
+
cmd = self.class.__send__(:system_command, path)
|
|
56
|
+
_stdout, stderr, status = Open3.capture3(cmd)
|
|
57
|
+
|
|
58
|
+
message = if status.exitstatus == 0
|
|
59
|
+
Message::Open.new(envelope:)
|
|
60
|
+
else
|
|
61
|
+
error_msg = stderr.empty? ? "Failed to open: #{path}" : stderr.strip
|
|
62
|
+
Message::Error.new(
|
|
63
|
+
command: envelope,
|
|
64
|
+
exception: RuntimeError.new(error_msg.freeze).freeze
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
out.put(Ractor.make_shareable(message))
|
|
69
|
+
rescue => e
|
|
70
|
+
out.put(Ractor.make_shareable(Message::Error.new(
|
|
71
|
+
command: envelope,
|
|
72
|
+
exception: RuntimeError.new(e.message.freeze).freeze
|
|
73
|
+
)))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Builds the platform-specific open command.
|
|
77
|
+
def self.system_command(path, platform = RUBY_PLATFORM) # :nodoc:
|
|
78
|
+
escaped = path.shellescape
|
|
79
|
+
case platform
|
|
80
|
+
when /darwin/
|
|
81
|
+
"open #{escaped}"
|
|
82
|
+
when /linux/
|
|
83
|
+
"xdg-open #{escaped}"
|
|
84
|
+
when /mingw|mswin|cygwin/
|
|
85
|
+
"start #{path}"
|
|
86
|
+
else
|
|
87
|
+
"xdg-open #{escaped}"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
private_class_method :system_command
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -85,10 +85,15 @@ module Rooibos
|
|
|
85
85
|
def initialize(message_queue, lifecycle:)
|
|
86
86
|
@message_queue = message_queue
|
|
87
87
|
@live = lifecycle
|
|
88
|
+
@pending_async = [] #: Array[AsyncHandle]
|
|
88
89
|
end
|
|
89
90
|
|
|
90
|
-
#
|
|
91
|
-
|
|
91
|
+
# Internal handle for async streaming commands.
|
|
92
|
+
AsyncHandle = Data.define(:future) # :nodoc:
|
|
93
|
+
private_constant :AsyncHandle
|
|
94
|
+
|
|
95
|
+
# Internal infrastructure for nested command lifecycle sharing.
|
|
96
|
+
attr_reader :live # :nodoc:
|
|
92
97
|
|
|
93
98
|
# Sends a message to the runtime.
|
|
94
99
|
#
|
|
@@ -131,7 +136,7 @@ module Rooibos
|
|
|
131
136
|
# [token] The parent's cancellation token, passed through to the child.
|
|
132
137
|
# [timeout] Max seconds to wait for the child's result (default: 30.0).
|
|
133
138
|
#
|
|
134
|
-
# Returns the message from the child, or +nil+ if
|
|
139
|
+
# Returns the message from the child, or +nil+ if canceled/timed out.
|
|
135
140
|
# Raises if the child command raised an exception.
|
|
136
141
|
#
|
|
137
142
|
# === Example
|
|
@@ -152,6 +157,103 @@ module Rooibos
|
|
|
152
157
|
def source(command, token, timeout: 30.0)
|
|
153
158
|
@live.run_sync(command, token, timeout:)
|
|
154
159
|
end
|
|
160
|
+
|
|
161
|
+
# Spawns an async streaming command.
|
|
162
|
+
#
|
|
163
|
+
# Multiple data sources often need to stream in parallel. Dashboards,
|
|
164
|
+
# real-time feeds, and multi-provider aggregations all face this pattern.
|
|
165
|
+
# Waiting for one source before starting the next creates latency.
|
|
166
|
+
#
|
|
167
|
+
# This method spawns a child command that runs asynchronously. Messages
|
|
168
|
+
# from the child stream directly to your update function as they arrive.
|
|
169
|
+
# The child gets a full Outlet, so it can nest +source+ or +standing+ calls.
|
|
170
|
+
#
|
|
171
|
+
# Use +wait+ to block until the child completes, or fire-and-forget for
|
|
172
|
+
# long-running streams.
|
|
173
|
+
#
|
|
174
|
+
# [command] A callable with <tt>call(out, token)</tt>.
|
|
175
|
+
# [token] The parent's cancellation token.
|
|
176
|
+
#
|
|
177
|
+
# Returns a handle for use with +wait+.
|
|
178
|
+
#
|
|
179
|
+
# === Example
|
|
180
|
+
#
|
|
181
|
+
# A dashboard that opens two SSE streams for live updates. Each stream
|
|
182
|
+
# emits chunks as they arrive — no waiting for the other.
|
|
183
|
+
#
|
|
184
|
+
#--
|
|
185
|
+
# SPDX-SnippetBegin
|
|
186
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
187
|
+
# SPDX-License-Identifier: MIT-0
|
|
188
|
+
#++
|
|
189
|
+
# def call(out, token)
|
|
190
|
+
# # Authenticate first (sync)
|
|
191
|
+
# auth = out.source(Authenticate.new, token)
|
|
192
|
+
# return if auth.nil?
|
|
193
|
+
#
|
|
194
|
+
# # Open two SSE streams in parallel — chunks arrive live
|
|
195
|
+
# # Streams remain outstanding until token is canceled
|
|
196
|
+
# out.standing(StreamNotifications.new(auth), token)
|
|
197
|
+
# out.standing(StreamPrices.new(auth), token)
|
|
198
|
+
# end
|
|
199
|
+
#--
|
|
200
|
+
# SPDX-SnippetEnd
|
|
201
|
+
#++
|
|
202
|
+
def standing(command, token)
|
|
203
|
+
child_outlet = Outlet.new(@message_queue, lifecycle: @live)
|
|
204
|
+
future = Concurrent::Promises.future do
|
|
205
|
+
command.call(child_outlet, token)
|
|
206
|
+
rescue => e
|
|
207
|
+
@message_queue.push Message::Error.new(command:, exception: e)
|
|
208
|
+
end
|
|
209
|
+
handle = AsyncHandle.new(future:)
|
|
210
|
+
@pending_async << handle
|
|
211
|
+
handle
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Blocks until async commands complete.
|
|
215
|
+
#
|
|
216
|
+
# After spawning children with +standing+, the parent command normally
|
|
217
|
+
# returns immediately. Use +wait+ to block until children finish, then
|
|
218
|
+
# emit a completion signal.
|
|
219
|
+
#
|
|
220
|
+
# This is how custom commands achieve the same end-of-streams dispatch
|
|
221
|
+
# that +Command.batch+ gets automatically with +Message::Batch+.
|
|
222
|
+
#
|
|
223
|
+
# [handles] Zero or more handles from +standing+. If empty, waits for all.
|
|
224
|
+
#
|
|
225
|
+
# === Example
|
|
226
|
+
#
|
|
227
|
+
# A custom command that streams from two sources and signals when done.
|
|
228
|
+
#
|
|
229
|
+
#--
|
|
230
|
+
# SPDX-SnippetBegin
|
|
231
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
232
|
+
# SPDX-License-Identifier: MIT-0
|
|
233
|
+
#++
|
|
234
|
+
# def call(out, token)
|
|
235
|
+
# h1 = out.standing(StreamPrices.new, token)
|
|
236
|
+
# h2 = out.standing(StreamNews.new, token)
|
|
237
|
+
# out.wait(h1, h2)
|
|
238
|
+
# out.put(:streams_closed) # Your custom completion signal
|
|
239
|
+
# end
|
|
240
|
+
#--
|
|
241
|
+
# SPDX-SnippetEnd
|
|
242
|
+
#++
|
|
243
|
+
def wait(*handles, token: nil)
|
|
244
|
+
handles = @pending_async || [] if handles.empty?
|
|
245
|
+
return if handles.empty?
|
|
246
|
+
|
|
247
|
+
futures = handles.map(&:future)
|
|
248
|
+
all_done = Concurrent::Promises.zip_futures(*futures)
|
|
249
|
+
|
|
250
|
+
if token
|
|
251
|
+
# Race completion against cancellation
|
|
252
|
+
Concurrent::Promises.any_event(all_done, token.origin).wait
|
|
253
|
+
else
|
|
254
|
+
all_done.wait
|
|
255
|
+
end
|
|
256
|
+
end
|
|
155
257
|
end
|
|
156
258
|
end
|
|
157
259
|
end
|
data/lib/rooibos/command/wait.rb
CHANGED
|
@@ -15,11 +15,14 @@ module Rooibos
|
|
|
15
15
|
# Cancellation is tricky.
|
|
16
16
|
#
|
|
17
17
|
# This command waits, then sends a message. It responds to
|
|
18
|
-
# cancellation cooperatively. When
|
|
19
|
-
# <tt>
|
|
18
|
+
# cancellation cooperatively. When canceled, it sends
|
|
19
|
+
# <tt>Message::Canceled</tt> so you know the timer stopped.
|
|
20
20
|
#
|
|
21
21
|
# Use it for delayed actions, debounced inputs, or animation loops.
|
|
22
22
|
#
|
|
23
|
+
# Prefer the <tt>Command.wait</tt> or <tt>Command.tick</tt> factory
|
|
24
|
+
# methods for convenience. Both are aliases for the same behavior.
|
|
25
|
+
#
|
|
23
26
|
# === Example: Notification dismissal
|
|
24
27
|
#
|
|
25
28
|
# def update(msg, model)
|
|
@@ -28,7 +31,7 @@ module Rooibos
|
|
|
28
31
|
# [model.with(notification: "Saved!"), Command.wait(3.0, :dismiss)]
|
|
29
32
|
# in :dismiss
|
|
30
33
|
# [model.with(notification: nil), nil]
|
|
31
|
-
# in
|
|
34
|
+
# in Message::Canceled
|
|
32
35
|
# [model.with(notification: nil), nil] # User navigated away
|
|
33
36
|
# end
|
|
34
37
|
# end
|
|
@@ -44,7 +47,7 @@ module Rooibos
|
|
|
44
47
|
# [model.with(frame:), Command.tick(0.1, :animate)]
|
|
45
48
|
# end
|
|
46
49
|
# end
|
|
47
|
-
Wait
|
|
50
|
+
class Wait < Data.define(:seconds, :envelope)
|
|
48
51
|
include Custom
|
|
49
52
|
|
|
50
53
|
# Cooperative cancellation needs no grace period.
|
|
@@ -57,7 +60,7 @@ module Rooibos
|
|
|
57
60
|
# Executes the timer.
|
|
58
61
|
#
|
|
59
62
|
# Waits for <tt>seconds</tt>, then sends <tt>TimerResponse</tt>.
|
|
60
|
-
# If
|
|
63
|
+
# If canceled, sends <tt>Message::Canceled</tt> instead.
|
|
61
64
|
#
|
|
62
65
|
# [out] Outlet for sending messages.
|
|
63
66
|
# [token] Cancellation token from the runtime.
|
|
@@ -68,7 +71,7 @@ module Rooibos
|
|
|
68
71
|
combined.origin.wait
|
|
69
72
|
|
|
70
73
|
if token.canceled?
|
|
71
|
-
out.put(
|
|
74
|
+
out.put(Message::Canceled.new(command: self))
|
|
72
75
|
else
|
|
73
76
|
elapsed = Time.now - start_time
|
|
74
77
|
response = Message::Timer.new(envelope:, elapsed:)
|
data/lib/rooibos/command.rb
CHANGED
|
@@ -13,6 +13,7 @@ require_relative "command/wait"
|
|
|
13
13
|
require_relative "command/batch"
|
|
14
14
|
require_relative "command/all"
|
|
15
15
|
require_relative "command/http"
|
|
16
|
+
require_relative "command/open"
|
|
16
17
|
|
|
17
18
|
module Rooibos
|
|
18
19
|
# Commands represent side effects.
|
|
@@ -35,9 +36,21 @@ module Rooibos
|
|
|
35
36
|
# # No side effect
|
|
36
37
|
# [model, nil]
|
|
37
38
|
module Command
|
|
38
|
-
#
|
|
39
|
+
# Terminates the application.
|
|
39
40
|
#
|
|
40
|
-
#
|
|
41
|
+
# Users press a key or click a button to quit. The update function returns
|
|
42
|
+
# a command, and the runtime executes it. Termination is special: the
|
|
43
|
+
# runtime detects this sentinel before dispatching and breaks the loop.
|
|
44
|
+
#
|
|
45
|
+
# Prefer the <tt>Command.exit</tt> factory method for convenience.
|
|
46
|
+
#
|
|
47
|
+
# === Example
|
|
48
|
+
#
|
|
49
|
+
# # Using the factory method (recommended)
|
|
50
|
+
# [model, Command.exit]
|
|
51
|
+
#
|
|
52
|
+
# # Using the class directly
|
|
53
|
+
# [model, Exit.new]
|
|
41
54
|
class Exit < Data.define
|
|
42
55
|
include Custom
|
|
43
56
|
|
|
@@ -67,11 +80,11 @@ module Rooibos
|
|
|
67
80
|
|
|
68
81
|
# Creates a fresh cancellation that never fires.
|
|
69
82
|
#
|
|
70
|
-
# Some I/O operations cannot be
|
|
83
|
+
# Some I/O operations cannot be canceled mid-execution. Ruby's <tt>Net::HTTP</tt>
|
|
71
84
|
# blocks until completion or timeout — there is no way to interrupt it.
|
|
72
85
|
#
|
|
73
86
|
# A shared singleton would be unsafe. If any code path accidentally resolves
|
|
74
|
-
# the origin, all commands using it become
|
|
87
|
+
# the origin, all commands using it become canceled.
|
|
75
88
|
#
|
|
76
89
|
# Use it for commands that wrap non-cancellable blocking I/O.
|
|
77
90
|
#
|
|
@@ -84,16 +97,28 @@ module Rooibos
|
|
|
84
97
|
cancellation
|
|
85
98
|
end
|
|
86
99
|
|
|
87
|
-
#
|
|
100
|
+
# Cancels a running command.
|
|
101
|
+
#
|
|
102
|
+
# Long-running commands (WebSocket listeners, database pollers) run until
|
|
103
|
+
# stopped. Stopping them requires signaling from outside the command. The
|
|
104
|
+
# runtime tracks active commands by their object identity and routes cancel
|
|
105
|
+
# requests.
|
|
106
|
+
#
|
|
107
|
+
# This type carries the handle (command object) to cancel. The runtime
|
|
108
|
+
# pattern-matches on <tt>Command::Cancel</tt> and signals the token.
|
|
88
109
|
#
|
|
89
|
-
#
|
|
90
|
-
# Stopping them requires signaling from outside the command. The runtime tracks
|
|
91
|
-
# active commands by their object identity and routes cancel requests.
|
|
110
|
+
# Prefer the <tt>Command.cancel</tt> factory method for convenience.
|
|
92
111
|
#
|
|
93
|
-
#
|
|
94
|
-
#
|
|
112
|
+
# === Example
|
|
113
|
+
#
|
|
114
|
+
# # Using the factory method (recommended)
|
|
115
|
+
# [model, Command.cancel(model.active_fetch)]
|
|
116
|
+
#
|
|
117
|
+
# # Using the class directly
|
|
118
|
+
# [model, Cancel.new(handle: model.active_fetch)]
|
|
95
119
|
class Cancel < Data.define(:handle)
|
|
96
120
|
include Custom
|
|
121
|
+
include Message::Predicates
|
|
97
122
|
|
|
98
123
|
# Stub - Cancel is a sentinel handled by runtime before dispatch.
|
|
99
124
|
def call(_out, _token)
|
|
@@ -121,55 +146,6 @@ module Rooibos
|
|
|
121
146
|
Cancel.new(handle:)
|
|
122
147
|
end
|
|
123
148
|
|
|
124
|
-
# Error message from a failed command.
|
|
125
|
-
#
|
|
126
|
-
# Commands run in background threads. Exceptions bubble up silently.
|
|
127
|
-
# Your update function never sees them. Backtraces in STDERR corrupt the TUI.
|
|
128
|
-
#
|
|
129
|
-
# The runtime catches exceptions and wraps them in Error messages.
|
|
130
|
-
# Pattern match on Error in your update function. Display the error, log it, or recover.
|
|
131
|
-
#
|
|
132
|
-
# Use it to surface failures from HTTP requests, file I/O, or external processes.
|
|
133
|
-
#
|
|
134
|
-
# === Examples
|
|
135
|
-
#
|
|
136
|
-
# Update = ->(message, model) {
|
|
137
|
-
# case message
|
|
138
|
-
# in Command::Error[command:, exception:]
|
|
139
|
-
# # Show error toast
|
|
140
|
-
# [model.with(error: exception.message), nil]
|
|
141
|
-
# in Command::Error[command: Command::Http, exception:]
|
|
142
|
-
# # Retry HTTP request
|
|
143
|
-
# [model, command]
|
|
144
|
-
# in Command::Error
|
|
145
|
-
# # Log and continue
|
|
146
|
-
# warn "Command failed: #{message.exception}"
|
|
147
|
-
# [model, nil]
|
|
148
|
-
# end
|
|
149
|
-
# }
|
|
150
|
-
class Error < Data.define(:command, :exception); end
|
|
151
|
-
|
|
152
|
-
# Creates an error sentinel.
|
|
153
|
-
#
|
|
154
|
-
# The runtime produces this automatically when a command raises.
|
|
155
|
-
# Use this factory for testing or for commands that want to signal
|
|
156
|
-
# error completion without raising.
|
|
157
|
-
#
|
|
158
|
-
# [command] The command that failed.
|
|
159
|
-
# [exception] The exception that was raised.
|
|
160
|
-
#
|
|
161
|
-
# === Example
|
|
162
|
-
#
|
|
163
|
-
# def update(message, model)
|
|
164
|
-
# case message
|
|
165
|
-
# in Command::Error(command:, exception:)
|
|
166
|
-
# model.with(error: "#{command.class} failed: #{exception.message}")
|
|
167
|
-
# end
|
|
168
|
-
# end
|
|
169
|
-
def self.error(command, exception)
|
|
170
|
-
Error.new(command:, exception:)
|
|
171
|
-
end
|
|
172
|
-
|
|
173
149
|
# Runs a shell command and routes its output back as messages.
|
|
174
150
|
#
|
|
175
151
|
# Apps run external tools: linters, compilers, scripts, system utilities.
|
|
@@ -180,20 +156,29 @@ module Rooibos
|
|
|
180
156
|
#
|
|
181
157
|
# Use it to run builds, lint files, execute scripts, or invoke any CLI tool.
|
|
182
158
|
#
|
|
159
|
+
# Prefer the <tt>Command.system</tt> factory method for convenience.
|
|
160
|
+
#
|
|
183
161
|
# === Batch Mode (default)
|
|
184
162
|
#
|
|
185
163
|
# A single message arrives when the command finishes:
|
|
186
|
-
# <tt>
|
|
164
|
+
# <tt>Message::System::Batch</tt> with <tt>stdout</tt>, <tt>stderr</tt>, <tt>status</tt>.
|
|
187
165
|
#
|
|
188
166
|
# === Streaming Mode
|
|
189
167
|
#
|
|
190
|
-
#
|
|
191
|
-
#
|
|
192
|
-
#
|
|
193
|
-
#
|
|
194
|
-
#
|
|
168
|
+
# <tt>Message::System::Stream</tt> messages arrive incrementally:
|
|
169
|
+
# <tt>stream: :stdout</tt>:: for each stdout chunk
|
|
170
|
+
# <tt>stream: :stderr</tt>:: for each stderr chunk
|
|
171
|
+
# <tt>stream: :complete</tt>:: when the command finishes
|
|
172
|
+
# <tt>stream: :error</tt>:: if the command cannot start
|
|
173
|
+
#
|
|
174
|
+
# === Example
|
|
175
|
+
#
|
|
176
|
+
# # Using the factory method (recommended)
|
|
177
|
+
# Command.system("ls -la", :got_files)
|
|
178
|
+
# Command.system("tail -f log.txt", :log, stream: true)
|
|
195
179
|
#
|
|
196
|
-
#
|
|
180
|
+
# # Using the class directly
|
|
181
|
+
# System.new(command: "ls -la", envelope: :got_files, stream: false)
|
|
197
182
|
class System < Data.define(:command, :envelope, :stream)
|
|
198
183
|
include Custom
|
|
199
184
|
|
|
@@ -320,9 +305,9 @@ module Rooibos
|
|
|
320
305
|
# # Then handle it later:
|
|
321
306
|
# def update(message, model)
|
|
322
307
|
# case message
|
|
323
|
-
# in
|
|
308
|
+
# in { type: :system, envelope: :got_files, stdout:, status: 0 }
|
|
324
309
|
# [model.with(files: stdout.lines), nil]
|
|
325
|
-
# in
|
|
310
|
+
# in { type: :system, envelope: :got_files, stderr:, status: }
|
|
326
311
|
# [model.with(error: stderr), nil]
|
|
327
312
|
# end
|
|
328
313
|
# end
|
|
@@ -335,14 +320,14 @@ module Rooibos
|
|
|
335
320
|
# # Then handle incremental messages:
|
|
336
321
|
# def update(message, model)
|
|
337
322
|
# case message
|
|
338
|
-
# in
|
|
323
|
+
# in { type: :system, envelope: :log, stream: :stdout, content: line }
|
|
339
324
|
# [model.with(lines: [*model.lines, line]), nil]
|
|
340
|
-
# in
|
|
325
|
+
# in { type: :system, envelope: :log, stream: :stderr, content: line }
|
|
341
326
|
# [model.with(errors: [*model.errors, line]), nil]
|
|
342
|
-
# in
|
|
327
|
+
# in { type: :system, envelope: :log, stream: :complete, status: }
|
|
343
328
|
# [model.with(loading: false, exit_status: status), nil]
|
|
344
|
-
# in
|
|
345
|
-
# [model.with(loading: false, error:
|
|
329
|
+
# in { type: :system, envelope: :log, stream: :error, content: msg }
|
|
330
|
+
# [model.with(loading: false, error: msg), nil]
|
|
346
331
|
# end
|
|
347
332
|
# end
|
|
348
333
|
def self.system(command, envelope, stream: false)
|
|
@@ -361,31 +346,48 @@ module Rooibos
|
|
|
361
346
|
# adds its routing prefix. Clean separation. No coupling.
|
|
362
347
|
#
|
|
363
348
|
# Use it to compose child fragments that return their own commands.
|
|
349
|
+
#
|
|
350
|
+
# Prefer the <tt>Command.map</tt> factory method for convenience.
|
|
351
|
+
#
|
|
352
|
+
# === Example
|
|
353
|
+
#
|
|
354
|
+
# # Using the factory method (recommended)
|
|
355
|
+
# Command.map(child_command) { |msg| [:sidebar, msg] }
|
|
356
|
+
#
|
|
357
|
+
# # Using the class directly
|
|
358
|
+
# Mapped.new(inner_command: child_command, mapper: ->(msg) { [:sidebar, msg] })
|
|
364
359
|
class Mapped < Data.define(:inner_command, :mapper)
|
|
365
360
|
include Custom
|
|
366
361
|
|
|
362
|
+
DONE = Object.new.freeze
|
|
363
|
+
private_constant :DONE
|
|
364
|
+
|
|
367
365
|
# Grace period delegates to inner command.
|
|
368
366
|
def rooibos_cancellation_grace_period
|
|
369
367
|
inner_command.respond_to?(:rooibos_cancellation_grace_period) ?
|
|
370
368
|
inner_command.rooibos_cancellation_grace_period : 0.1
|
|
371
369
|
end
|
|
372
370
|
|
|
373
|
-
# Executes the inner command
|
|
371
|
+
# Executes the inner command and transforms each message.
|
|
374
372
|
def call(out, token)
|
|
375
373
|
inner_channel = Concurrent::Promises::Channel.new
|
|
376
374
|
inner_outlet = Outlet.new(inner_channel, lifecycle: out.live)
|
|
377
375
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
376
|
+
Concurrent::Promises.future do
|
|
377
|
+
if inner_command.respond_to?(:call)
|
|
378
|
+
inner_command.call(inner_outlet, token)
|
|
379
|
+
else
|
|
380
|
+
raise ArgumentError, "Inner command must respond to #call"
|
|
381
|
+
end
|
|
382
|
+
inner_channel.push(DONE)
|
|
383
383
|
end
|
|
384
384
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
385
|
+
loop do
|
|
386
|
+
msg = inner_channel.pop
|
|
387
|
+
break if msg.equal?(DONE)
|
|
388
|
+
transformed = mapper.call(msg)
|
|
389
|
+
out.put(*transformed) if transformed
|
|
390
|
+
end
|
|
389
391
|
end
|
|
390
392
|
end
|
|
391
393
|
|
|
@@ -405,8 +407,14 @@ module Rooibos
|
|
|
405
407
|
#
|
|
406
408
|
# # Parent wraps to route as [:sidebar, :got_files, {...}]
|
|
407
409
|
# parent_command = Command.map(child_command) { |child_result| [:sidebar, *child_result] }
|
|
408
|
-
def self.map(inner_command, &
|
|
409
|
-
|
|
410
|
+
def self.map(inner_command, mapper = nil, &block)
|
|
411
|
+
if mapper && block
|
|
412
|
+
raise ArgumentError, "Pass either a mapper callable or a block, not both"
|
|
413
|
+
end
|
|
414
|
+
unless mapper || block
|
|
415
|
+
raise ArgumentError, "Pass a mapper callable or a block"
|
|
416
|
+
end
|
|
417
|
+
Mapped.new(inner_command:, mapper: mapper || block)
|
|
410
418
|
end
|
|
411
419
|
|
|
412
420
|
# Gives a callable unique identity for cancellation.
|
|
@@ -529,15 +537,32 @@ module Rooibos
|
|
|
529
537
|
Http.new(*, **)
|
|
530
538
|
end
|
|
531
539
|
|
|
532
|
-
#
|
|
533
|
-
|
|
540
|
+
# Opens a file or URL with the system's default application.
|
|
541
|
+
# Cross-platform: uses +open+ on macOS, +xdg-open+ on Linux, +start+ on Windows.
|
|
542
|
+
#
|
|
543
|
+
# On success (exit 0), sends +Message::Open+.
|
|
544
|
+
# On failure (non-zero), sends +Message::Error+.
|
|
545
|
+
#
|
|
546
|
+
# === Example
|
|
547
|
+
#
|
|
548
|
+
# case message
|
|
549
|
+
# in { type: :open, envelope: path }
|
|
550
|
+
# model.with(status: "Opened #{path}")
|
|
551
|
+
# in { type: :error, envelope: path }
|
|
552
|
+
# model.with(error: "Could not open #{path}")
|
|
553
|
+
# end
|
|
554
|
+
#
|
|
555
|
+
def self.open(path, envelope = path)
|
|
556
|
+
Open.new(path:, envelope:)
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
class Wrapped < Data.define(:callable, :grace_period) # :nodoc:
|
|
534
560
|
include Custom
|
|
535
561
|
def rooibos_cancellation_grace_period
|
|
536
562
|
grace_period || super
|
|
537
563
|
end
|
|
538
564
|
|
|
539
|
-
# :nodoc:
|
|
540
|
-
def call(out, token)
|
|
565
|
+
def call(out, token) # :nodoc:
|
|
541
566
|
callable.call(out, token)
|
|
542
567
|
end
|
|
543
568
|
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
#--
|
|
4
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
|
|
5
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
6
|
+
#++
|
|
7
|
+
|
|
8
|
+
module Rooibos
|
|
9
|
+
module Message
|
|
10
|
+
# Completion sentinel from Command.batch.
|
|
11
|
+
#
|
|
12
|
+
# Batch commands stream child messages live. When all children finish,
|
|
13
|
+
# this message signals completion. Use it to trigger follow-up actions
|
|
14
|
+
# or UI updates after parallel work completes.
|
|
15
|
+
#
|
|
16
|
+
# === Example
|
|
17
|
+
#
|
|
18
|
+
# case msg
|
|
19
|
+
# in Message::Batch
|
|
20
|
+
# out.put(:all_done)
|
|
21
|
+
# in [:progress, pct]
|
|
22
|
+
# model.with(progress: pct)
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
Batch = Data.define(:command) do
|
|
26
|
+
include Predicates
|
|
27
|
+
|
|
28
|
+
# Returns <tt>true</tt> for batch completion messages.
|
|
29
|
+
def batch?
|
|
30
|
+
true
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Deconstructs for pattern matching.
|
|
34
|
+
def deconstruct_keys(_keys)
|
|
35
|
+
{ type: :batch, command: }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
#--
|
|
4
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
|
|
5
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
6
|
+
#++
|
|
7
|
+
|
|
8
|
+
module Rooibos
|
|
9
|
+
module Message
|
|
10
|
+
# Cancellation notification from a canceled command.
|
|
11
|
+
#
|
|
12
|
+
# Long-running commands respond to cancellation cooperatively. When the
|
|
13
|
+
# runtime signals cancellation, the command finishes current work and sends
|
|
14
|
+
# this message.
|
|
15
|
+
#
|
|
16
|
+
# Pattern match on Canceled in your update function to clean up state,
|
|
17
|
+
# stop animations, or acknowledge the cancellation.
|
|
18
|
+
#
|
|
19
|
+
# Use it to handle timer cancellations, aborted HTTP requests, or
|
|
20
|
+
# stopped background processes.
|
|
21
|
+
#
|
|
22
|
+
# === Example
|
|
23
|
+
#
|
|
24
|
+
# Update = ->(message, model) {
|
|
25
|
+
# case message
|
|
26
|
+
# in { type: :canceled, command: }
|
|
27
|
+
# # Timer was canceled, clear the notification
|
|
28
|
+
# model.with(notification: nil)
|
|
29
|
+
# in Message::Canceled
|
|
30
|
+
# # Generic cancellation handling
|
|
31
|
+
# model
|
|
32
|
+
# end
|
|
33
|
+
# }
|
|
34
|
+
Canceled = Data.define(:command) do
|
|
35
|
+
include Predicates
|
|
36
|
+
|
|
37
|
+
# Returns <tt>true</tt> for cancellation messages.
|
|
38
|
+
def canceled?
|
|
39
|
+
true
|
|
40
|
+
end
|
|
41
|
+
alias_method :cancelled?, :canceled?
|
|
42
|
+
|
|
43
|
+
# Deconstructs for pattern matching.
|
|
44
|
+
#
|
|
45
|
+
# Returns a hash with <tt>type</tt> and <tt>command</tt>.
|
|
46
|
+
def deconstruct_keys(_keys)
|
|
47
|
+
{ type: :canceled, command: }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|