ratatui_ruby-devtools 0.1.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 (90) hide show
  1. checksums.yaml +7 -0
  2. data/.builds/ruby-4.0.yml +38 -0
  3. data/.pre-commit-config.yaml +16 -0
  4. data/.rubocop.yml +8 -0
  5. data/AGENTS.md +72 -0
  6. data/CHANGELOG.md +23 -0
  7. data/LICENSE +661 -0
  8. data/LICENSES/AGPL-3.0-or-later.txt +661 -0
  9. data/LICENSES/CC-BY-SA-4.0.txt +427 -0
  10. data/LICENSES/CC0-1.0.txt +121 -0
  11. data/LICENSES/MIT-0.txt +16 -0
  12. data/LICENSES/MIT.txt +18 -0
  13. data/README.md +199 -0
  14. data/REUSE.toml +18 -0
  15. data/Rakefile +13 -0
  16. data/bin/agent_rake +13 -0
  17. data/bin/announce +13 -0
  18. data/bin/console +14 -0
  19. data/bin/consolidate_md +13 -0
  20. data/bin/hbs +13 -0
  21. data/bin/setup +17 -0
  22. data/doc/contributors/documentation_style.md +121 -0
  23. data/doc/custom.css +22 -0
  24. data/exe/agent_rake +96 -0
  25. data/exe/announce +1120 -0
  26. data/exe/consolidate_md +246 -0
  27. data/exe/hbs +670 -0
  28. data/exe/scaffold +662 -0
  29. data/lib/ratatui_ruby/devtools/tasks/autodoc/examples.rb +133 -0
  30. data/lib/ratatui_ruby/devtools/tasks/autodoc/member.rb +116 -0
  31. data/lib/ratatui_ruby/devtools/tasks/autodoc/name.rb +33 -0
  32. data/lib/ratatui_ruby/devtools/tasks/autodoc.rake +21 -0
  33. data/lib/ratatui_ruby/devtools/tasks/bump/cargo_lockfile.rb +38 -0
  34. data/lib/ratatui_ruby/devtools/tasks/bump/changelog.rb +67 -0
  35. data/lib/ratatui_ruby/devtools/tasks/bump/header.rb +43 -0
  36. data/lib/ratatui_ruby/devtools/tasks/bump/history.rb +50 -0
  37. data/lib/ratatui_ruby/devtools/tasks/bump/links.rb +78 -0
  38. data/lib/ratatui_ruby/devtools/tasks/bump/manifest.rb +63 -0
  39. data/lib/ratatui_ruby/devtools/tasks/bump/ruby_gem.rb +77 -0
  40. data/lib/ratatui_ruby/devtools/tasks/bump/sem_ver.rb +63 -0
  41. data/lib/ratatui_ruby/devtools/tasks/bump/unreleased_section.rb +75 -0
  42. data/lib/ratatui_ruby/devtools/tasks/bump.rake +80 -0
  43. data/lib/ratatui_ruby/devtools/tasks/cargo.rake +47 -0
  44. data/lib/ratatui_ruby/devtools/tasks/doc.rake +887 -0
  45. data/lib/ratatui_ruby/devtools/tasks/example_viewer.html.erb +172 -0
  46. data/lib/ratatui_ruby/devtools/tasks/license/headers_md.rb +276 -0
  47. data/lib/ratatui_ruby/devtools/tasks/license/headers_rb.rb +236 -0
  48. data/lib/ratatui_ruby/devtools/tasks/license/license_utils.rb +143 -0
  49. data/lib/ratatui_ruby/devtools/tasks/license/snippets_md.rb +353 -0
  50. data/lib/ratatui_ruby/devtools/tasks/license/snippets_rdoc.rb +186 -0
  51. data/lib/ratatui_ruby/devtools/tasks/license.rake +91 -0
  52. data/lib/ratatui_ruby/devtools/tasks/lint.rake +84 -0
  53. data/lib/ratatui_ruby/devtools/tasks/rdoc_config.rb +45 -0
  54. data/lib/ratatui_ruby/devtools/tasks/resources/build.yml.erb +54 -0
  55. data/lib/ratatui_ruby/devtools/tasks/resources/rubies.yml +7 -0
  56. data/lib/ratatui_ruby/devtools/tasks/reuse.rake +104 -0
  57. data/lib/ratatui_ruby/devtools/tasks/sourcehut.rake +94 -0
  58. data/lib/ratatui_ruby/devtools/tasks/test.rake +18 -0
  59. data/lib/ratatui_ruby/devtools/templates/.builds/ruby.yml.erb +47 -0
  60. data/lib/ratatui_ruby/devtools/templates/.gitignore.erb +18 -0
  61. data/lib/ratatui_ruby/devtools/templates/.pre-commit-config.yaml.erb +16 -0
  62. data/lib/ratatui_ruby/devtools/templates/.rubocop.yml.erb +8 -0
  63. data/lib/ratatui_ruby/devtools/templates/AGENTS.md.erb +65 -0
  64. data/lib/ratatui_ruby/devtools/templates/CHANGELOG.md.erb +18 -0
  65. data/lib/ratatui_ruby/devtools/templates/Gemfile.erb +32 -0
  66. data/lib/ratatui_ruby/devtools/templates/README.md.erb +127 -0
  67. data/lib/ratatui_ruby/devtools/templates/REUSE.toml.erb +33 -0
  68. data/lib/ratatui_ruby/devtools/templates/Rakefile.erb +29 -0
  69. data/lib/ratatui_ruby/devtools/templates/bin/console.erb +18 -0
  70. data/lib/ratatui_ruby/devtools/templates/bin/setup.erb +24 -0
  71. data/lib/ratatui_ruby/devtools/templates/doc/concepts/application_architecture.md.erb +16 -0
  72. data/lib/ratatui_ruby/devtools/templates/doc/concepts/application_testing.md.erb +49 -0
  73. data/lib/ratatui_ruby/devtools/templates/doc/custom.css.erb +24 -0
  74. data/lib/ratatui_ruby/devtools/templates/doc/getting_started/quickstart.md.erb +56 -0
  75. data/lib/ratatui_ruby/devtools/templates/doc/images/.gitkeep +0 -0
  76. data/lib/ratatui_ruby/devtools/templates/doc/index.md.erb +25 -0
  77. data/lib/ratatui_ruby/devtools/templates/exe/.gitkeep +0 -0
  78. data/lib/ratatui_ruby/devtools/templates/gemspec.erb +58 -0
  79. data/lib/ratatui_ruby/devtools/templates/mise.toml.erb +12 -0
  80. data/lib/ratatui_ruby/devtools/templates/tasks/example_viewer.html.erb +174 -0
  81. data/lib/ratatui_ruby/devtools/templates/tasks/resources/build.yml.erb +62 -0
  82. data/lib/ratatui_ruby/devtools/templates/tasks/resources/index.html.erb +46 -0
  83. data/lib/ratatui_ruby/devtools/templates/tasks/resources/rubies.yml.erb +9 -0
  84. data/lib/ratatui_ruby/devtools/templates/vendor/goodcop/base.yml +1047 -0
  85. data/lib/ratatui_ruby/devtools/version.rb +13 -0
  86. data/lib/ratatui_ruby/devtools.rb +137 -0
  87. data/mise.toml +7 -0
  88. data/sig/ratatui_ruby/devtools.rbs +15 -0
  89. data/vendor/goodcop/base.yml +1047 -0
  90. metadata +252 -0
data/exe/announce ADDED
@@ -0,0 +1,1120 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+
7
+ ####
8
+ # ======================================================================
9
+ # announce_tui - TUI for release announcements (experimental)
10
+ # ======================================================================
11
+ #
12
+ # SYNOPSIS
13
+ # bin/announce_tui [OPTIONS] [VERSION]
14
+ #
15
+ # DESCRIPTION
16
+ # A TUI for managing release announcements. Displays email preview,
17
+ # commit preview, and a release checklist with action controls.
18
+ #
19
+ # OPTIONS
20
+ # -w, --wiki-dir PATH Path to wiki repo (default: ../ratatui_ruby-wiki)
21
+ # -n, --builds N Number of builds to show (default: 4)
22
+ # -h, --help Show this help message
23
+ #
24
+ # ======================================================================
25
+
26
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
27
+ require "ratatui_ruby"
28
+ require "uri"
29
+ require "optparse"
30
+ require "fileutils"
31
+ require "tmpdir"
32
+
33
+ # A path flag value with display methods.
34
+ #
35
+ # CLI flags need both full and abbreviated display. Paths are long. Home
36
+ # directory abbreviation improves readability.
37
+ #
38
+ # [value] The path string.
39
+ PathFlag = Data.define(:value) do
40
+ # Returns the full path value.
41
+ def to_s = value
42
+
43
+ # Returns an abbreviated path using ~ for home directory.
44
+ def to_short
45
+ home = Dir.home
46
+ value.start_with?(home) ? value.sub(home, "~") : value
47
+ end
48
+ end
49
+
50
+ # A boolean flag value with display methods.
51
+ #
52
+ # CLI flags need string representation for display. This wraps a boolean
53
+ # with <tt>to_s</tt> and <tt>to_short</tt> methods.
54
+ #
55
+ # [value] The boolean value.
56
+ BoolFlag = Data.define(:value) do
57
+ # Returns "true" or "false".
58
+ def to_s = value.to_s
59
+
60
+ # Returns "yes" or "no".
61
+ def to_short = value ? "yes" : "no"
62
+ end
63
+
64
+ # An integer flag value with display methods.
65
+ #
66
+ # CLI flags need string representation for display. This wraps an integer
67
+ # with <tt>to_s</tt> and <tt>to_short</tt> methods.
68
+ #
69
+ # [value] The integer value.
70
+ IntFlag = Data.define(:value) do
71
+ # Returns the integer as a string.
72
+ def to_s = value.to_s
73
+
74
+ # Returns the integer as a string.
75
+ def to_short = value.to_s
76
+ end
77
+
78
+ # Global CLI flags singleton.
79
+ #
80
+ # CLI options need to be accessible throughout the application. Passing them
81
+ # through every method call is verbose. A module-level singleton provides
82
+ # clean access.
83
+ module CLIFlags
84
+ class << self
85
+ # Whether to perform a dry run (no side effects).
86
+ attr_accessor :dry_run
87
+
88
+ # Path to the wiki repository.
89
+ attr_accessor :wiki_dir
90
+
91
+ # Number of builds to display.
92
+ attr_accessor :builds
93
+
94
+ # Version to announce.
95
+ attr_accessor :version
96
+ end
97
+
98
+ self.dry_run = BoolFlag.new(false)
99
+ self.builds = IntFlag.new(4)
100
+ end
101
+
102
+ # Reads and parses release announcement files.
103
+ #
104
+ # Release announcements live in the wiki repo as markdown files. They contain
105
+ # a Subject line and body for mailing list posts. Parsing them manually each
106
+ # time is tedious.
107
+ #
108
+ # This class reads announcement files, extracts subject and body, and generates
109
+ # commit messages. It checks both stable and devel announcement directories.
110
+ #
111
+ # Use it to preview and send release emails.
112
+ class Announcement
113
+ # The email subject line.
114
+ attr_reader :subject
115
+
116
+ # The email body content.
117
+ attr_reader :body
118
+
119
+ # The file path to the announcement.
120
+ attr_reader :path
121
+
122
+ # Creates an Announcement for a version.
123
+ #
124
+ # [wiki_dir] Path to the wiki repository.
125
+ # [version] The version string (e.g., "v1.2.3").
126
+ def initialize(wiki_dir, version)
127
+ # Prefer announcements/ (major/minor) over devel-announcements/ (patches)
128
+ stable_path = File.join(wiki_dir, "announcements", "#{version}.md")
129
+ devel_path = File.join(wiki_dir, "devel-announcements", "#{version}.md")
130
+ @path = File.exist?(stable_path) ? stable_path : devel_path
131
+ @subject = nil
132
+ @body = nil
133
+ parse
134
+ end
135
+
136
+ # Checks whether the announcement file exists on disk.
137
+ #
138
+ # The UI previews announcement content. Missing files produce errors or empty
139
+ # previews. Check this before accessing subject or body to avoid confusion.
140
+ def exists?
141
+ File.exist?(@path)
142
+ end
143
+
144
+ # Generates a conventional commit message for this announcement.
145
+ #
146
+ # Announcements get committed to the wiki. Consistent commit messages aid
147
+ # changelog generation and git log readability. This generates the message
148
+ # following project conventions.
149
+ def commit_message
150
+ <<~MSG
151
+ docs: add #{File.basename(@path, '.md')} release announcement
152
+
153
+ Generated with [Antigravity](https://antigravity.google)
154
+
155
+ Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
156
+ MSG
157
+ end
158
+
159
+ private def parse
160
+ unless exists?
161
+ @subject = "(Announcement file not found)"
162
+ @body = "Expected file: #{@path}"
163
+ return
164
+ end
165
+
166
+ content = File.read(@path)
167
+ lines = content.lines
168
+
169
+ subject_line = lines.find { |l| l.start_with?("Subject: ") }
170
+ if subject_line
171
+ @subject = subject_line.sub(/^Subject: /, "").strip
172
+ subject_index = lines.index(subject_line)
173
+ body_lines = lines[(subject_index + 1)..]
174
+ .reject { |l| l.match?(/^<!--/) || l.match?(/-->/) }
175
+ @body = body_lines.join.strip
176
+ else
177
+ @subject = "(No subject found)"
178
+ @body = content
179
+ end
180
+ end
181
+ end
182
+
183
+ # Queries git repository state for release verification.
184
+ #
185
+ # Releases require the correct git tag to exist and be pushed. Checking these
186
+ # conditions manually is error-prone. Network latency makes synchronous checks
187
+ # slow.
188
+ #
189
+ # This class queries local and remote tags. It spawns background processes for
190
+ # network operations. It caches results for responsive UI.
191
+ #
192
+ # Use it to verify release preconditions.
193
+ class GitRepo
194
+ # Temp file for background check results.
195
+ CACHE_FILE = File.join(Dir.tmpdir, "ratatui_git_tag_pushed.txt")
196
+
197
+ # The latest git tag in the repository.
198
+ attr_reader :latest_tag
199
+
200
+ # The expected version tag (e.g., "v1.2.3").
201
+ attr_reader :expected_version
202
+
203
+ # Whether the tag has been pushed to origin.
204
+ attr_reader :tag_pushed
205
+
206
+ # Creates a new GitRepo and starts background checks.
207
+ def initialize
208
+ @expected_version = "v#{RatatuiRuby::VERSION}"
209
+ @latest_tag = `git describe --tags --abbrev=0 2>/dev/null`.strip
210
+ @latest_tag = nil if @latest_tag.empty?
211
+ @tag_pushed = nil
212
+ @loading = true
213
+ @check_pid = nil
214
+
215
+ # Spawn background process BEFORE TUI enters raw mode
216
+ start_background_check if tag_matches?
217
+ end
218
+
219
+ # Compares the latest git tag against the expected version.
220
+ #
221
+ # Releases require the correct tag. A mismatch indicates the tag wasn't
222
+ # created or the wrong branch is checked out. Check this before proceeding
223
+ # with release steps.
224
+ def tag_matches?
225
+ @latest_tag == @expected_version
226
+ end
227
+
228
+ # Indicates whether background network checks are in progress.
229
+ #
230
+ # The TUI shows loading indicators while fetching remote data. Displaying
231
+ # stale data confuses users. Poll this to update the loading UI and detect
232
+ # when fresh data arrives.
233
+ def loading?
234
+ return false unless @loading
235
+
236
+ # Poll for background process completion
237
+ if @check_pid
238
+ _pid, status = Process.waitpid2(@check_pid, Process::WNOHANG)
239
+ if status
240
+ # Process completed, read result
241
+ @tag_pushed = File.exist?(CACHE_FILE) && File.read(CACHE_FILE).strip == "true"
242
+ @loading = false
243
+ @check_pid = nil
244
+ end
245
+ end
246
+ @loading
247
+ end
248
+
249
+ # Fetches tag information from git synchronously.
250
+ #
251
+ # The user triggers manual refresh. Synchronous fetch blocks but guarantees
252
+ # fresh data. Use this for explicit refresh actions, not TUI polling loops.
253
+ def refresh
254
+ @loading = true
255
+ @latest_tag = `git describe --tags --abbrev=0 2>/dev/null`.strip
256
+ @latest_tag = nil if @latest_tag.empty?
257
+ @tag_pushed = check_tag_pushed_sync
258
+ @loading = false
259
+ end
260
+
261
+ # Refreshes tag information in the background.
262
+ #
263
+ # The TUI polls for fresh data while rendering. Synchronous network calls
264
+ # freeze the interface. This method spawns a background process so the UI
265
+ # stays responsive.
266
+ def refresh_async
267
+ @loading = true
268
+ start_background_check if tag_matches?
269
+ end
270
+
271
+ private def start_background_check
272
+ # Spawn process that runs git and writes result to temp file
273
+ @check_pid = Process.spawn(
274
+ "git ls-remote --tags origin 2>/dev/null | grep -q '#{@expected_version}' && echo true > #{CACHE_FILE} || echo false > #{CACHE_FILE}"
275
+ )
276
+ end
277
+
278
+ private def check_tag_pushed_sync
279
+ return false unless tag_matches?
280
+ remote_tags = `git ls-remote --tags origin 2>/dev/null`.strip
281
+ remote_tags.include?(@expected_version)
282
+ end
283
+ end
284
+
285
+ # Queries SourceHut build status.
286
+ #
287
+ # Releases should wait for CI to pass. Checking SourceHut manually is tedious.
288
+ # Network latency makes synchronous checks slow.
289
+ #
290
+ # This class queries build status via the hut CLI. It refreshes in background
291
+ # threads. It reports aggregate pass/fail/running status.
292
+ #
293
+ # Use it to verify CI status before releasing.
294
+ class SourceHut
295
+ # Array of build hashes with :id and :status keys.
296
+ attr_reader :builds
297
+
298
+ # Creates a new SourceHut query.
299
+ #
300
+ # [max_builds] Maximum number of builds to track.
301
+ def initialize(max_builds: 4)
302
+ @max_builds = max_builds
303
+ @builds = []
304
+ @loading = true
305
+ refresh_async
306
+ end
307
+
308
+ # Indicates whether build data is being fetched.
309
+ #
310
+ # The TUI shows loading indicators while fetching. Displaying stale build
311
+ # status misleads users. Poll this to show spinners and detect when fresh
312
+ # data arrives.
313
+ def loading?
314
+ @loading
315
+ end
316
+
317
+ # Fetches build information from SourceHut synchronously.
318
+ #
319
+ # The user triggers manual refresh. Synchronous fetch blocks but guarantees
320
+ # fresh data. Use this for explicit refresh actions, not TUI polling loops.
321
+ def refresh
322
+ @loading = true
323
+ output = `hut builds list 2>/dev/null | head -#{@max_builds * 10}`.strip
324
+ @builds = parse_builds(output)
325
+ @loading = false
326
+ end
327
+
328
+ # Queries build status in a background thread.
329
+ #
330
+ # The TUI renders continuously. Blocking on hut CLI calls freezes the
331
+ # interface. This method offloads the query to a thread so the UI stays
332
+ # responsive.
333
+ def refresh_async
334
+ Thread.new { refresh }
335
+ end
336
+
337
+ # Indicates whether all tracked builds succeeded.
338
+ #
339
+ # Releases should wait for CI. A single failure means the release isn't
340
+ # ready. Check this to gate release actions.
341
+ def all_passed?
342
+ @builds.all? { |b| b[:status] == :success }
343
+ end
344
+
345
+ # Indicates whether any build is still in progress.
346
+ #
347
+ # Releases should wait for CI to complete. Running builds mean the final
348
+ # result isn't known yet. Check this to show waiting states.
349
+ def any_running?
350
+ @builds.any? { |b| b[:status] == :running }
351
+ end
352
+
353
+ # Indicates whether any build failed.
354
+ #
355
+ # A failed build blocks the release. Check this to prevent premature
356
+ # announcements and surface CI failures.
357
+ def any_failed?
358
+ @builds.any? { |b| b[:status] == :failed }
359
+ end
360
+
361
+ private def parse_builds(output)
362
+ # Parse hut builds list output
363
+ builds = []
364
+ output.scan(/#(\d+).*?(SUCCESS|FAILED|RUNNING)/i) do |id, status|
365
+ builds << {
366
+ id:,
367
+ status: status.downcase.to_sym,
368
+ }
369
+ end
370
+ builds.first(@max_builds)
371
+ end
372
+ end
373
+
374
+ # Queries RubyGems.org for gem publication status.
375
+ #
376
+ # Releases should verify the gem is published. Checking manually is tedious.
377
+ # Network latency makes synchronous checks slow.
378
+ #
379
+ # This class queries gem info from the remote registry. It refreshes in
380
+ # background threads. It reports whether a specific version is published.
381
+ #
382
+ # Use it to verify gem publication after releasing.
383
+ class RubyGemsInfo
384
+ # The latest published version string.
385
+ attr_reader :published_version
386
+
387
+ # Creates a new RubyGemsInfo query.
388
+ #
389
+ # [gem_name] The gem name to query.
390
+ def initialize(gem_name)
391
+ @gem_name = gem_name
392
+ @published_version = nil
393
+ @loading = true
394
+ refresh_async
395
+ end
396
+
397
+ # Indicates whether gem data is being fetched.
398
+ #
399
+ # The TUI shows loading indicators while fetching. Displaying stale
400
+ # publication status misleads users. Poll this to show spinners and detect
401
+ # when fresh data arrives.
402
+ def loading?
403
+ @loading
404
+ end
405
+
406
+ # Fetches gem publication information synchronously.
407
+ #
408
+ # The user triggers manual refresh. Synchronous fetch blocks but guarantees
409
+ # fresh data. Use this for explicit refresh actions, not TUI polling loops.
410
+ def refresh
411
+ @loading = true
412
+ output = `gem info #{@gem_name} -r 2>/dev/null`.strip
413
+ match = output.match(/#{@gem_name} \(([^)]+)\)/)
414
+ @published_version = match[1] if match
415
+ @loading = false
416
+ end
417
+
418
+ # Queries gem publication in a background thread.
419
+ #
420
+ # The TUI renders continuously. Blocking on gem info calls freezes the
421
+ # interface. This method offloads the query to a thread so the UI stays
422
+ # responsive.
423
+ def refresh_async
424
+ Thread.new { refresh }
425
+ end
426
+
427
+ # Compares the given version against the published version.
428
+ #
429
+ # Releases verify the gem actually published. A mismatch means the release
430
+ # failed or is still propagating. Check this before announcing.
431
+ #
432
+ # [version] The version string (with or without "v" prefix).
433
+ def published?(version)
434
+ @published_version == version.delete_prefix("v")
435
+ end
436
+ end
437
+
438
+ # Manages wiki repository commit and push status.
439
+ #
440
+ # Release announcements live in the wiki repo. They need to be committed and
441
+ # pushed as part of the release process. Tracking this state manually is
442
+ # error-prone.
443
+ #
444
+ # This class queries git status for announcement files. It performs commits
445
+ # and pushes. It reports committed/pushed state for the UI.
446
+ #
447
+ # Use it to manage wiki announcement lifecycle.
448
+ class WikiRepo
449
+ # Whether the announcement is committed.
450
+ attr_reader :committed
451
+
452
+ # Whether the announcement is pushed.
453
+ attr_reader :pushed
454
+
455
+ # Creates a new WikiRepo for an announcement.
456
+ #
457
+ # [wiki_dir] Path to the wiki repository.
458
+ # [announcement_path] Full path to the announcement file.
459
+ def initialize(wiki_dir, announcement_path)
460
+ @wiki_dir = wiki_dir
461
+ @relative_path = announcement_path.sub("#{wiki_dir}/", "")
462
+ @committed = false
463
+ @pushed = false
464
+ @loading = true
465
+ refresh_async
466
+ end
467
+
468
+ # Indicates whether git status is being queried.
469
+ #
470
+ # The TUI shows loading indicators while fetching. Displaying stale
471
+ # commit/push status misleads users. Poll this to show spinners and detect
472
+ # when fresh data arrives.
473
+ def loading?
474
+ @loading
475
+ end
476
+
477
+ # Queries git status synchronously.
478
+ #
479
+ # The user triggers manual refresh. Synchronous queries block but guarantee
480
+ # fresh data. Use this for explicit refresh actions, not TUI polling loops.
481
+ def refresh
482
+ @loading = true
483
+ Dir.chdir(@wiki_dir) do
484
+ # Check if committed (not in diff or untracked)
485
+ diff_clean = system("git diff --quiet #{@relative_path} 2>/dev/null")
486
+ cached_clean = system("git diff --cached --quiet #{@relative_path} 2>/dev/null")
487
+ tracked = system("git ls-files --error-unmatch #{@relative_path} >/dev/null 2>&1")
488
+ @committed = diff_clean && cached_clean && tracked
489
+
490
+ # Check if pushed
491
+ if @committed
492
+ unpushed = `git log origin/HEAD..HEAD --oneline 2>/dev/null`.strip
493
+ @pushed = unpushed.empty?
494
+ else
495
+ @pushed = false
496
+ end
497
+ end
498
+ @loading = false
499
+ end
500
+
501
+ # Checks wiki commit/push status in a background thread.
502
+ #
503
+ # The TUI renders continuously. Blocking on git status calls freezes the
504
+ # interface. This method offloads the query to a thread so the UI stays
505
+ # responsive.
506
+ def refresh_async
507
+ Thread.new { refresh }
508
+ end
509
+
510
+ # Commits the announcement file.
511
+ #
512
+ # Stages and commits the file with the given message. Does nothing in dry-run
513
+ # mode.
514
+ #
515
+ # [message] The commit message.
516
+ def commit!(message)
517
+ return if CLIFlags.dry_run.value
518
+
519
+ Dir.chdir(@wiki_dir) do
520
+ system("git", "add", @relative_path)
521
+ system("git", "commit", "-m", message)
522
+ end
523
+ refresh
524
+ end
525
+
526
+ # Pushes the wiki repo to origin.
527
+ #
528
+ # Does nothing in dry-run mode.
529
+ def push!
530
+ return if CLIFlags.dry_run.value
531
+
532
+ Dir.chdir(@wiki_dir) do
533
+ system("git", "push")
534
+ end
535
+ refresh
536
+ end
537
+ end
538
+
539
+ # Sends release announcement emails.
540
+ #
541
+ # Announcements go to mailing lists. macOS provides multiple ways to send:
542
+ # MailMate's emate CLI for instant delivery, or the mailto: URL scheme for
543
+ # manual review. Handling both paths manually is tedious.
544
+ #
545
+ # This class wraps email sending. It prefers emate when available. It falls
546
+ # back to mailto: URLs. It respects dry-run mode.
547
+ #
548
+ # Use it to send release announcements.
549
+ class Emailer
550
+ # Path to MailMate's emate CLI tool.
551
+ EMATE_PATH = "/Applications/MailMate.app/Contents/Resources/emate"
552
+
553
+ # Creates an Emailer.
554
+ #
555
+ # [to_address] The recipient email address.
556
+ # [subject] The email subject line.
557
+ # [body] The email body content.
558
+ def initialize(to_address, subject, body)
559
+ @to_address = to_address
560
+ @subject = subject
561
+ @body = body
562
+ end
563
+
564
+ # Detects whether MailMate's emate CLI is installed.
565
+ #
566
+ # The UI shows which email method will be used. Users need to know if
567
+ # send will be instant or require manual intervention. Check this to select
568
+ # the appropriate UI indicator and send method.
569
+ def emate_available?
570
+ File.executable?(EMATE_PATH)
571
+ end
572
+
573
+ # Sends the email via emate (instant delivery).
574
+ #
575
+ # Returns true on success. Returns true immediately in dry-run mode.
576
+ # Returns false if emate is not available.
577
+ def send_via_emate!
578
+ return true if CLIFlags.dry_run.value
579
+
580
+ return false unless emate_available?
581
+
582
+ IO.popen([
583
+ EMATE_PATH,
584
+ "mailto",
585
+ "--to",
586
+ @to_address,
587
+ "--subject",
588
+ @subject,
589
+ "--send-now",
590
+ ], "w") do |io|
591
+ io.write(@body)
592
+ end
593
+ true
594
+ end
595
+
596
+ # Sends the email via mailto: URL (opens mail client for review).
597
+ def send_via_mailto!
598
+ encoded_subject = URI.encode_www_form_component(@subject).gsub("+", "%20")
599
+ encoded_body = URI.encode_www_form_component(@body).gsub("+", "%20")
600
+ mailto = "mailto:#{@to_address}?subject=#{encoded_subject}&body=#{encoded_body}"
601
+ system("open", mailto)
602
+ end
603
+ end
604
+
605
+ # TUI application for managing release announcements.
606
+ #
607
+ # Releases involve multiple steps: previewing emails, checking CI, committing
608
+ # wiki changes, sending announcements. Coordinating manually is error-prone.
609
+ # Status changes asynchronously.
610
+ #
611
+ # This class provides a tabbed TUI. It previews emails and commits. It displays
612
+ # a release checklist with live status. It provides actions to ship wiki
613
+ # changes and send announcements.
614
+ #
615
+ # Use it to orchestrate release announcements.
616
+ class AnnounceTUI
617
+ # Tab names for the UI.
618
+ TABS = ["Preview Email", "Preview Commit", "Announce"].freeze
619
+
620
+ # The mailing list address.
621
+ TO_ADDRESS = "~kerrick/ratatui_ruby-announce@lists.sr.ht"
622
+
623
+ # Creates a new AnnounceTUI.
624
+ def initialize
625
+ @current_tab = 0
626
+ @scroll_positions = Hash.new { |h, k| h[k] = [0, 0] } # [scroll_y, scroll_x] per tab
627
+ @show_help = false
628
+ @use_emate = nil # nil = auto-detect, true = force emate, false = force mailto
629
+ @content_area = nil # Stored for page scroll calculations
630
+ @content_block = nil # Stored for page scroll calculations
631
+ @current_paragraph = nil # Stored for content length calculations
632
+
633
+ # Initialize domain objects (all caching happens here)
634
+ @git_repo = GitRepo.new
635
+ @version = CLIFlags.version || @git_repo.expected_version
636
+
637
+ @announcement = Announcement.new(CLIFlags.wiki_dir.value, @version)
638
+ @sourcehut = SourceHut.new(max_builds: CLIFlags.builds.value)
639
+ @rubygems = RubyGemsInfo.new("ratatui_ruby")
640
+ @wiki_repo = WikiRepo.new(CLIFlags.wiki_dir.value, @announcement.path)
641
+ @emailer = Emailer.new(TO_ADDRESS, @announcement.subject, @announcement.body)
642
+ end
643
+
644
+ # Starts the TUI event loop.
645
+ #
646
+ # The announcement workflow requires user interaction. A TUI provides a
647
+ # responsive interface for previewing, checking status, and sending. Call
648
+ # this after parsing CLI arguments.
649
+ def run
650
+ RatatuiRuby.run do |tui|
651
+ @tui = tui
652
+ init_styles
653
+
654
+ loop do
655
+ render
656
+ break if handle_input == :quit
657
+ end
658
+ end
659
+ end
660
+
661
+ private def init_styles
662
+ @hotkey_style = @tui.style(modifiers: [:bold, :underlined])
663
+ @ok_style = @tui.style(fg: :green)
664
+ @pending_style = @tui.style(fg: :yellow)
665
+ @error_style = @tui.style(fg: :red)
666
+ @border_style = @tui.style(fg: :dark_gray)
667
+ @title_style = @tui.style(fg: :cyan)
668
+ @email_client_style = @tui.style(fg: :yellow)
669
+ end
670
+
671
+ private def render
672
+ @tui.draw do |frame|
673
+ @tabs_area, content_area, wiki_dir_area = @tui.layout_split(
674
+ frame.area,
675
+ direction: :vertical,
676
+ constraints: [
677
+ @tui.constraint_length(3), # Tabs
678
+ @tui.constraint_fill(1), # Content
679
+ @tui.constraint_length(1), # Wiki Dir
680
+ ],
681
+ )
682
+
683
+ # Store content area for page scroll calculations
684
+ @content_area = content_area
685
+
686
+ render_tabs(frame, @tabs_area)
687
+ render_content(frame, content_area)
688
+ render_wiki_dir(frame, wiki_dir_area)
689
+
690
+ # Overlay on top
691
+ render_help_overlay(frame) if @show_help
692
+ end
693
+ end
694
+
695
+ private def render_tabs(frame, area)
696
+ email_client = use_emate? ? "emate" : "mailto"
697
+
698
+ # # Dry-run indicator: gray when "no", bright bold white when "yes"
699
+ # dry_run_style = CLIFlags.dry_run.value ? @tui.style(fg: :white, modifiers: [:bold]) : @border_style
700
+ # dry_run_title = @tui.text_line(
701
+ # @tui.paragraph(content: "dry-run: #{CLIFlags.dry_run.to_short}",
702
+ # style: dry_run_style)
703
+ # )
704
+ dry_run_title = nil
705
+
706
+ tabs = @tui.tabs(
707
+ titles: TABS,
708
+ selected_index: @current_tab,
709
+ divider: @tui.text_span(content: " ▶ ", style: @border_style),
710
+ block: @tui.block(
711
+ titles: [
712
+ { content: "Announce #{@version}" },
713
+ { content: dry_run_title, position: :top, alignment: :center },
714
+ { content: @tui.text_line(spans: [@tui.text_span(content: email_client, style: @email_client_style)]), position: :top, alignment: :right },
715
+ ],
716
+ borders: [:all],
717
+ border_style: @border_style,
718
+ ),
719
+ highlight_style: @tui.style(fg: :magenta, modifiers: [:bold]),
720
+ )
721
+ frame.render_widget(tabs, area)
722
+ end
723
+
724
+ private def use_emate?
725
+ @use_emate.nil? ? @emailer.emate_available? : @use_emate
726
+ end
727
+
728
+ private def render_content(frame, area)
729
+ case @current_tab
730
+ when 0 then render_email_preview(frame, area)
731
+ when 1 then render_commit_preview(frame, area)
732
+ when 2 then render_checklist(frame, area)
733
+ end
734
+ end
735
+
736
+ private def render_email_preview(frame, area)
737
+ email_text = <<~EMAIL
738
+ To: #{TO_ADDRESS}
739
+ Subject: #{@announcement.subject}
740
+
741
+ #{@announcement.body}
742
+ EMAIL
743
+
744
+ lines = email_text.lines
745
+ content_length = lines.size
746
+
747
+ @current_paragraph = @tui.paragraph(
748
+ text: email_text,
749
+ wrap: true,
750
+ scroll: [scroll_y, scroll_x],
751
+ block: content_block("Email Preview"),
752
+ )
753
+ frame.render_widget(@current_paragraph, area)
754
+
755
+ scrollbar = @tui.scrollbar(
756
+ content_length:,
757
+ position: scroll_y,
758
+ orientation: :vertical,
759
+ )
760
+ frame.render_widget(scrollbar, area)
761
+ end
762
+
763
+ private def render_commit_preview(frame, area)
764
+ content = @announcement.commit_message
765
+ content_length = content.lines.size
766
+
767
+ @current_paragraph = @tui.paragraph(
768
+ text: content,
769
+ scroll: [scroll_y, scroll_x],
770
+ block: content_block("Commit Preview"),
771
+ )
772
+ frame.render_widget(@current_paragraph, area)
773
+
774
+ scrollbar = @tui.scrollbar(
775
+ content_length:,
776
+ position: scroll_y,
777
+ orientation: :vertical,
778
+ )
779
+ frame.render_widget(scrollbar, area)
780
+ end
781
+
782
+ private def render_checklist(frame, area)
783
+ lines = checklist_items.map { |item| checklist_line(item) }
784
+
785
+ paragraph = @tui.paragraph(
786
+ text: lines,
787
+ block: content_block("Release Checklist"),
788
+ )
789
+ frame.render_widget(paragraph, area)
790
+ end
791
+
792
+ private def checklist_items
793
+ # Build checklist from domain objects (reads cached data only)
794
+ # Each item shows loading if its domain object is still loading
795
+
796
+ # Build latest tag status first (needed for tag_pushed logic)
797
+ latest_tag_value = @git_repo.latest_tag || "None"
798
+ latest_tag_status = if @git_repo.tag_matches?
799
+ :ok
800
+ else
801
+ latest_tag_value = "#{latest_tag_value} (Should be #{@git_repo.expected_version})"
802
+ :error
803
+ end
804
+
805
+ # Git repo tag pushed check (only meaningful if tag matches)
806
+ tag_pushed_item = if !@git_repo.tag_matches?
807
+ { value: "N/A", status: :pending }
808
+ elsif @git_repo.loading? || @git_repo.tag_pushed.nil?
809
+ { value: "Loading...", status: :loading }
810
+ elsif @git_repo.tag_pushed
811
+ { value: "Yes", status: :ok }
812
+ else
813
+ { value: "No", status: :error }
814
+ end
815
+
816
+ # SourceHut builds
817
+ builds_item = if @sourcehut.loading?
818
+ { value: "Loading...", status: :loading }
819
+ else
820
+ builds_status = if @sourcehut.any_running?
821
+ :pending
822
+ elsif @sourcehut.all_passed?
823
+ :ok
824
+ else
825
+ :error
826
+ end
827
+ builds_value = @sourcehut.builds.map { |b| "##{b[:id]} #{b[:status]}" }.join(", ")
828
+ { value: builds_value.empty? ? "None" : builds_value, status: builds_status }
829
+ end
830
+
831
+ # RubyGems
832
+ gem_item = if @rubygems.loading?
833
+ { value: "Loading...", status: :loading }
834
+ else
835
+ { value: @rubygems.published_version || "Unknown", status: @rubygems.published?(@version) ? :ok : :pending }
836
+ end
837
+
838
+ # Wiki repo
839
+ wiki_committed = if @wiki_repo.loading?
840
+ { value: "Loading...", status: :loading }
841
+ else
842
+ { value: @wiki_repo.committed ? "Yes" : "No", status: @wiki_repo.committed ? :ok : :pending }
843
+ end
844
+
845
+ wiki_pushed = if @wiki_repo.loading?
846
+ { value: "Loading...", status: :loading }
847
+ else
848
+ { value: @wiki_repo.pushed ? "Yes" : "No", status: @wiki_repo.pushed ? :ok : :pending }
849
+ end
850
+
851
+ [
852
+ { label: "Latest tag", value: latest_tag_value, status: latest_tag_status },
853
+ { label: "Tag pushed", **tag_pushed_item },
854
+ { label: "Builds (#{@sourcehut.builds.size})", **builds_item },
855
+ { label: "Gem published", **gem_item },
856
+ { label: "Wiki committed", **wiki_committed },
857
+ { label: "Wiki pushed", **wiki_pushed },
858
+ ]
859
+ end
860
+
861
+ private def checklist_line(item)
862
+ icon = case item[:status]
863
+ when :ok then "✓"
864
+ when :error then "✗"
865
+ when :pending then "●"
866
+ when :loading then "…"
867
+ else "?"
868
+ end
869
+ style = case item[:status]
870
+ when :ok then @ok_style
871
+ when :error then @error_style
872
+ when :pending, :loading then @pending_style
873
+ end
874
+
875
+ @tui.text_line(spans: [
876
+ @tui.text_span(content: "#{icon} ", style:),
877
+ @tui.text_span(content: "#{item[:label]}: "),
878
+ @tui.text_span(content: item[:value].to_s),
879
+ ])
880
+ end
881
+
882
+ private def content_block(title)
883
+ # Bottom-left titles for universal controls
884
+ bottom_left = @tui.text_line(spans: [
885
+ @tui.text_span(content: "?", style: @hotkey_style),
886
+ @tui.text_span(content: ":Help"),
887
+ @tui.text_span(content: "─", style: @border_style),
888
+ @tui.text_span(content: "q", style: @hotkey_style),
889
+ @tui.text_span(content: ": Quit"),
890
+ ])
891
+
892
+ titles = [
893
+ { content: title, position: :top, alignment: :left },
894
+ { content: bottom_left, position: :bottom, alignment: :left },
895
+ ]
896
+
897
+ # Add action controls on Announce tab (bottom-right, styled red)
898
+ if @current_tab == 2
899
+ action_style = @tui.style(fg: :red, modifiers: [:bold, :underlined])
900
+ bottom_right = @tui.text_line(spans: [
901
+ @tui.text_span(content: "w", style: action_style),
902
+ @tui.text_span(content: ":Ship Wiki", style: @tui.style(fg: :red)),
903
+ @tui.text_span(content: "─", style: @border_style),
904
+ @tui.text_span(content: "s", style: action_style),
905
+ @tui.text_span(content: ":Send Email", style: @tui.style(fg: :red)),
906
+ @tui.text_span(content: "─", style: @border_style),
907
+ @tui.text_span(content: "r", style: action_style),
908
+ @tui.text_span(content: ":Refresh", style: @tui.style(fg: :red)),
909
+ ])
910
+ titles << { content: bottom_right, position: :bottom, alignment: :right }
911
+ end
912
+
913
+ # Store block for page scroll inner height calculation
914
+ @content_block = @tui.block(
915
+ titles:,
916
+ borders: [:all],
917
+ border_style: @border_style,
918
+ title_style: @title_style,
919
+ )
920
+ end
921
+
922
+ private def control_line(pairs)
923
+ spans = pairs.flat_map do |key, desc|
924
+ [
925
+ @tui.text_span(content: key, style: @hotkey_style),
926
+ @tui.text_span(content: ": #{desc} "),
927
+ ]
928
+ end
929
+ @tui.text_line(spans:)
930
+ end
931
+
932
+ private def render_help_overlay(frame)
933
+ # Clear the area first
934
+ frame.render_widget(@tui.clear, frame.area)
935
+
936
+ help_text = <<~HELP
937
+ Navigation
938
+ ←/→ or h/l Switch tabs
939
+ [/] or Tab Switch tabs (alternate)
940
+ ↑/↓ or j/k Scroll one line
941
+ PgUp/PgDn Scroll 10 lines
942
+ Home/End Scroll to top/bottom
943
+ Mouse wheel Scroll 3 lines
944
+
945
+ Actions (Announce tab only)
946
+ w Ship Wiki
947
+ s Send Email
948
+ r Refresh all data
949
+
950
+ General
951
+ ? Toggle this help
952
+ q or Ctrl+C Quit
953
+ HELP
954
+
955
+ content = @tui.paragraph(
956
+ text: help_text,
957
+ block: @tui.block(title: "Help (press Esc to close)", borders: [:all]),
958
+ )
959
+
960
+ center_widget = @tui.center(
961
+ child: content,
962
+ width_percent: 80,
963
+ height_percent: 80,
964
+ )
965
+ frame.render_widget(center_widget, frame.area)
966
+ end
967
+
968
+ private def render_wiki_dir(frame, area)
969
+ frame.render_widget(@tui.text_line(
970
+ alignment: :center,
971
+ spans: [
972
+ @tui.text_span(
973
+ content: "Wiki Dir: ",
974
+ style: @tui.style(
975
+ modifiers: [:dim]
976
+ )
977
+ ),
978
+ @tui.text_span(
979
+ content: CLIFlags.wiki_dir.to_short
980
+ ),
981
+ ]
982
+ ), area)
983
+ end
984
+
985
+ private
986
+
987
+ private def handle_input
988
+ event = @tui.poll_event
989
+
990
+ # When help overlay is open, only ? closes it (but q/Ctrl+C still quit)
991
+ if @show_help
992
+ case event
993
+ in { type: :key, code: "q" } | { type: :key, code: "c", modifiers: ["ctrl"] }
994
+ return :quit
995
+ in { type: :key, code: "?" } | { type: :key, code: "esc" }
996
+ @show_help = false
997
+ else
998
+ # Ignore all other input
999
+ end
1000
+ return
1001
+ end
1002
+
1003
+ case event
1004
+ in { type: :key, code: "q" } | { type: :key, code: "c", modifiers: ["ctrl"] }
1005
+ :quit
1006
+ in { type: :key, code: "right" } | { type: :key, code: "l" } | { type: :key, code: "]" } | { type: :key, code: "tab", modifiers: ["ctrl"] }
1007
+ @current_tab = [@current_tab + 1, TABS.size - 1].min
1008
+ in { type: :key, code: "left" } | { type: :key, code: "h" } | { type: :key, code: "[" } | { type: :key, code: "backtab" }
1009
+ @current_tab = [@current_tab - 1, 0].max
1010
+ in { type: :key, code: "up" } | { type: :key, code: "k" }
1011
+ self.scroll_y = [scroll_y - 1, 0].max
1012
+ in { type: :key, code: "down" } | { type: :key, code: "j" }
1013
+ self.scroll_y += 1
1014
+ in { type: :key, code: "home" }
1015
+ self.scroll_y = 0
1016
+ in { type: :key, code: "end" }
1017
+ self.scroll_y = max_scroll_y
1018
+ in { type: :key, code: "page_up" }
1019
+ self.scroll_y = [scroll_y - page_height, 0].max
1020
+ in { type: :key, code: "page_down" }
1021
+ self.scroll_y += page_height
1022
+ in { type: :key, code: "?" }
1023
+ @show_help = !@show_help
1024
+ in { type: :key, code: "w" } if @current_tab == 2
1025
+ commit_and_push_wiki
1026
+ in { type: :key, code: "s" } if @current_tab == 2
1027
+ send_email
1028
+ in { type: :key, code: "r" } if @current_tab == 2
1029
+ refresh_all
1030
+ in { type: :mouse, kind: "scroll_down" }
1031
+ self.scroll_y += 1
1032
+ in { type: :mouse, kind: "scroll_up" }
1033
+ self.scroll_y = [scroll_y - 1, 0].max
1034
+
1035
+ else
1036
+ # Ignore other events
1037
+ end
1038
+ end
1039
+
1040
+ private def scroll_y = @scroll_positions[@current_tab][0]
1041
+ private def scroll_x = @scroll_positions[@current_tab][1]
1042
+
1043
+ private def scroll_y=(val)
1044
+ @scroll_positions[@current_tab][0] = val
1045
+ end
1046
+
1047
+ private def scroll_x=(val)
1048
+ @scroll_positions[@current_tab][1] = val
1049
+ end
1050
+
1051
+ private def page_height
1052
+ return 10 unless @content_area && @content_block
1053
+
1054
+ @content_block.inner(@content_area).height
1055
+ end
1056
+
1057
+ private def content_length
1058
+ return 0 unless @current_paragraph && @content_area && @content_block
1059
+
1060
+ inner_width = @content_block.inner(@content_area).width
1061
+ @current_paragraph.line_count(inner_width)
1062
+ end
1063
+
1064
+ private def max_scroll_y
1065
+ [content_length - page_height, 0].max
1066
+ end
1067
+
1068
+ # Actions
1069
+ private def commit_and_push_wiki
1070
+ @wiki_repo.commit!(@announcement.commit_message)
1071
+ @wiki_repo.push!
1072
+ end
1073
+
1074
+ private def send_email
1075
+ if use_emate?
1076
+ @emailer.send_via_emate!
1077
+ else
1078
+ @emailer.send_via_mailto!
1079
+ end
1080
+ end
1081
+
1082
+ private def refresh_all
1083
+ @git_repo.refresh
1084
+ @sourcehut.refresh
1085
+ @rubygems.refresh
1086
+ @wiki_repo.refresh
1087
+ end
1088
+ end
1089
+
1090
+ if __FILE__ == $PROGRAM_NAME
1091
+ # Parse CLI options
1092
+ script_dir = File.dirname(File.expand_path(__FILE__))
1093
+
1094
+ OptionParser.new do |opts|
1095
+ opts.banner = "Usage: bin/announce_tui [OPTIONS] [VERSION]"
1096
+
1097
+ opts.on("-w", "--wiki-dir PATH", "Path to wiki repo") do |path|
1098
+ CLIFlags.wiki_dir = PathFlag.new(File.expand_path(path))
1099
+ end
1100
+
1101
+ opts.on("-n", "--builds N", Integer, "Number of builds to show") do |n|
1102
+ CLIFlags.builds = IntFlag.new(n)
1103
+ end
1104
+
1105
+ opts.on("-d", "--dry-run", "Stub dangerous actions (emate, git commit/push)") do
1106
+ CLIFlags.dry_run = BoolFlag.new(true)
1107
+ end
1108
+
1109
+ opts.on("-h", "--help", "Show this help") do
1110
+ puts opts
1111
+ exit
1112
+ end
1113
+ end.parse!
1114
+
1115
+ # Set defaults and determine version
1116
+ CLIFlags.wiki_dir ||= PathFlag.new(File.expand_path(File.join(script_dir, "..", "..", "ratatui_ruby-wiki")))
1117
+ CLIFlags.version = ARGV[0] # nil if not provided, will use RatatuiRuby::VERSION via GitRepo
1118
+
1119
+ AnnounceTUI.new.run
1120
+ end