rooibos 0.5.0 → 0.6.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/.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 +46 -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_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 +25 -20
- data/lib/rooibos/command/batch.rb +26 -25
- data/lib/rooibos/command/custom.rb +84 -1
- data/lib/rooibos/command/http.rb +59 -55
- data/lib/rooibos/command/lifecycle.rb +5 -5
- data/lib/rooibos/command/open.rb +86 -0
- data/lib/rooibos/command/outlet.rb +105 -3
- data/lib/rooibos/command/wait.rb +5 -5
- data/lib/rooibos/command.rb +57 -74
- 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 +3 -0
- data/sig/gem.rbs +20 -0
- data/sig/rooibos/cli.rbs +42 -0
- data/sig/rooibos/command.rbs +48 -0
- data/sig/rooibos/message.rbs +60 -0
- 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 +272 -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
|
@@ -71,7 +71,7 @@ module Rooibos
|
|
|
71
71
|
# it calls <tt>token.cancel!</tt> and waits this long for your command to stop.
|
|
72
72
|
# If your command does not exit within this window, it is force-killed.
|
|
73
73
|
#
|
|
74
|
-
# *This is NOT a lifetime limit.* Your command runs indefinitely until
|
|
74
|
+
# *This is NOT a lifetime limit.* Your command runs indefinitely until canceled.
|
|
75
75
|
# A WebSocket open for 15 minutes is fine. This timeout only applies to the
|
|
76
76
|
# cleanup phase after cancellation is requested.
|
|
77
77
|
#
|
|
@@ -99,6 +99,89 @@ module Rooibos
|
|
|
99
99
|
def rooibos_cancellation_grace_period
|
|
100
100
|
0.1
|
|
101
101
|
end
|
|
102
|
+
|
|
103
|
+
# Infrastructure methods to exclude from introspection.
|
|
104
|
+
# Computed once from bare prototypes.
|
|
105
|
+
INFRASTRUCTURE_METHODS = begin
|
|
106
|
+
bare_data = Data.define(:_)
|
|
107
|
+
bare_struct = Struct.new(:_)
|
|
108
|
+
|
|
109
|
+
methods = Object.public_instance_methods +
|
|
110
|
+
bare_data.public_instance_methods +
|
|
111
|
+
bare_struct.public_instance_methods
|
|
112
|
+
|
|
113
|
+
# PP methods can error when called on objects without pretty_print override
|
|
114
|
+
methods += %i[
|
|
115
|
+
pretty_print
|
|
116
|
+
pretty_print_cycle
|
|
117
|
+
pretty_print_instance_variables
|
|
118
|
+
pretty_print_inspect
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
methods.uniq.freeze
|
|
122
|
+
end
|
|
123
|
+
private_constant :INFRASTRUCTURE_METHODS
|
|
124
|
+
|
|
125
|
+
# Deconstructs for hash-based pattern matching.
|
|
126
|
+
#
|
|
127
|
+
# Introspects public query methods (CQS: zero-arity, no side effects) and
|
|
128
|
+
# returns a hash suitable for +case+/+in+ matching. Excludes infrastructure
|
|
129
|
+
# methods from Object, Data, and Struct.
|
|
130
|
+
#
|
|
131
|
+
# Always includes +:type+ as a snake_case symbol of the class name.
|
|
132
|
+
# Anonymous classes default to +:custom+.
|
|
133
|
+
#
|
|
134
|
+
# Data.define members are automatically included since they generate
|
|
135
|
+
# public accessor methods.
|
|
136
|
+
#
|
|
137
|
+
# This is a naive but practical default. Override for:
|
|
138
|
+
# - Hot paths (introspects methods on every call)
|
|
139
|
+
# - Ghost methods via +method_missing+/+respond_to_missing?+
|
|
140
|
+
# - Methods with optional arguments (only zero-arity detected)
|
|
141
|
+
#
|
|
142
|
+
# @param keys [Array<Symbol>, nil] Limit output to specific keys for performance.
|
|
143
|
+
# Pass +nil+ to include all keys.
|
|
144
|
+
# @return [Hash{Symbol => Object}] Deconstructed hash with +:type+ discriminator.
|
|
145
|
+
#
|
|
146
|
+
# @example Pattern matching with Data.define command
|
|
147
|
+
# case msg
|
|
148
|
+
# in { type: :http_response, envelope: :users, status: 200 }
|
|
149
|
+
# # handle success
|
|
150
|
+
# end
|
|
151
|
+
def deconstruct_keys(keys)
|
|
152
|
+
class_name = self.class.name&.split("::")&.last
|
|
153
|
+
type_name = if class_name
|
|
154
|
+
class_name
|
|
155
|
+
.gsub(/([a-z])([A-Z])/, '\1_\2')
|
|
156
|
+
.downcase
|
|
157
|
+
.to_sym
|
|
158
|
+
else
|
|
159
|
+
:custom
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
result = { type: type_name }
|
|
163
|
+
|
|
164
|
+
# Include Data.define/Struct members
|
|
165
|
+
if self.class.respond_to?(:members)
|
|
166
|
+
klass = self.class #: Class & _HasMembers
|
|
167
|
+
klass.members.each do |member|
|
|
168
|
+
next if keys && !keys.include?(member)
|
|
169
|
+
result[member] = public_send(member)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Include public zero-arity query methods (excluding infrastructure)
|
|
174
|
+
# Use Kernel#public_method to avoid collision with Data.define :method member
|
|
175
|
+
get_method = Kernel.instance_method(:public_method)
|
|
176
|
+
(public_methods - INFRASTRUCTURE_METHODS).each do |method_name|
|
|
177
|
+
next if method_name.to_s.end_with?("=", "!")
|
|
178
|
+
next unless get_method.bind_call(self, method_name).arity.zero?
|
|
179
|
+
next if keys && !keys.include?(method_name)
|
|
180
|
+
result[method_name] = public_send(method_name)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
result
|
|
184
|
+
end
|
|
102
185
|
end
|
|
103
186
|
end
|
|
104
187
|
end
|
data/lib/rooibos/command/http.rb
CHANGED
|
@@ -18,76 +18,80 @@ module Rooibos
|
|
|
18
18
|
Http = Data.define(:method, :url, :envelope, :headers, :body, :timeout, :parser) do
|
|
19
19
|
include Custom
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
21
|
+
class << self
|
|
22
|
+
undef_method :new
|
|
23
|
+
|
|
24
|
+
def new(*args, method: nil, url: nil, envelope: nil, headers: nil, body: nil, timeout: nil, parser: nil,
|
|
25
|
+
get: nil, post: nil, put: nil, patch: nil, delete: nil
|
|
26
|
+
)
|
|
27
|
+
# Auto-splat single hash argument
|
|
28
|
+
return new(**args.first) if args.size == 1 && args.first.is_a?(Hash)
|
|
29
|
+
|
|
30
|
+
# Auto-spread single array argument
|
|
31
|
+
return new(*args.first) if args.size == 1 && args.first.is_a?(Array)
|
|
32
|
+
|
|
33
|
+
# DWIM: parse positional args and keyword method shortcuts
|
|
34
|
+
method_keywords = { get:, post:, put:, patch:, delete: }.compact
|
|
35
|
+
method, url, envelope, body = parse_dwim_args(args, method, url, envelope, body, method_keywords)
|
|
36
|
+
|
|
37
|
+
# Ractor validation
|
|
38
|
+
if RatatuiRuby::Debug.enabled? && !Ractor.shareable?(url)
|
|
39
|
+
raise Rooibos::Error::Invariant,
|
|
40
|
+
"URL is not Ractor-shareable: #{url.inspect}\n" \
|
|
41
|
+
"Use a frozen string or Ractor.make_shareable."
|
|
42
|
+
end
|
|
33
43
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
end
|
|
44
|
+
if RatatuiRuby::Debug.enabled? && headers && !Ractor.shareable?(headers)
|
|
45
|
+
raise Rooibos::Error::Invariant,
|
|
46
|
+
"Headers are not Ractor-shareable: #{headers.inspect}\n" \
|
|
47
|
+
"Use Ractor.make_shareable or freeze the hash and its contents."
|
|
48
|
+
end
|
|
40
49
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
50
|
+
if RatatuiRuby::Debug.enabled? && body && !Ractor.shareable?(body)
|
|
51
|
+
raise Rooibos::Error::Invariant,
|
|
52
|
+
"Body is not Ractor-shareable: #{body.inspect}\n" \
|
|
53
|
+
"Use a frozen string or Ractor.make_shareable."
|
|
54
|
+
end
|
|
46
55
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
56
|
+
if RatatuiRuby::Debug.enabled? && envelope && !Ractor.shareable?(envelope)
|
|
57
|
+
raise Rooibos::Error::Invariant,
|
|
58
|
+
"Envelope is not Ractor-shareable: #{envelope.inspect}\n" \
|
|
59
|
+
"Use a frozen string, symbol, or Ractor.make_shareable."
|
|
60
|
+
end
|
|
52
61
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
62
|
+
if RatatuiRuby::Debug.enabled? && timeout && !Ractor.shareable?(timeout)
|
|
63
|
+
raise Rooibos::Error::Invariant,
|
|
64
|
+
"Timeout is not Ractor-shareable: #{timeout.inspect}\n" \
|
|
65
|
+
"Use a number or Ractor.make_shareable."
|
|
66
|
+
end
|
|
58
67
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
"
|
|
62
|
-
|
|
63
|
-
end
|
|
68
|
+
# Parser validation
|
|
69
|
+
if parser && !parser.respond_to?(:call)
|
|
70
|
+
raise ArgumentError, "parser: must respond to :call"
|
|
71
|
+
end
|
|
64
72
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
73
|
+
if RatatuiRuby::Debug.enabled? && parser && !Ractor.shareable?(parser)
|
|
74
|
+
raise Rooibos::Error::Invariant,
|
|
75
|
+
"Parser is not Ractor-shareable: #{parser.inspect}\n" \
|
|
76
|
+
"Use a frozen Method object or Ractor.make_shareable."
|
|
77
|
+
end
|
|
69
78
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
"
|
|
73
|
-
|
|
74
|
-
end
|
|
79
|
+
# Method validation
|
|
80
|
+
unless %i[get post put patch delete].include?(method)
|
|
81
|
+
raise ArgumentError, "Unsupported HTTP method: #{method.inspect}"
|
|
82
|
+
end
|
|
75
83
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
84
|
+
instance = allocate
|
|
85
|
+
instance.__send__(:initialize, method:, url:, envelope:, headers:, body:, timeout: timeout || 10, parser:)
|
|
86
|
+
instance
|
|
79
87
|
end
|
|
80
|
-
|
|
81
|
-
instance = allocate
|
|
82
|
-
instance.__send__(:initialize, method:, url:, envelope:, headers:, body:, timeout: timeout || 10, parser:)
|
|
83
|
-
instance
|
|
84
88
|
end
|
|
85
89
|
|
|
86
90
|
# Net::HTTP is blocking; no cooperative cancellation possible.
|
|
87
91
|
# Grace period = 0 means runtime can force-kill immediately.
|
|
88
92
|
def rooibos_cancellation_grace_period = 0
|
|
89
93
|
|
|
90
|
-
def self.parse_dwim_args(args, method_kw, url_kw, envelope_kw, body_kw, method_keywords)
|
|
94
|
+
def self.parse_dwim_args(args, method_kw, url_kw, envelope_kw, body_kw, method_keywords) # :nodoc:
|
|
91
95
|
# Handle keyword method shortcuts: get: 'url'
|
|
92
96
|
if method_keywords.any?
|
|
93
97
|
method_key, url = method_keywords.first
|
|
@@ -19,8 +19,7 @@ module Rooibos
|
|
|
19
19
|
#
|
|
20
20
|
# The framework creates one instance at startup. All outlets share it.
|
|
21
21
|
class Lifecycle
|
|
22
|
-
# :nodoc: Internal representation of a tracked async command.
|
|
23
|
-
Entry = Data.define(:future, :origin)
|
|
22
|
+
Entry = Data.define(:future, :origin) # :nodoc: Internal representation of a tracked async command.
|
|
24
23
|
|
|
25
24
|
# Creates a lifecycle manager.
|
|
26
25
|
#
|
|
@@ -40,7 +39,7 @@ module Rooibos
|
|
|
40
39
|
# [token] Parent's cancellation token.
|
|
41
40
|
# [timeout] Max wait seconds for the result.
|
|
42
41
|
#
|
|
43
|
-
# Returns the child's message, or <tt>nil</tt> if
|
|
42
|
+
# Returns the child's message, or <tt>nil</tt> if canceled or timed out.
|
|
44
43
|
# Raises if the child raised.
|
|
45
44
|
def run_sync(command, token, timeout:)
|
|
46
45
|
return nil if token.canceled?
|
|
@@ -76,7 +75,7 @@ module Rooibos
|
|
|
76
75
|
#
|
|
77
76
|
# Spawns a future that executes the command. Tracks the command in the
|
|
78
77
|
# active map for cancellation support. Errors are pushed to the channel
|
|
79
|
-
# as <tt>
|
|
78
|
+
# as <tt>Message::Error</tt> messages.
|
|
80
79
|
#
|
|
81
80
|
# [command] Callable with <tt>call(out, token)</tt>.
|
|
82
81
|
# [channel] Channel to push results and errors to.
|
|
@@ -89,7 +88,7 @@ module Rooibos
|
|
|
89
88
|
future = Concurrent::Promises.future do
|
|
90
89
|
command.call(outlet, cancellation)
|
|
91
90
|
rescue => e
|
|
92
|
-
channel.push
|
|
91
|
+
channel.push Message::Error.new(command:, exception: e)
|
|
93
92
|
end
|
|
94
93
|
|
|
95
94
|
entry = Entry.new(future:, origin:)
|
|
@@ -114,6 +113,7 @@ module Rooibos
|
|
|
114
113
|
entry.future.wait(grace.finite? ? grace : nil)
|
|
115
114
|
|
|
116
115
|
@active.delete(command)
|
|
116
|
+
entry # Return so caller can remove from pending_futures
|
|
117
117
|
end
|
|
118
118
|
|
|
119
119
|
# Cancels all active commands and waits for them to complete.
|
|
@@ -0,0 +1,86 @@
|
|
|
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
|
+
# +open+ on macOS, +xdg-open+ on Linux, +start+ on Windows.
|
|
20
|
+
#
|
|
21
|
+
# On success (exit 0), sends +Message::Open+.
|
|
22
|
+
# On failure (non-zero), sends +Message::Error+.
|
|
23
|
+
#
|
|
24
|
+
# === Example
|
|
25
|
+
#
|
|
26
|
+
# def update(msg, model)
|
|
27
|
+
# case msg
|
|
28
|
+
# in :view_clicked
|
|
29
|
+
# [model, Command.open(model.selected_file)]
|
|
30
|
+
# in { type: :open, envelope: path }
|
|
31
|
+
# model.with(status: "Opened #{path}")
|
|
32
|
+
# in { type: :error, envelope: path }
|
|
33
|
+
# model.with(error: "Could not open #{path}")
|
|
34
|
+
# end
|
|
35
|
+
# end
|
|
36
|
+
#
|
|
37
|
+
class Open < Data.define(:path, :envelope)
|
|
38
|
+
include Custom
|
|
39
|
+
|
|
40
|
+
# System commands are generally fast; no grace period needed.
|
|
41
|
+
def rooibos_cancellation_grace_period = 0
|
|
42
|
+
|
|
43
|
+
# Executes the open command and sends the result message.
|
|
44
|
+
def call(out, token)
|
|
45
|
+
return if token.canceled?
|
|
46
|
+
|
|
47
|
+
require "open3"
|
|
48
|
+
cmd = self.class.__send__(:system_command, path)
|
|
49
|
+
_stdout, stderr, status = Open3.capture3(cmd)
|
|
50
|
+
|
|
51
|
+
message = if status.exitstatus == 0
|
|
52
|
+
Message::Open.new(envelope:)
|
|
53
|
+
else
|
|
54
|
+
error_msg = stderr.empty? ? "Failed to open: #{path}" : stderr.strip
|
|
55
|
+
Message::Error.new(
|
|
56
|
+
command: envelope,
|
|
57
|
+
exception: RuntimeError.new(error_msg.freeze).freeze
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
out.put(Ractor.make_shareable(message))
|
|
62
|
+
rescue => e
|
|
63
|
+
out.put(Ractor.make_shareable(Message::Error.new(
|
|
64
|
+
command: envelope,
|
|
65
|
+
exception: RuntimeError.new(e.message.freeze).freeze
|
|
66
|
+
)))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Builds the platform-specific open command.
|
|
70
|
+
def self.system_command(path, platform = RUBY_PLATFORM) # :nodoc:
|
|
71
|
+
escaped = path.shellescape
|
|
72
|
+
case platform
|
|
73
|
+
when /darwin/
|
|
74
|
+
"open #{escaped}"
|
|
75
|
+
when /linux/
|
|
76
|
+
"xdg-open #{escaped}"
|
|
77
|
+
when /mingw|mswin|cygwin/
|
|
78
|
+
"start #{path}"
|
|
79
|
+
else
|
|
80
|
+
"xdg-open #{escaped}"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
private_class_method :system_command
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
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,8 +15,8 @@ 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
|
#
|
|
@@ -28,7 +28,7 @@ module Rooibos
|
|
|
28
28
|
# [model.with(notification: "Saved!"), Command.wait(3.0, :dismiss)]
|
|
29
29
|
# in :dismiss
|
|
30
30
|
# [model.with(notification: nil), nil]
|
|
31
|
-
# in
|
|
31
|
+
# in Message::Canceled
|
|
32
32
|
# [model.with(notification: nil), nil] # User navigated away
|
|
33
33
|
# end
|
|
34
34
|
# end
|
|
@@ -57,7 +57,7 @@ module Rooibos
|
|
|
57
57
|
# Executes the timer.
|
|
58
58
|
#
|
|
59
59
|
# Waits for <tt>seconds</tt>, then sends <tt>TimerResponse</tt>.
|
|
60
|
-
# If
|
|
60
|
+
# If canceled, sends <tt>Message::Canceled</tt> instead.
|
|
61
61
|
#
|
|
62
62
|
# [out] Outlet for sending messages.
|
|
63
63
|
# [token] Cancellation token from the runtime.
|
|
@@ -68,7 +68,7 @@ module Rooibos
|
|
|
68
68
|
combined.origin.wait
|
|
69
69
|
|
|
70
70
|
if token.canceled?
|
|
71
|
-
out.put(
|
|
71
|
+
out.put(Message::Canceled.new(command: self))
|
|
72
72
|
else
|
|
73
73
|
elapsed = Time.now - start_time
|
|
74
74
|
response = Message::Timer.new(envelope:, elapsed:)
|