taski 0.2.2 → 0.3.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 +130 -7
- data/examples/README.md +13 -1
- data/examples/section_configuration.rb +212 -0
- data/examples/tree_demo.rb +125 -0
- data/lib/taski/dependency_analyzer.rb +104 -45
- data/lib/taski/exceptions.rb +3 -0
- data/lib/taski/logger.rb +7 -62
- data/lib/taski/logging/formatter_factory.rb +34 -0
- data/lib/taski/logging/formatter_interface.rb +19 -0
- data/lib/taski/logging/json_formatter.rb +26 -0
- data/lib/taski/logging/simple_formatter.rb +16 -0
- data/lib/taski/logging/structured_formatter.rb +44 -0
- data/lib/taski/progress/display_colors.rb +17 -0
- data/lib/taski/progress/display_manager.rb +115 -0
- data/lib/taski/progress/output_capture.rb +105 -0
- data/lib/taski/progress/spinner_animation.rb +46 -0
- data/lib/taski/progress/task_formatter.rb +25 -0
- data/lib/taski/progress/task_status.rb +38 -0
- data/lib/taski/progress/terminal_controller.rb +35 -0
- data/lib/taski/progress_display.rb +23 -320
- data/lib/taski/section.rb +268 -0
- data/lib/taski/task/base.rb +11 -32
- data/lib/taski/task/dependency_resolver.rb +4 -64
- data/lib/taski/task/instance_management.rb +28 -15
- data/lib/taski/tree_colors.rb +91 -0
- data/lib/taski/utils/dependency_resolver_helper.rb +85 -0
- data/lib/taski/utils/tree_display_helper.rb +71 -0
- data/lib/taski/version.rb +1 -1
- data/lib/taski.rb +4 -0
- metadata +20 -3
@@ -15,52 +15,79 @@ module Taski
|
|
15
15
|
file_path, line_number = source_location
|
16
16
|
return [] unless File.exist?(file_path)
|
17
17
|
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
unless result.success?
|
22
|
-
Taski.logger.error("Parse errors in source file",
|
23
|
-
file: file_path,
|
24
|
-
errors: result.errors.map(&:message),
|
25
|
-
method: "#{klass}##{method_name}")
|
26
|
-
return []
|
27
|
-
end
|
18
|
+
parse_source_file(file_path, line_number, klass, method_name)
|
19
|
+
end
|
28
20
|
|
29
|
-
|
30
|
-
if result.warnings.any?
|
31
|
-
Taski.logger.warn("Parse warnings in source file",
|
32
|
-
file: file_path,
|
33
|
-
warnings: result.warnings.map(&:message),
|
34
|
-
method: "#{klass}##{method_name}")
|
35
|
-
end
|
21
|
+
private
|
36
22
|
|
37
|
-
|
38
|
-
|
23
|
+
# Parse source file and extract dependencies with proper error handling
|
24
|
+
# @param file_path [String] Path to source file
|
25
|
+
# @param line_number [Integer] Line number of method definition
|
26
|
+
# @param klass [Class] Class containing the method
|
27
|
+
# @param method_name [Symbol] Method name being analyzed
|
28
|
+
# @return [Array<Class>] Array of dependency classes
|
29
|
+
def parse_source_file(file_path, line_number, klass, method_name)
|
30
|
+
result = Prism.parse_file(file_path)
|
31
|
+
handle_parse_errors(result, file_path, klass, method_name)
|
32
|
+
extract_dependencies_from_node(result.value, line_number, klass, method_name)
|
33
|
+
rescue IOError, SystemCallError => e
|
34
|
+
Taski.logger.error("Failed to read source file",
|
35
|
+
file: file_path,
|
36
|
+
error: e.message,
|
37
|
+
method: "#{klass}##{method_name}")
|
38
|
+
[]
|
39
|
+
rescue => e
|
40
|
+
Taski.logger.error("Failed to analyze method dependencies",
|
41
|
+
class: klass.name,
|
42
|
+
method: method_name,
|
43
|
+
error: e.message,
|
44
|
+
error_class: e.class.name)
|
45
|
+
[]
|
46
|
+
end
|
39
47
|
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
48
|
+
# Handle parse errors and warnings from Prism parsing
|
49
|
+
# @param result [Prism::ParseResult] Parse result from Prism
|
50
|
+
# @param file_path [String] Path to source file
|
51
|
+
# @param klass [Class] Class containing the method
|
52
|
+
# @param method_name [Symbol] Method name being analyzed
|
53
|
+
# @return [Array] Empty array if errors found
|
54
|
+
# @raise [RuntimeError] If parse fails
|
55
|
+
def handle_parse_errors(result, file_path, klass, method_name)
|
56
|
+
unless result.success?
|
57
|
+
Taski.logger.error("Parse errors in source file",
|
58
|
+
file: file_path,
|
59
|
+
errors: result.errors.map(&:message),
|
60
|
+
method: "#{klass}##{method_name}")
|
61
|
+
return []
|
62
|
+
end
|
45
63
|
|
46
|
-
|
47
|
-
|
48
|
-
Taski.logger.
|
64
|
+
# Handle warnings if present
|
65
|
+
if result.warnings.any?
|
66
|
+
Taski.logger.warn("Parse warnings in source file",
|
49
67
|
file: file_path,
|
50
|
-
|
68
|
+
warnings: result.warnings.map(&:message),
|
51
69
|
method: "#{klass}##{method_name}")
|
52
|
-
[]
|
53
|
-
rescue => e
|
54
|
-
Taski.logger.error("Failed to analyze method dependencies",
|
55
|
-
class: klass.name,
|
56
|
-
method: method_name,
|
57
|
-
error: e.message,
|
58
|
-
error_class: e.class.name)
|
59
|
-
[]
|
60
70
|
end
|
61
71
|
end
|
62
72
|
|
63
|
-
|
73
|
+
# Extract dependencies from parsed AST node
|
74
|
+
# @param root_node [Prism::Node] Root AST node
|
75
|
+
# @param line_number [Integer] Line number of method definition
|
76
|
+
# @param klass [Class] Class containing the method
|
77
|
+
# @param method_name [Symbol] Method name being analyzed
|
78
|
+
# @return [Array<Class>] Array of unique dependency classes
|
79
|
+
def extract_dependencies_from_node(root_node, line_number, klass, method_name)
|
80
|
+
dependencies = []
|
81
|
+
method_node = find_method_node(root_node, method_name, line_number)
|
82
|
+
|
83
|
+
if method_node
|
84
|
+
visitor = TaskDependencyVisitor.new(klass)
|
85
|
+
visitor.visit(method_node)
|
86
|
+
dependencies = visitor.dependencies
|
87
|
+
end
|
88
|
+
|
89
|
+
dependencies.uniq
|
90
|
+
end
|
64
91
|
|
65
92
|
def find_method_node(node, method_name, target_line)
|
66
93
|
return nil unless node
|
@@ -96,9 +123,10 @@ module Taski
|
|
96
123
|
class TaskDependencyVisitor < Prism::Visitor
|
97
124
|
attr_reader :dependencies
|
98
125
|
|
99
|
-
def initialize
|
126
|
+
def initialize(context_class = nil)
|
100
127
|
@dependencies = []
|
101
128
|
@constant_cache = {}
|
129
|
+
@context_class = context_class
|
102
130
|
end
|
103
131
|
|
104
132
|
def visit_constant_read_node(node)
|
@@ -135,14 +163,19 @@ module Taski
|
|
135
163
|
return @dependencies << cached_result if cached_result # Cached positive result
|
136
164
|
|
137
165
|
begin
|
166
|
+
resolved_class = nil
|
167
|
+
|
168
|
+
# 1. Try absolute reference first (existing logic)
|
138
169
|
if Object.const_defined?(const_name)
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
170
|
+
resolved_class = Object.const_get(const_name)
|
171
|
+
# 2. Try relative reference within namespace context
|
172
|
+
elsif @context_class
|
173
|
+
resolved_class = resolve_relative_constant(const_name)
|
174
|
+
end
|
175
|
+
|
176
|
+
if resolved_class&.is_a?(Class) && (resolved_class < Taski::Task || resolved_class < Taski::Section)
|
177
|
+
@constant_cache[const_name] = resolved_class
|
178
|
+
@dependencies << resolved_class
|
146
179
|
else
|
147
180
|
@constant_cache[const_name] = false
|
148
181
|
end
|
@@ -151,6 +184,32 @@ module Taski
|
|
151
184
|
end
|
152
185
|
end
|
153
186
|
|
187
|
+
def resolve_relative_constant(const_name)
|
188
|
+
return nil unless @context_class
|
189
|
+
|
190
|
+
# Get the namespace from the context class
|
191
|
+
namespace = get_namespace_from_class(@context_class)
|
192
|
+
return nil unless namespace
|
193
|
+
|
194
|
+
# Try to resolve the constant within the namespace
|
195
|
+
full_const_name = "#{namespace}::#{const_name}"
|
196
|
+
Object.const_get(full_const_name) if Object.const_defined?(full_const_name)
|
197
|
+
rescue NameError, ArgumentError
|
198
|
+
nil
|
199
|
+
end
|
200
|
+
|
201
|
+
def get_namespace_from_class(klass)
|
202
|
+
# Extract namespace from class name (e.g., "A::AB" -> "A")
|
203
|
+
class_name = klass.name
|
204
|
+
return nil unless class_name&.include?("::")
|
205
|
+
|
206
|
+
# Split by "::" and take all but the last part
|
207
|
+
parts = class_name.split("::")
|
208
|
+
return nil if parts.length <= 1 # No namespace
|
209
|
+
|
210
|
+
parts[0..-2].join("::")
|
211
|
+
end
|
212
|
+
|
154
213
|
def extract_constant_path(node)
|
155
214
|
case node
|
156
215
|
when Prism::ConstantReadNode
|
data/lib/taski/exceptions.rb
CHANGED
data/lib/taski/logger.rb
CHANGED
@@ -1,5 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require_relative "logging/formatter_factory"
|
4
|
+
|
3
5
|
module Taski
|
4
6
|
# Enhanced logging functionality for Taski framework
|
5
7
|
# Provides structured logging with multiple levels and context information
|
@@ -13,7 +15,7 @@ module Taski
|
|
13
15
|
def initialize(level: :info, output: $stdout, format: :structured)
|
14
16
|
@level = level
|
15
17
|
@output = output
|
16
|
-
@
|
18
|
+
@formatter = Logging::FormatterFactory.create(format)
|
17
19
|
@start_time = Time.now
|
18
20
|
end
|
19
21
|
|
@@ -103,21 +105,15 @@ module Taski
|
|
103
105
|
|
104
106
|
private
|
105
107
|
|
106
|
-
# Core logging method
|
108
|
+
# Core logging method using Strategy Pattern
|
107
109
|
# @param level [Symbol] Log level
|
108
110
|
# @param message [String] Log message
|
109
111
|
# @param context [Hash] Additional context
|
110
112
|
def log(level, message, context)
|
111
113
|
return unless should_log?(level)
|
112
114
|
|
113
|
-
|
114
|
-
|
115
|
-
log_simple(level, message, context)
|
116
|
-
when :structured
|
117
|
-
log_structured(level, message, context)
|
118
|
-
when :json
|
119
|
-
log_json(level, message, context)
|
120
|
-
end
|
115
|
+
formatted_line = @formatter.format(level, message, context, @start_time)
|
116
|
+
@output.puts formatted_line
|
121
117
|
end
|
122
118
|
|
123
119
|
# Check if message should be logged based on current level
|
@@ -126,57 +122,6 @@ module Taski
|
|
126
122
|
def should_log?(level)
|
127
123
|
LEVELS[@level] <= LEVELS[level]
|
128
124
|
end
|
129
|
-
|
130
|
-
# Simple log format: [LEVEL] message
|
131
|
-
def log_simple(level, message, context)
|
132
|
-
@output.puts "[#{level.upcase}] #{message}"
|
133
|
-
end
|
134
|
-
|
135
|
-
# Structured log format with timestamp and context
|
136
|
-
def log_structured(level, message, context)
|
137
|
-
timestamp = Time.now.strftime("%Y-%m-%d %H:%M:%S.%3N")
|
138
|
-
elapsed = ((Time.now - @start_time) * 1000).round(1)
|
139
|
-
|
140
|
-
line = "[#{timestamp}] [#{elapsed}ms] #{level.to_s.upcase.ljust(5)} Taski: #{message}"
|
141
|
-
|
142
|
-
unless context.empty?
|
143
|
-
context_parts = context.map do |key, value|
|
144
|
-
"#{key}=#{format_value(value)}"
|
145
|
-
end
|
146
|
-
line += " (#{context_parts.join(", ")})"
|
147
|
-
end
|
148
|
-
|
149
|
-
@output.puts line
|
150
|
-
end
|
151
|
-
|
152
|
-
# JSON log format for structured logging systems
|
153
|
-
def log_json(level, message, context)
|
154
|
-
require "json"
|
155
|
-
|
156
|
-
log_entry = {
|
157
|
-
timestamp: Time.now.iso8601(3),
|
158
|
-
level: level.to_s,
|
159
|
-
logger: "taski",
|
160
|
-
message: message,
|
161
|
-
elapsed_ms: ((Time.now - @start_time) * 1000).round(1)
|
162
|
-
}.merge(context)
|
163
|
-
|
164
|
-
@output.puts JSON.generate(log_entry)
|
165
|
-
end
|
166
|
-
|
167
|
-
# Format values for structured logging
|
168
|
-
def format_value(value)
|
169
|
-
case value
|
170
|
-
when String
|
171
|
-
(value.length > 50) ? "#{value[0..47]}..." : value
|
172
|
-
when Array
|
173
|
-
(value.size > 5) ? "[#{value[0..4].join(", ")}, ...]" : value.inspect
|
174
|
-
when Hash
|
175
|
-
(value.size > 3) ? "{#{value.keys[0..2].join(", ")}, ...}" : value.inspect
|
176
|
-
else
|
177
|
-
value.inspect
|
178
|
-
end
|
179
|
-
end
|
180
125
|
end
|
181
126
|
|
182
127
|
class << self
|
@@ -189,7 +134,7 @@ module Taski
|
|
189
134
|
# Get the current progress display instance (always enabled)
|
190
135
|
# @return [ProgressDisplay] Current progress display instance
|
191
136
|
def progress_display
|
192
|
-
@progress_display ||= ProgressDisplay.new
|
137
|
+
@progress_display ||= ProgressDisplay.new
|
193
138
|
end
|
194
139
|
|
195
140
|
# Configure the logger with new settings
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "simple_formatter"
|
4
|
+
require_relative "structured_formatter"
|
5
|
+
require_relative "json_formatter"
|
6
|
+
|
7
|
+
module Taski
|
8
|
+
module Logging
|
9
|
+
# Factory for creating log formatters
|
10
|
+
class FormatterFactory
|
11
|
+
# Create a formatter instance based on format symbol
|
12
|
+
# @param format [Symbol] Format type (:simple, :structured, :json)
|
13
|
+
# @return [FormatterInterface] Formatter instance
|
14
|
+
def self.create(format)
|
15
|
+
case format
|
16
|
+
when :simple
|
17
|
+
SimpleFormatter.new
|
18
|
+
when :structured
|
19
|
+
StructuredFormatter.new
|
20
|
+
when :json
|
21
|
+
JsonFormatter.new
|
22
|
+
else
|
23
|
+
raise ArgumentError, "Unknown format: #{format}. Valid formats: :simple, :structured, :json"
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
# Get list of available formats
|
28
|
+
# @return [Array<Symbol>] Available format symbols
|
29
|
+
def self.available_formats
|
30
|
+
[:simple, :structured, :json]
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Taski
|
4
|
+
module Logging
|
5
|
+
# Interface for log formatters
|
6
|
+
# All formatters must implement the format method
|
7
|
+
module FormatterInterface
|
8
|
+
# Format a log entry
|
9
|
+
# @param level [Symbol] Log level (:debug, :info, :warn, :error)
|
10
|
+
# @param message [String] Log message
|
11
|
+
# @param context [Hash] Additional context information
|
12
|
+
# @param start_time [Time] Logger start time for elapsed calculation
|
13
|
+
# @return [String] Formatted log line
|
14
|
+
def format(level, message, context, start_time)
|
15
|
+
raise NotImplementedError, "Subclass must implement format method"
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "formatter_interface"
|
4
|
+
|
5
|
+
module Taski
|
6
|
+
module Logging
|
7
|
+
# JSON log formatter for structured logging systems
|
8
|
+
class JsonFormatter
|
9
|
+
include FormatterInterface
|
10
|
+
|
11
|
+
def format(level, message, context, start_time)
|
12
|
+
require "json"
|
13
|
+
|
14
|
+
log_entry = {
|
15
|
+
timestamp: Time.now.iso8601(3),
|
16
|
+
level: level.to_s,
|
17
|
+
logger: "taski",
|
18
|
+
message: message,
|
19
|
+
elapsed_ms: ((Time.now - start_time) * 1000).round(1)
|
20
|
+
}.merge(context)
|
21
|
+
|
22
|
+
JSON.generate(log_entry)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "formatter_interface"
|
4
|
+
|
5
|
+
module Taski
|
6
|
+
module Logging
|
7
|
+
# Simple log formatter: [LEVEL] message
|
8
|
+
class SimpleFormatter
|
9
|
+
include FormatterInterface
|
10
|
+
|
11
|
+
def format(level, message, context, start_time)
|
12
|
+
"[#{level.upcase}] #{message}"
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "formatter_interface"
|
4
|
+
|
5
|
+
module Taski
|
6
|
+
module Logging
|
7
|
+
# Structured log formatter with timestamp and context
|
8
|
+
class StructuredFormatter
|
9
|
+
include FormatterInterface
|
10
|
+
|
11
|
+
def format(level, message, context, start_time)
|
12
|
+
timestamp = Time.now.strftime("%Y-%m-%d %H:%M:%S.%3N")
|
13
|
+
elapsed = ((Time.now - start_time) * 1000).round(1)
|
14
|
+
|
15
|
+
line = "[#{timestamp}] [#{elapsed}ms] #{level.to_s.upcase.ljust(5)} Taski: #{message}"
|
16
|
+
|
17
|
+
unless context.empty?
|
18
|
+
context_parts = context.map do |key, value|
|
19
|
+
"#{key}=#{format_value(value)}"
|
20
|
+
end
|
21
|
+
line += " (#{context_parts.join(", ")})"
|
22
|
+
end
|
23
|
+
|
24
|
+
line
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
# Format values for structured logging
|
30
|
+
def format_value(value)
|
31
|
+
case value
|
32
|
+
when String
|
33
|
+
(value.length > 50) ? "#{value[0..47]}..." : value
|
34
|
+
when Array
|
35
|
+
(value.size > 5) ? "[#{value[0..4].join(", ")}, ...]" : value.inspect
|
36
|
+
when Hash
|
37
|
+
(value.size > 3) ? "{#{value.keys[0..2].join(", ")}, ...}" : value.inspect
|
38
|
+
else
|
39
|
+
value.inspect
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Taski
|
4
|
+
module Progress
|
5
|
+
# Color constants for progress display
|
6
|
+
module DisplayColors
|
7
|
+
COLORS = {
|
8
|
+
reset: "\033[0m",
|
9
|
+
bold: "\033[1m",
|
10
|
+
dim: "\033[2m",
|
11
|
+
cyan: "\033[36m",
|
12
|
+
green: "\033[32m",
|
13
|
+
red: "\033[31m"
|
14
|
+
}.freeze
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,115 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "task_status"
|
4
|
+
require_relative "task_formatter"
|
5
|
+
|
6
|
+
module Taski
|
7
|
+
module Progress
|
8
|
+
# Manages the display state and coordinates display updates
|
9
|
+
class DisplayManager
|
10
|
+
def initialize(terminal, spinner, output_capture, include_captured_output: false)
|
11
|
+
@terminal = terminal
|
12
|
+
@spinner = spinner
|
13
|
+
@output_capture = output_capture
|
14
|
+
@include_captured_output = include_captured_output
|
15
|
+
@formatter = TaskFormatter.new
|
16
|
+
@completed_tasks = []
|
17
|
+
@current_display_lines = 0
|
18
|
+
end
|
19
|
+
|
20
|
+
def start_task_display(task_name)
|
21
|
+
clear_current_display
|
22
|
+
@output_capture.start
|
23
|
+
start_spinner_display(task_name)
|
24
|
+
end
|
25
|
+
|
26
|
+
def complete_task_display(task_name, duration:)
|
27
|
+
status = TaskStatus.new(name: task_name, duration: duration)
|
28
|
+
finish_task_display(status)
|
29
|
+
end
|
30
|
+
|
31
|
+
def fail_task_display(task_name, error:, duration:)
|
32
|
+
status = TaskStatus.new(name: task_name, duration: duration, error: error)
|
33
|
+
finish_task_display(status)
|
34
|
+
end
|
35
|
+
|
36
|
+
def clear_all_displays
|
37
|
+
@spinner.stop
|
38
|
+
@output_capture.stop
|
39
|
+
clear_current_display
|
40
|
+
|
41
|
+
# Display final summary of all completed tasks
|
42
|
+
if @completed_tasks.any?
|
43
|
+
@completed_tasks.each do |status|
|
44
|
+
@terminal.puts @formatter.format_completed_task(status)
|
45
|
+
end
|
46
|
+
@terminal.flush
|
47
|
+
end
|
48
|
+
|
49
|
+
@completed_tasks.clear
|
50
|
+
@current_display_lines = 0
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
|
55
|
+
def start_spinner_display(task_name)
|
56
|
+
@spinner.start(@terminal, task_name) do |spinner_char, name|
|
57
|
+
display_current_state(spinner_char, name)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def display_current_state(spinner_char, task_name)
|
62
|
+
clear_current_display
|
63
|
+
|
64
|
+
lines_count = 0
|
65
|
+
|
66
|
+
# Only display current task with spinner (no past completed tasks during execution)
|
67
|
+
@terminal.puts @formatter.format_current_task(spinner_char, task_name)
|
68
|
+
lines_count += 1
|
69
|
+
|
70
|
+
# Display output lines
|
71
|
+
@output_capture.last_lines.each do |line|
|
72
|
+
@terminal.puts @formatter.format_output_line(line)
|
73
|
+
lines_count += 1
|
74
|
+
end
|
75
|
+
|
76
|
+
@current_display_lines = lines_count
|
77
|
+
@terminal.flush
|
78
|
+
end
|
79
|
+
|
80
|
+
def finish_task_display(status)
|
81
|
+
@spinner.stop
|
82
|
+
|
83
|
+
# Capture output before stopping
|
84
|
+
captured_output = @output_capture.last_lines
|
85
|
+
@output_capture.stop
|
86
|
+
clear_current_display
|
87
|
+
|
88
|
+
# Include captured output if requested (typically for test environments)
|
89
|
+
if @include_captured_output && captured_output.any?
|
90
|
+
captured_output.each do |line|
|
91
|
+
@terminal.puts line.chomp
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
@completed_tasks << status
|
96
|
+
display_final_state
|
97
|
+
end
|
98
|
+
|
99
|
+
def display_final_state
|
100
|
+
# Only display the newly completed task (last one)
|
101
|
+
if @completed_tasks.any?
|
102
|
+
latest_task = @completed_tasks.last
|
103
|
+
@terminal.puts @formatter.format_completed_task(latest_task)
|
104
|
+
end
|
105
|
+
@terminal.flush
|
106
|
+
@current_display_lines = 1 # Only one line for the latest task
|
107
|
+
end
|
108
|
+
|
109
|
+
def clear_current_display
|
110
|
+
@terminal.clear_lines(@current_display_lines)
|
111
|
+
@current_display_lines = 0
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
@@ -0,0 +1,105 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Taski
|
4
|
+
module Progress
|
5
|
+
# Captures stdout and maintains last N lines like tail -f
|
6
|
+
class OutputCapture
|
7
|
+
MAX_LINES = 10
|
8
|
+
DISPLAY_LINES = 5
|
9
|
+
|
10
|
+
def initialize(main_output)
|
11
|
+
@main_output = main_output
|
12
|
+
@buffer = []
|
13
|
+
@capturing = false
|
14
|
+
@original_stdout = nil
|
15
|
+
@pipe_reader = nil
|
16
|
+
@pipe_writer = nil
|
17
|
+
@capture_thread = nil
|
18
|
+
end
|
19
|
+
|
20
|
+
def start
|
21
|
+
return if @capturing
|
22
|
+
|
23
|
+
@buffer.clear
|
24
|
+
setup_stdout_redirection
|
25
|
+
@capturing = true
|
26
|
+
|
27
|
+
start_capture_thread
|
28
|
+
end
|
29
|
+
|
30
|
+
def stop
|
31
|
+
return unless @capturing
|
32
|
+
|
33
|
+
@capturing = false
|
34
|
+
|
35
|
+
# Restore stdout
|
36
|
+
restore_stdout
|
37
|
+
|
38
|
+
# Clean up pipes and thread
|
39
|
+
cleanup_capture_thread
|
40
|
+
cleanup_pipes
|
41
|
+
end
|
42
|
+
|
43
|
+
def last_lines
|
44
|
+
@buffer.last(DISPLAY_LINES)
|
45
|
+
end
|
46
|
+
|
47
|
+
def capturing?
|
48
|
+
@capturing
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
def setup_stdout_redirection
|
54
|
+
@original_stdout = $stdout
|
55
|
+
@pipe_reader, @pipe_writer = IO.pipe
|
56
|
+
$stdout = @pipe_writer
|
57
|
+
end
|
58
|
+
|
59
|
+
def restore_stdout
|
60
|
+
return unless @original_stdout
|
61
|
+
|
62
|
+
$stdout = @original_stdout
|
63
|
+
@original_stdout = nil
|
64
|
+
end
|
65
|
+
|
66
|
+
def start_capture_thread
|
67
|
+
@capture_thread = Thread.new do
|
68
|
+
while (line = @pipe_reader.gets)
|
69
|
+
line = line.chomp
|
70
|
+
next if line.empty?
|
71
|
+
next if skip_line?(line)
|
72
|
+
|
73
|
+
add_line_to_buffer(line)
|
74
|
+
end
|
75
|
+
rescue IOError
|
76
|
+
# Pipe closed, normal termination
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def skip_line?(line)
|
81
|
+
# Skip logger lines (they appear separately)
|
82
|
+
line.match?(/^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}\]/)
|
83
|
+
end
|
84
|
+
|
85
|
+
def add_line_to_buffer(line)
|
86
|
+
@buffer << line
|
87
|
+
@buffer.shift while @buffer.length > MAX_LINES
|
88
|
+
end
|
89
|
+
|
90
|
+
def cleanup_capture_thread
|
91
|
+
@capture_thread&.join(0.1)
|
92
|
+
@capture_thread = nil
|
93
|
+
end
|
94
|
+
|
95
|
+
def cleanup_pipes
|
96
|
+
[@pipe_writer, @pipe_reader].each do |pipe|
|
97
|
+
pipe&.close
|
98
|
+
rescue IOError
|
99
|
+
# Already closed, ignore
|
100
|
+
end
|
101
|
+
@pipe_writer = @pipe_reader = nil
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|