fun_ci 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/README.md +3 -1
  4. data/exe/fun-ci-trigger +6 -6
  5. data/exe/fun-ci-tui +9 -4
  6. data/lib/fun_ci/animations/celebrate.rb +210 -0
  7. data/lib/fun_ci/animations/explosion.rb +138 -0
  8. data/lib/fun_ci/animations/flash.rb +205 -0
  9. data/lib/fun_ci/animations/idle.rb +127 -0
  10. data/lib/fun_ci/animations/leprechauns.rb +240 -0
  11. data/lib/fun_ci/animations/running.rb +129 -0
  12. data/lib/fun_ci/animations/success.rb +281 -0
  13. data/lib/fun_ci/animations/yay.rb +169 -0
  14. data/lib/fun_ci/cli.rb +20 -14
  15. data/lib/fun_ci/persistence/database.rb +52 -0
  16. data/lib/fun_ci/persistence/pipeline_recorder.rb +74 -0
  17. data/lib/fun_ci/persistence/pipeline_run.rb +59 -0
  18. data/lib/fun_ci/persistence/stage_job.rb +46 -0
  19. data/lib/fun_ci/{state_machine.rb → persistence/state_machine.rb} +3 -12
  20. data/lib/fun_ci/pipeline/background_wrapper.rb +27 -0
  21. data/lib/fun_ci/pipeline/pipeline_forker.rb +38 -0
  22. data/lib/fun_ci/pipeline/process_runner.rb +28 -0
  23. data/lib/fun_ci/pipeline/progress_reporter.rb +31 -0
  24. data/lib/fun_ci/pipeline/stage_runner.rb +85 -0
  25. data/lib/fun_ci/pipeline/stale_pipeline_canceller.rb +53 -0
  26. data/lib/fun_ci/pipeline/trigger.rb +153 -0
  27. data/lib/fun_ci/setup/hook_writer.rb +75 -0
  28. data/lib/fun_ci/setup/installer.rb +55 -0
  29. data/lib/fun_ci/setup/maven_linter_detector.rb +26 -0
  30. data/lib/fun_ci/setup/project_config.rb +42 -0
  31. data/lib/fun_ci/setup/project_detector.rb +22 -0
  32. data/lib/fun_ci/setup/setup_checker.rb +30 -0
  33. data/lib/fun_ci/setup/template_writer.rb +53 -0
  34. data/lib/fun_ci/tui/admin_tui.rb +90 -0
  35. data/lib/fun_ci/tui/animation.rb +49 -0
  36. data/lib/fun_ci/tui/animation_compositor.rb +107 -0
  37. data/lib/fun_ci/tui/animation_frames.rb +112 -0
  38. data/lib/fun_ci/tui/animation_library.rb +46 -0
  39. data/lib/fun_ci/tui/animation_renderer.rb +144 -0
  40. data/lib/fun_ci/tui/ansi.rb +34 -0
  41. data/lib/fun_ci/tui/board_data.rb +53 -0
  42. data/lib/fun_ci/tui/board_renderer.rb +105 -0
  43. data/lib/fun_ci/tui/duration_formatter.rb +24 -0
  44. data/lib/fun_ci/tui/header_animation_manager.rb +71 -0
  45. data/lib/fun_ci/tui/header_animation_player.rb +45 -0
  46. data/lib/fun_ci/tui/key_handler.rb +86 -0
  47. data/lib/fun_ci/tui/looping_animation_player.rb +45 -0
  48. data/lib/fun_ci/tui/relative_time.rb +22 -0
  49. data/lib/fun_ci/tui/row_formatter.rb +108 -0
  50. data/lib/fun_ci/tui/screen.rb +103 -0
  51. data/lib/fun_ci/tui/spinner.rb +24 -0
  52. data/lib/fun_ci/tui/stage_change_detector.rb +58 -0
  53. data/lib/fun_ci/tui/streak_counter.rb +29 -0
  54. data/lib/fun_ci/tui/terminal_input.rb +69 -0
  55. data/lib/fun_ci.rb +6 -6
  56. metadata +49 -28
  57. data/lib/fun_ci/admin_tui.rb +0 -226
  58. data/lib/fun_ci/ansi.rb +0 -21
  59. data/lib/fun_ci/background_wrapper.rb +0 -27
  60. data/lib/fun_ci/board_data.rb +0 -51
  61. data/lib/fun_ci/database.rb +0 -50
  62. data/lib/fun_ci/duration_formatter.rb +0 -23
  63. data/lib/fun_ci/hook_writer.rb +0 -73
  64. data/lib/fun_ci/installer.rb +0 -53
  65. data/lib/fun_ci/maven_linter_detector.rb +0 -24
  66. data/lib/fun_ci/pipeline_forker.rb +0 -36
  67. data/lib/fun_ci/pipeline_recorder.rb +0 -72
  68. data/lib/fun_ci/pipeline_run.rb +0 -57
  69. data/lib/fun_ci/progress_reporter.rb +0 -29
  70. data/lib/fun_ci/project_config.rb +0 -40
  71. data/lib/fun_ci/project_detector.rb +0 -18
  72. data/lib/fun_ci/relative_time.rb +0 -20
  73. data/lib/fun_ci/row_formatter.rb +0 -106
  74. data/lib/fun_ci/screen.rb +0 -81
  75. data/lib/fun_ci/setup_checker.rb +0 -28
  76. data/lib/fun_ci/spinner.rb +0 -22
  77. data/lib/fun_ci/stage_job.rb +0 -44
  78. data/lib/fun_ci/stage_runner.rb +0 -108
  79. data/lib/fun_ci/stale_pipeline_canceller.rb +0 -51
  80. data/lib/fun_ci/streak_counter.rb +0 -30
  81. data/lib/fun_ci/template_writer.rb +0 -51
  82. data/lib/fun_ci/trigger.rb +0 -150
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../animations/explosion"
4
+ require_relative "../animations/success"
5
+ require_relative "../animations/celebrate"
6
+ require_relative "../animations/flash"
7
+ require_relative "../animations/leprechauns"
8
+ require_relative "../animations/yay"
9
+ require_relative "../animations/idle"
10
+ require_relative "../animations/running"
11
+
12
+ module FunCi
13
+ module Tui
14
+ module AnimationLibrary
15
+ FAILURE = [Animations::Explosion::DATA].freeze
16
+
17
+ SUCCESS = [
18
+ Animations::Success::DATA,
19
+ Animations::Celebrate::DATA,
20
+ Animations::Flash::DATA,
21
+ Animations::Leprechauns::DATA,
22
+ Animations::Yay::DATA
23
+ ].freeze
24
+
25
+ IDLE = Animations::Idle::DATA
26
+
27
+ RUNNING = Animations::Running::DATA
28
+
29
+ def self.random_failure
30
+ FAILURE.sample
31
+ end
32
+
33
+ def self.random_success
34
+ SUCCESS.sample
35
+ end
36
+
37
+ def self.idle
38
+ IDLE
39
+ end
40
+
41
+ def self.running
42
+ RUNNING
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "animation"
4
+ require_relative "animation_compositor"
5
+ require_relative "animation_library"
6
+ require_relative "header_animation_manager"
7
+ require_relative "stage_change_detector"
8
+
9
+ module FunCi
10
+ module Tui
11
+ class AnimationRenderer
12
+ HEADER_HEIGHT = HeaderAnimationManager::HEADER_HEIGHT
13
+
14
+ def initialize(animation_library: AnimationLibrary)
15
+ @previous_runs = []
16
+ @animations = []
17
+ @header_manager = HeaderAnimationManager.new(animation_library: animation_library)
18
+ end
19
+
20
+ def render(screen, runs)
21
+ detect_and_queue(runs)
22
+
23
+ screen.save_cursor
24
+ render_header_overlay(screen)
25
+ render_stage_overlays(screen, runs)
26
+ render_footer_overlay(screen, runs)
27
+ screen.restore_cursor
28
+
29
+ advance_all
30
+ expire_finished
31
+ end
32
+
33
+ def any_active?
34
+ @animations.any? { |a| !a.finished? } || @header_manager.any_active_event?
35
+ end
36
+
37
+ def active_count
38
+ @animations.count { |a| !a.finished? }
39
+ end
40
+
41
+ private
42
+
43
+ def detect_and_queue(runs)
44
+ changes = StageChangeDetector.detect(@previous_runs, runs)
45
+ @previous_runs = snapshot(runs)
46
+ changes.each { |change| queue_animation(change, runs) }
47
+ update_running_state(runs)
48
+ end
49
+
50
+ def update_running_state(runs)
51
+ any_running = runs.any? { |r| r[:status] == "running" }
52
+ any_running ? @header_manager.start_running : @header_manager.stop_running
53
+ end
54
+
55
+ def queue_animation(change, runs)
56
+ run_status = runs.find { |r| r[:id] == change.run_id }&.dig(:status)
57
+
58
+ case change.to
59
+ when "failed"
60
+ add_animation(:failure, change.run_id, change.stage)
61
+ @header_manager.trigger_failure
62
+ when "timed_out"
63
+ add_animation(:timeout, change.run_id, change.stage)
64
+ when "completed"
65
+ if run_status == "completed"
66
+ add_animation(:success, change.run_id, change.stage)
67
+ @header_manager.trigger_success
68
+ else
69
+ add_animation(:stage_pass, change.run_id, change.stage)
70
+ end
71
+ end
72
+ end
73
+
74
+ def add_animation(type, run_id, stage)
75
+ @animations.reject! do |a|
76
+ a.type == type && a.run_id == run_id && a.stage == stage
77
+ end
78
+ @animations << Animation.new(type: type, run_id: run_id, stage: stage)
79
+ end
80
+
81
+ def render_header_overlay(screen)
82
+ @header_manager.current_lines(screen.width).each_with_index do |line, i|
83
+ screen.write_at(1 + i, 1, line)
84
+ end
85
+ end
86
+
87
+ def render_footer_overlay(screen, runs)
88
+ anim = highest_priority(:has_footer?)
89
+ return unless anim
90
+
91
+ footer_row = HEADER_HEIGHT + (runs.length * 2) + 1
92
+ overlay = AnimationCompositor.footer_overlay(anim, screen.width)
93
+ screen.write_at(footer_row, 1, "#{overlay}\e[K") if overlay
94
+ end
95
+
96
+ def render_stage_overlays(screen, runs)
97
+ @animations.each do |anim|
98
+ next if anim.finished?
99
+
100
+ run_index = runs.index { |r| r[:id] == anim.run_id }
101
+ next unless run_index
102
+
103
+ row = HEADER_HEIGHT + 1 + (run_index * 2)
104
+ run = runs[run_index]
105
+ text = AnimationCompositor.stage_text_for(run, anim.stage)
106
+ next unless text
107
+
108
+ overlay = AnimationCompositor.stage_column_overlay(anim, text, 0)
109
+ next unless overlay
110
+
111
+ col = AnimationCompositor.stage_col_for(run, anim.stage)
112
+ screen.write_at(row, col, overlay) if col
113
+ end
114
+ end
115
+
116
+ def highest_priority(predicate)
117
+ @animations
118
+ .select { |a| a.send(predicate) && !a.finished? }
119
+ .max_by(&:priority)
120
+ end
121
+
122
+ def advance_all
123
+ @animations.each { |a| a.advance! unless a.finished? }
124
+ @header_manager.advance!
125
+ end
126
+
127
+ def expire_finished
128
+ @animations.reject!(&:finished?)
129
+ @header_manager.expire_if_finished!
130
+ end
131
+
132
+ def snapshot(runs)
133
+ runs.map do |r|
134
+ {
135
+ id: r[:id],
136
+ stages: (r[:stages] || []).map do |s|
137
+ { stage: s[:stage], status: s[:status] }
138
+ end
139
+ }
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Tui
5
+ module Ansi
6
+ RESET = "\e[0m"
7
+
8
+ def self.green(text) = "\e[32m#{text}#{RESET}"
9
+ def self.bold_green(text) = "\e[1;32m#{text}#{RESET}"
10
+ def self.bold_red(text) = "\e[1;31m#{text}#{RESET}"
11
+ def self.bold_yellow(text) = "\e[1;33m#{text}#{RESET}"
12
+ def self.cyan(text) = "\e[36m#{text}#{RESET}"
13
+ def self.bold_cyan(text) = "\e[1;36m#{text}#{RESET}"
14
+ def self.dim(text) = "\e[2m#{text}#{RESET}"
15
+ def self.bold_white(text) = "\e[1;37m#{text}#{RESET}"
16
+ def self.white(text) = "\e[37m#{text}#{RESET}"
17
+ def self.yellow(text) = "\e[33m#{text}#{RESET}"
18
+ def self.dim_green(text) = "\e[2;32m#{text}#{RESET}"
19
+ def self.dim_red(text) = "\e[2;31m#{text}#{RESET}"
20
+ def self.orange(text) = "\e[38;5;208m#{text}#{RESET}"
21
+ def self.dark_red(text) = "\e[38;5;52m#{text}#{RESET}"
22
+ def self.bg_charcoal(text) = "\e[48;5;236m#{text}#{RESET}"
23
+ def self.bg_dark_red(text) = "\e[48;5;124m#{text}#{RESET}"
24
+ def self.bg_orange(text) = "\e[48;5;166m#{text}#{RESET}"
25
+ def self.bg_very_dark_red(text) = "\e[48;5;52m#{text}#{RESET}"
26
+ def self.bg_dark_green(text) = "\e[48;5;22m#{text}#{RESET}"
27
+ def self.bg_dark_yellow(text) = "\e[48;5;58m#{text}#{RESET}"
28
+
29
+ def self.strip(text)
30
+ text.gsub(/\e\[[0-9;]*[A-Za-z]/, "")
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "../persistence/pipeline_run"
5
+ require_relative "../persistence/stage_job"
6
+ require_relative "streak_counter"
7
+
8
+ module FunCi
9
+ module Tui
10
+ class BoardData
11
+ def initialize(db, limit: 15, page_size: nil)
12
+ @db = db
13
+ @page_size = page_size || limit
14
+ @limit = @page_size
15
+ end
16
+
17
+ def load_more
18
+ @limit += @page_size
19
+ end
20
+
21
+ def runs
22
+ pipeline_runs = Persistence::PipelineRun.recent(@db, limit: @limit)
23
+ pipeline_runs.map { |run| enrich_with_stages(run) }
24
+ end
25
+
26
+ def streak
27
+ pipeline_runs = Persistence::PipelineRun.recent(@db, limit: @limit)
28
+ StreakCounter.count(pipeline_runs)
29
+ end
30
+
31
+ def cancel_run(run_id)
32
+ Persistence::PipelineRun.update_status(@db, run_id, "cancelled")
33
+ end
34
+
35
+ private
36
+
37
+ def enrich_with_stages(run)
38
+ rows = @db.execute(
39
+ "SELECT id, pipeline_run_id, stage, status, started_at, completed_at FROM stage_jobs WHERE pipeline_run_id = ? ORDER BY id",
40
+ [run[:id]]
41
+ )
42
+
43
+ stages = rows.map do |row|
44
+ job = { id: row[0], pipeline_run_id: row[1], stage: row[2], status: row[3], started_at: row[4], completed_at: row[5] }
45
+ duration = Persistence::StageJob.elapsed_duration(job)
46
+ { stage: job[:stage], status: job[:status], duration: duration, started_at: job[:started_at] }
47
+ end
48
+
49
+ run.merge(stages: stages)
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "header_animation_manager"
4
+ require_relative "row_formatter"
5
+ require_relative "streak_counter"
6
+
7
+ module FunCi
8
+ module Tui
9
+ class BoardRenderer
10
+ HEADER_HEIGHT = HeaderAnimationManager::HEADER_HEIGHT
11
+
12
+ def initialize(screen:, spinner:, animation_renderer:, height_provider:)
13
+ @screen = screen
14
+ @spinner = spinner
15
+ @animation_renderer = animation_renderer
16
+ @height_provider = height_provider
17
+ end
18
+
19
+ def render(runs:, streak:, cursor_index:, confirming:)
20
+ update_height
21
+ render_header_area(runs, streak)
22
+
23
+ if runs.empty?
24
+ @screen.render_empty_state
25
+ @screen.render_footer(empty: true)
26
+ else
27
+ rows = runs.map { |run| format_run(run) }
28
+ rows = truncate_rows_to_height(rows)
29
+ @screen.render_board(rows, cursor_index: cursor_index)
30
+ @screen.println unless rows.empty?
31
+ @screen.render_footer(empty: false, confirming: confirming)
32
+ end
33
+
34
+ @screen.clear_below
35
+ render_animations(runs)
36
+ end
37
+
38
+ def begin_frame
39
+ @screen.move_cursor_home
40
+ @spinner.advance!
41
+ end
42
+
43
+ def clear
44
+ @screen.clear
45
+ end
46
+
47
+ def resize(new_width)
48
+ @screen.width = new_width
49
+ end
50
+
51
+ def animation_renderer?
52
+ !!@animation_renderer
53
+ end
54
+
55
+ private
56
+
57
+ def render_header_area(runs, streak)
58
+ if @animation_renderer
59
+ @screen.write_at(HEADER_HEIGHT + 1, 1, "")
60
+ else
61
+ streak_text = StreakCounter.format_text(streak)
62
+ @screen.render_header(streak_text: streak_text)
63
+ (HEADER_HEIGHT - 1).times { @screen.println }
64
+ end
65
+ end
66
+
67
+ def format_run(run)
68
+ opts = {}
69
+ if run[:status] == "running"
70
+ active_stage = run[:stages].find { |s| s[:status] == "running" }
71
+ if active_stage && active_stage[:started_at]
72
+ opts[:elapsed_seconds] = Time.now - Time.parse(active_stage[:started_at])
73
+ end
74
+ opts[:spinner_frame] = @spinner.current_frame
75
+ end
76
+ RowFormatter.format(run, **opts)
77
+ end
78
+
79
+ def render_animations(runs)
80
+ return unless @animation_renderer
81
+
82
+ @animation_renderer.render(@screen, runs)
83
+ end
84
+
85
+ def update_height
86
+ return unless @height_provider
87
+
88
+ height = @height_provider.call
89
+ @screen.height = height if height
90
+ end
91
+
92
+ def truncate_rows_to_height(rows)
93
+ height = @screen.height
94
+ return rows unless height
95
+
96
+ # Line budget: N rows consume 2N-1 lines (row + separator between rows).
97
+ # Remaining height after header, post-board separator, and footer:
98
+ # available = height - HEADER_HEIGHT - 2
99
+ # Solving 2N - 1 <= available gives N <= (available) / 2.
100
+ max_rows = [(height - HEADER_HEIGHT - 2) / 2, 0].max
101
+ rows.first(max_rows)
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Tui
5
+ module DurationFormatter
6
+ def self.format(seconds)
7
+ if seconds >= 60
8
+ mins = (seconds / 60).to_i
9
+ secs = (seconds % 60).to_i
10
+ "#{mins}m#{secs.to_s.rjust(2, "0")}"
11
+ elsif seconds == seconds.to_i
12
+ "#{seconds.to_i}s"
13
+ else
14
+ "#{format_decimal(seconds)}s"
15
+ end
16
+ end
17
+
18
+ def self.format_decimal(value)
19
+ sprintf("%.1f", value)
20
+ end
21
+ private_class_method :format_decimal
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "header_animation_player"
4
+ require_relative "looping_animation_player"
5
+
6
+ module FunCi
7
+ module Tui
8
+ class HeaderAnimationManager
9
+ HEADER_HEIGHT = 14
10
+
11
+ def initialize(animation_library:)
12
+ @idle_player = LoopingAnimationPlayer.new(animation_library.idle)
13
+ @running_player = nil
14
+ @header_player = nil
15
+ @animation_library = animation_library
16
+ end
17
+
18
+ def trigger_failure
19
+ @header_player = HeaderAnimationPlayer.new(@animation_library.random_failure)
20
+ end
21
+
22
+ def trigger_success
23
+ @header_player = HeaderAnimationPlayer.new(@animation_library.random_success)
24
+ end
25
+
26
+ def start_running
27
+ @running_player ||= LoopingAnimationPlayer.new(@animation_library.running)
28
+ end
29
+
30
+ def stop_running
31
+ @running_player = nil
32
+ end
33
+
34
+ def current_lines(width)
35
+ lines = active_player.current_lines(width)
36
+ pad_to_height(lines, HEADER_HEIGHT)
37
+ end
38
+
39
+ def advance!
40
+ @idle_player.advance!
41
+ @running_player&.advance!
42
+ @header_player&.advance!
43
+ end
44
+
45
+ def expire_if_finished!
46
+ @header_player = nil if @header_player&.finished?
47
+ end
48
+
49
+ def any_active_event?
50
+ @header_player && !@header_player.finished?
51
+ end
52
+
53
+ private
54
+
55
+ def active_player
56
+ return @header_player if any_active_event?
57
+ return @running_player if @running_player
58
+
59
+ @idle_player
60
+ end
61
+
62
+ def pad_to_height(lines, height)
63
+ return lines if lines.length >= height
64
+
65
+ top_pad = (height - lines.length) / 2
66
+ blank = "\e[K"
67
+ Array.new(top_pad, blank) + lines + Array.new(height - lines.length - top_pad, blank)
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ansi"
4
+
5
+ module FunCi
6
+ module Tui
7
+ class HeaderAnimationPlayer
8
+ attr_reader :frame
9
+
10
+ def initialize(data)
11
+ @data = data
12
+ @frame = 0
13
+ end
14
+
15
+ def advance!
16
+ @frame += 1
17
+ end
18
+
19
+ def finished?
20
+ @frame >= total_frames
21
+ end
22
+
23
+ def total_frames
24
+ @data[:frames].length
25
+ end
26
+
27
+ def current_lines(width)
28
+ return [] if finished?
29
+
30
+ frame_lines = @data[:frames][@frame]
31
+ return [] unless frame_lines
32
+
33
+ frame_lines.map { |line| center_line(line, width) }
34
+ end
35
+
36
+ private
37
+
38
+ def center_line(line, width)
39
+ visible_len = Ansi.strip(line).length
40
+ pad = [(width - visible_len) / 2, 0].max
41
+ "#{" " * pad}#{line}\e[K"
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Tui
5
+ class KeyHandler
6
+ attr_reader :cursor_index
7
+
8
+ def initialize(board_data:)
9
+ @board_data = board_data
10
+ @cursor_index = nil
11
+ @confirm_cancel = nil
12
+ end
13
+
14
+ def handle_key(key)
15
+ if @confirm_cancel
16
+ handle_confirm_key(key)
17
+ return
18
+ end
19
+
20
+ case key
21
+ when "q"
22
+ :quit
23
+ when "j", :down
24
+ move_cursor_down
25
+ when "k", :up
26
+ move_cursor_up
27
+ when "c"
28
+ initiate_cancel
29
+ end
30
+ end
31
+
32
+ def confirming?
33
+ !@confirm_cancel.nil?
34
+ end
35
+
36
+ def confirmation_run
37
+ @confirm_cancel
38
+ end
39
+
40
+ private
41
+
42
+ def move_cursor_down
43
+ runs = @board_data.runs
44
+ return if runs.empty?
45
+
46
+ if @cursor_index.nil?
47
+ @cursor_index = 0
48
+ elsif @cursor_index < runs.length - 1
49
+ @cursor_index += 1
50
+ @board_data.load_more if @cursor_index == runs.length - 1
51
+ end
52
+ end
53
+
54
+ def move_cursor_up
55
+ return if @cursor_index.nil?
56
+
57
+ @cursor_index -= 1 if @cursor_index > 0
58
+ end
59
+
60
+ def initiate_cancel
61
+ return if @cursor_index.nil?
62
+
63
+ runs = @board_data.runs
64
+ return if @cursor_index >= runs.length
65
+
66
+ run = runs[@cursor_index]
67
+ case run[:status]
68
+ when "scheduled"
69
+ @board_data.cancel_run(run[:id])
70
+ when "running"
71
+ @confirm_cancel = run
72
+ end
73
+ end
74
+
75
+ def handle_confirm_key(key)
76
+ case key
77
+ when "y"
78
+ @board_data.cancel_run(@confirm_cancel[:id])
79
+ @confirm_cancel = nil
80
+ when "n", :escape
81
+ @confirm_cancel = nil
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ansi"
4
+
5
+ module FunCi
6
+ module Tui
7
+ class LoopingAnimationPlayer
8
+ def initialize(data)
9
+ @data = data
10
+ @frame = 0
11
+ end
12
+
13
+ def advance!
14
+ @frame += 1
15
+ end
16
+
17
+ def frame
18
+ @frame % total_frames
19
+ end
20
+
21
+ def finished?
22
+ false
23
+ end
24
+
25
+ def total_frames
26
+ @data[:frames].length
27
+ end
28
+
29
+ def current_lines(width)
30
+ frame_lines = @data[:frames][frame]
31
+ return [] unless frame_lines
32
+
33
+ frame_lines.map { |line| center_line(line, width) }
34
+ end
35
+
36
+ private
37
+
38
+ def center_line(line, width)
39
+ visible_len = Ansi.strip(line).length
40
+ pad = [(width - visible_len) / 2, 0].max
41
+ "#{" " * pad}#{line}\e[K"
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module FunCi
6
+ module Tui
7
+ module RelativeTime
8
+ def self.format(iso_timestamp, now: Time.now)
9
+ elapsed = now - Time.parse(iso_timestamp)
10
+ seconds = elapsed.to_i
11
+
12
+ if seconds < 60
13
+ "just now"
14
+ elsif seconds < 3600
15
+ "#{seconds / 60}m ago"
16
+ else
17
+ "#{seconds / 3600}h ago"
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end