taski 0.9.0 → 0.9.2

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.
@@ -267,7 +267,7 @@ module Taski
267
267
  if failed_count > 0
268
268
  render_execution_failed(failed_count: failed_count, total_count: total_count, total_duration: total_duration, skipped_count: skipped_count)
269
269
  else
270
- render_execution_completed(completed_count: completed_count, total_count: total_count, total_duration: total_duration, skipped_count: skipped_count)
270
+ render_execution_completed(done_count: done_count, total_count: total_count, total_duration: total_duration, skipped_count: skipped_count)
271
271
  end
272
272
  end
273
273
 
@@ -372,8 +372,8 @@ module Taski
372
372
  end
373
373
 
374
374
  # Render execution complete event
375
- def render_execution_completed(completed_count:, total_count:, total_duration:, skipped_count: 0)
376
- execution = ExecutionDrop.new(state: :completed, completed_count:, total_count:, total_duration:, skipped_count:)
375
+ def render_execution_completed(done_count:, total_count:, total_duration:, skipped_count: 0)
376
+ execution = ExecutionDrop.new(state: :completed, done_count:, total_count:, total_duration:, skipped_count:)
377
377
  render_execution_template(:execution_complete, execution:)
378
378
  end
379
379
 
@@ -515,6 +515,28 @@ module Taski
515
515
  task_class.name || task_class.to_s
516
516
  end
517
517
 
518
+ # Start a periodic render loop in a background thread.
519
+ # Starts spinner timer and calls the given block at @theme.render_interval.
520
+ # @yield Block to execute each render cycle (called under @monitor.synchronize)
521
+ def render_loop(&block)
522
+ @render_thread_running = true
523
+ start_spinner_timer
524
+ @render_thread = Thread.new do
525
+ while @render_thread_running
526
+ @monitor.synchronize(&block)
527
+ sleep @theme.render_interval
528
+ end
529
+ end
530
+ end
531
+
532
+ # Stop the periodic render loop and spinner timer.
533
+ def stop_render_loop
534
+ @render_thread_running = false
535
+ @render_thread&.join
536
+ @render_thread = nil
537
+ stop_spinner_timer
538
+ end
539
+
518
540
  # Check if output is a TTY
519
541
  def tty?
520
542
  @output.tty?
@@ -36,9 +36,6 @@ module Taski
36
36
  def initialize(output: $stdout, theme: nil)
37
37
  theme ||= Theme::Compact.new
38
38
  super
39
- @renderer_thread = nil
40
- @running = false
41
- @running_mutex = Mutex.new
42
39
  end
43
40
 
44
41
  protected
@@ -70,38 +67,26 @@ module Taski
70
67
  end
71
68
 
72
69
  def handle_start
73
- @running_mutex.synchronize { @running = true }
74
- start_spinner_timer
75
70
  @output.print "\e[?25l" # Hide cursor
76
- @renderer_thread = Thread.new do
77
- loop do
78
- break unless @running_mutex.synchronize { @running }
79
- render_live
80
- sleep @theme.render_interval
81
- end
82
- end
71
+ render_loop { render_status_line }
83
72
  end
84
73
 
85
74
  def handle_stop
86
- @running_mutex.synchronize { @running = false }
87
- @renderer_thread&.join
88
- stop_spinner_timer
75
+ stop_render_loop
89
76
  @output.print "\e[?25h" # Show cursor
90
77
  render_final
91
78
  end
92
79
 
93
80
  private
94
81
 
95
- def render_live
96
- @monitor.synchronize do
97
- line = build_status_line
98
- # Truncate line to terminal width to prevent line wrap
99
- max_width = terminal_width - 1 # Leave space for cursor
100
- line = line[0, max_width] if line.length > max_width
101
- # Clear line and write new content
102
- @output.print "\r\e[K#{line}"
103
- @output.flush
104
- end
82
+ def render_status_line
83
+ line = build_status_line
84
+ # Truncate line to terminal width to prevent line wrap
85
+ max_width = terminal_width - 1 # Leave space for cursor
86
+ line = line[0, max_width] if line.length > max_width
87
+ # Clear line and write new content
88
+ @output.print "\r\e[K#{line}"
89
+ @output.flush
105
90
  end
106
91
 
107
92
  def terminal_width
@@ -113,9 +98,9 @@ module Taski
113
98
  def render_final
114
99
  @monitor.synchronize do
115
100
  line = if failed_count > 0
116
- render_execution_failed(failed_count: failed_count, total_count: total_count, total_duration: total_duration)
101
+ render_execution_failed(failed_count: failed_count, total_count: total_count, total_duration: total_duration, skipped_count: skipped_count)
117
102
  else
118
- render_execution_completed(completed_count: completed_count, total_count: total_count, total_duration: total_duration)
103
+ render_execution_completed(done_count: done_count, total_count: total_count, total_duration: total_duration, skipped_count: skipped_count)
119
104
  end
120
105
 
121
106
  @output.print "\r\e[K#{line}\n"
@@ -126,7 +111,7 @@ module Taski
126
111
  def build_status_line
127
112
  task_names = collect_current_task_names
128
113
 
129
- primary_task = running_tasks.keys.first || cleaning_tasks.keys.first
114
+ primary_task = running_tasks.keys.last || cleaning_tasks.keys.last
130
115
  task_stdout = build_task_stdout(primary_task)
131
116
 
132
117
  render_execution_running(
@@ -139,12 +124,13 @@ module Taski
139
124
 
140
125
  def collect_current_task_names
141
126
  # Prioritize: cleaning > running > pending
127
+ # Reverse so most recently started tasks appear first
142
128
  current_tasks = if cleaning_tasks.any?
143
- cleaning_tasks.keys
129
+ cleaning_tasks.keys.reverse
144
130
  elsif running_tasks.any?
145
- running_tasks.keys
131
+ running_tasks.keys.reverse
146
132
  elsif pending_tasks.any?
147
- pending_tasks.keys
133
+ pending_tasks.keys.reverse
148
134
  else
149
135
  []
150
136
  end
@@ -76,7 +76,7 @@ module Taski
76
76
  # failed_count, total_count, total_duration, root_task_name, task_names
77
77
  #
78
78
  # @example Using in Liquid template
79
- # [{{ execution.completed_count }}/{{ execution.total_count }}]
79
+ # [{{ execution.done_count }}/{{ execution.total_count }}]
80
80
  # {{ execution.total_duration | format_duration }}
81
81
  class ExecutionDrop < DataDrop; end
82
82
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../base"
4
+ require_relative "../../theme/detail"
5
+ require_relative "structure"
6
+
7
+ module Taski
8
+ module Progress
9
+ module Layout
10
+ module Tree
11
+ # Non-TTY event-driven tree layout.
12
+ # Outputs lines immediately with tree prefixes as events arrive.
13
+ # Used for logs, CI, piped output, and static tree display.
14
+ class Event < Base
15
+ include Structure
16
+
17
+ def initialize(output: $stderr, theme: nil)
18
+ theme ||= Theme::Detail.new
19
+ super
20
+ init_tree_structure
21
+ end
22
+
23
+ protected
24
+
25
+ def handle_ready
26
+ build_ready_tree
27
+ end
28
+
29
+ def handle_task_update(task_class, current_state, phase)
30
+ progress = @tasks[task_class]
31
+ duration = compute_duration(progress, phase)
32
+ text = render_for_task_event(task_class, current_state, duration, nil, phase)
33
+ output_with_prefix(task_class, text) if text
34
+ end
35
+
36
+ def handle_group_started(task_class, group_name, phase)
37
+ text = render_group_started(task_class, group_name: group_name)
38
+ output_with_prefix(task_class, text) if text
39
+ end
40
+
41
+ def handle_group_completed(task_class, group_name, phase, duration)
42
+ text = render_group_succeeded(task_class, group_name: group_name, task_duration: duration)
43
+ output_with_prefix(task_class, text) if text
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../base"
4
+ require_relative "../../theme/detail"
5
+ require_relative "structure"
6
+
7
+ module Taski
8
+ module Progress
9
+ module Layout
10
+ module Tree
11
+ # TTY periodic-update tree layout.
12
+ # Refreshes the entire tree display at regular intervals with spinner animation.
13
+ # Used for interactive terminal output.
14
+ class Live < Base
15
+ include Structure
16
+
17
+ def initialize(output: $stderr, theme: nil)
18
+ theme ||= Theme::Detail.new
19
+ super
20
+ init_tree_structure
21
+ @last_line_count = 0
22
+ end
23
+
24
+ protected
25
+
26
+ def handle_ready
27
+ build_ready_tree
28
+ end
29
+
30
+ # TTY mode: skip per-event output, tree is updated by render_loop
31
+ def handle_task_update(_task_class, _current_state, _phase)
32
+ end
33
+
34
+ def handle_group_started(_task_class, _group_name, _phase)
35
+ end
36
+
37
+ def handle_group_completed(_task_class, _group_name, _phase, _duration)
38
+ end
39
+
40
+ def should_activate?
41
+ tty?
42
+ end
43
+
44
+ def handle_start
45
+ @output.print "\e[?25l" # Hide cursor
46
+ render_loop { render_tree_live }
47
+ end
48
+
49
+ def handle_stop
50
+ stop_render_loop
51
+ @output.print "\e[?25h" # Show cursor
52
+ render_final
53
+ end
54
+
55
+ private
56
+
57
+ def render_tree_live
58
+ lines = build_tree_lines
59
+ clear_previous_output
60
+ lines.each { |line| @output.puts line }
61
+ @output.flush
62
+ @last_line_count = lines.size
63
+ end
64
+
65
+ def render_final
66
+ @monitor.synchronize do
67
+ lines = build_tree_lines
68
+ clear_previous_output
69
+
70
+ lines.each { |line| @output.puts line }
71
+ @output.puts render_execution_summary
72
+ @output.flush
73
+ end
74
+ end
75
+
76
+ def clear_previous_output
77
+ return if @last_line_count == 0
78
+ # Move cursor up and clear lines
79
+ @output.print "\e[#{@last_line_count}A\e[J"
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Taski
4
+ module Progress
5
+ module Layout
6
+ module Tree
7
+ # Shared tree structure logic for Tree::Live and Tree::Event.
8
+ # Provides tree building, node registration, prefix generation,
9
+ # and tree rendering methods.
10
+ module Structure
11
+ # Tree connector characters
12
+ BRANCH = "├── "
13
+ LAST_BRANCH = "└── "
14
+ VERTICAL = "│ "
15
+ SPACE = " "
16
+
17
+ # Returns the tree structure as a string.
18
+ # Uses the current theme to render task content for each node.
19
+ def render_tree
20
+ build_tree_lines.join("\n") + "\n"
21
+ end
22
+
23
+ protected
24
+
25
+ def init_tree_structure
26
+ @tree_nodes = {}
27
+ @node_depths = {}
28
+ @node_is_last = {}
29
+ end
30
+
31
+ def build_ready_tree
32
+ graph = context&.dependency_graph
33
+ root = context&.root_task_class
34
+ return unless graph && root
35
+
36
+ tree = build_tree_from_graph(root, graph)
37
+ register_tree_nodes(tree, depth: 0, is_last: true, ancestors_last: [])
38
+ end
39
+
40
+ # Output text with tree prefix for the given task
41
+ def output_with_prefix(task_class, text)
42
+ prefix = build_tree_prefix(task_class)
43
+ output_line("#{prefix}#{text}")
44
+ end
45
+
46
+ private
47
+
48
+ def build_tree_from_graph(task_class, graph, ancestors = Set.new)
49
+ is_circular = ancestors.include?(task_class)
50
+ node = {task_class: task_class, is_circular: is_circular, children: []}
51
+ return node if is_circular
52
+
53
+ new_ancestors = ancestors + [task_class]
54
+ deps = graph.dependencies_for(task_class)
55
+ deps.each do |dep|
56
+ child_node = build_tree_from_graph(dep, graph, new_ancestors)
57
+ node[:children] << child_node
58
+ end
59
+ node
60
+ end
61
+
62
+ def register_tree_nodes(node, depth:, is_last:, ancestors_last:)
63
+ return unless node
64
+
65
+ task_class = node[:task_class]
66
+ @tasks[task_class] ||= new_task_progress
67
+ @tree_nodes[task_class] = node
68
+ @node_depths[task_class] = depth
69
+ @node_is_last[task_class] = {is_last: is_last, ancestors_last: ancestors_last.dup}
70
+
71
+ children = node[:children]
72
+ children.each_with_index do |child, index|
73
+ child_is_last = (index == children.size - 1)
74
+ new_ancestors_last = ancestors_last + [is_last]
75
+ register_tree_nodes(child, depth: depth + 1, is_last: child_is_last, ancestors_last: new_ancestors_last)
76
+ end
77
+ end
78
+
79
+ def build_tree_lines
80
+ return [] unless @root_task_class
81
+
82
+ lines = []
83
+ root_node = @tree_nodes[@root_task_class]
84
+ build_node_lines(root_node, lines)
85
+ lines
86
+ end
87
+
88
+ def build_node_lines(node, lines)
89
+ return unless node
90
+
91
+ task_class = node[:task_class]
92
+ prefix = build_tree_prefix(task_class)
93
+ content = build_task_content(task_class)
94
+ lines << "#{prefix}#{content}"
95
+
96
+ node[:children].each do |child|
97
+ build_node_lines(child, lines)
98
+ end
99
+ end
100
+
101
+ def build_task_content(task_class)
102
+ progress = @tasks[task_class]
103
+
104
+ case progress&.dig(:run_state)
105
+ when :running
106
+ render_task_started(task_class)
107
+ when :completed
108
+ render_task_succeeded(task_class, task_duration: compute_duration(progress, :run))
109
+ when :failed
110
+ render_task_failed(task_class, error: nil)
111
+ when :skipped
112
+ render_task_skipped(task_class)
113
+ else
114
+ task = TaskDrop.new(name: task_class_name(task_class), state: :pending)
115
+ render_task_template(:task_pending, task:, execution: execution_drop)
116
+ end
117
+ end
118
+
119
+ def build_tree_prefix(task_class)
120
+ depth = @node_depths[task_class]
121
+ return "" if depth.nil? || depth == 0
122
+
123
+ last_info = @node_is_last[task_class]
124
+ return "" unless last_info
125
+
126
+ ancestors_last = last_info[:ancestors_last]
127
+ is_last = last_info[:is_last]
128
+
129
+ prefix = ""
130
+ # Skip the first ancestor (root) since root has no visual prefix
131
+ ancestors_last[1..].each do |ancestor_is_last|
132
+ prefix += ancestor_is_last ? SPACE : VERTICAL
133
+ end
134
+
135
+ prefix += is_last ? LAST_BRANCH : BRANCH
136
+ prefix
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end