raindown 0.6.0 → 0.8.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 +4 -4
  2. data/lib/nodes/endpoint_node.rb +3 -2
  3. data/lib/raindown/actions.rb +5 -3
  4. data/lib/raindown/bare_directives.rb +14 -27
  5. data/lib/raindown/blueprints.rb +255 -0
  6. data/lib/raindown/chart/chart.rb +12 -1
  7. data/lib/raindown/cli.rb +39 -54
  8. data/lib/raindown/config.rb +2 -3
  9. data/lib/raindown/directives.rb +30 -13
  10. data/lib/raindown/document.rb +22 -20
  11. data/lib/raindown/endpoint/curl.rb +11 -7
  12. data/lib/raindown/endpoint/endpoint_runner.rb +13 -5
  13. data/lib/raindown/node_blocks.rb +31 -22
  14. data/lib/raindown/node_injector.rb +7 -10
  15. data/lib/raindown/nodes.rb +1 -0
  16. data/lib/raindown/pages.rb +1 -1
  17. data/lib/raindown/pre_renderer.rb +18 -0
  18. data/lib/raindown/stat.rb +53 -0
  19. data/lib/raindown/tui/blueprints/blueprints_renderer.rb +93 -0
  20. data/lib/raindown/tui/blueprints/blueprints_view.rb +146 -0
  21. data/lib/raindown/tui/blueprints/canvas.rb +58 -0
  22. data/lib/raindown/tui/blueprints/code_comments.rb +71 -0
  23. data/lib/raindown/tui/blueprints/filter.rb +77 -0
  24. data/lib/raindown/tui/blueprints/traced_code.rb +56 -0
  25. data/lib/raindown/tui/blueprints/tracing.rb +129 -0
  26. data/lib/raindown/tui/code_box.rb +143 -0
  27. data/lib/raindown/tui/endpoints.rb +2 -2
  28. data/lib/raindown/tui/events.rb +18 -1
  29. data/lib/raindown/tui/feelings/batch.rb +47 -0
  30. data/lib/raindown/tui/feelings/bins.rb +67 -0
  31. data/lib/raindown/tui/feelings/celebration/celebration.rb +28 -0
  32. data/lib/raindown/tui/feelings/celebration/fireworks.rb +75 -0
  33. data/lib/raindown/tui/feelings/celebration/rain.rb +66 -0
  34. data/lib/raindown/tui/feelings/celebration/scene.rb +40 -0
  35. data/lib/raindown/tui/feelings/cell_moves.rb +34 -0
  36. data/lib/raindown/tui/feelings/code_methods.rb +134 -0
  37. data/lib/raindown/tui/feelings/feelings_code_box.rb +40 -0
  38. data/lib/raindown/tui/feelings/feelings_log.rb +136 -0
  39. data/lib/raindown/tui/feelings/feelings_renderer.rb +183 -0
  40. data/lib/raindown/tui/feelings/feelings_view.rb +157 -0
  41. data/lib/raindown/tui/feelings/magnifier.rb +68 -0
  42. data/lib/raindown/tui/feelings/motion.rb +93 -0
  43. data/lib/raindown/tui/feelings/waffle/baked_waffle.rb +88 -0
  44. data/lib/raindown/tui/feelings/waffle/balloons.rb +81 -0
  45. data/lib/raindown/tui/feelings/waffle/confetti.rb +55 -0
  46. data/lib/raindown/tui/feelings/waffle/sparkles.rb +61 -0
  47. data/lib/raindown/tui/feelings/waffle/waffle.rb +62 -0
  48. data/lib/raindown/tui/feelings/weekly_batch.rb +51 -0
  49. data/lib/raindown/tui/file_tree.rb +6 -5
  50. data/lib/raindown/tui/highlighter.rb +45 -0
  51. data/lib/raindown/tui/layout.rb +32 -18
  52. data/lib/raindown/tui/navigator/commands.rb +46 -2
  53. data/lib/raindown/tui/navigator/mouse.rb +43 -0
  54. data/lib/raindown/tui/navigator/navigator.rb +54 -25
  55. data/lib/raindown/tui/navigator/teleport.rb +128 -0
  56. data/lib/raindown/tui/navigator/views.rb +5 -3
  57. data/lib/raindown/tui/{workers/project.rb → project.rb} +38 -27
  58. data/lib/raindown/tui/{menu_overlay.rb → project_box.rb} +2 -2
  59. data/lib/raindown/tui/renderers/dashboard_renderer.rb +4 -4
  60. data/lib/raindown/tui/renderers/endpoint_renderer.rb +14 -10
  61. data/lib/raindown/tui/renderers/outline_renderer.rb +4 -4
  62. data/lib/raindown/tui/renderers/renderer.rb +4 -0
  63. data/lib/raindown/tui/row.rb +4 -2
  64. data/lib/raindown/tui/rows.rb +2 -1
  65. data/lib/raindown/tui/saves.rb +22 -0
  66. data/lib/raindown/tui/search_box.rb +86 -0
  67. data/lib/raindown/tui/terminal/input.rb +95 -7
  68. data/lib/raindown/tui/terminal/screen.rb +37 -29
  69. data/lib/raindown/tui/terminal/styles.rb +111 -26
  70. data/lib/raindown/tui/terminal/terminal_title.rb +31 -0
  71. data/lib/raindown/tui/text.rb +62 -12
  72. data/lib/raindown/tui/title.rb +1 -1
  73. data/lib/raindown/tui/views/dashboard_view.rb +7 -5
  74. data/lib/raindown/tui/views/endpoint_view.rb +4 -2
  75. data/lib/raindown/tui/views/list_view.rb +2 -0
  76. data/lib/raindown/tui/views/outline_view.rb +16 -6
  77. data/lib/raindown/tui.rb +19 -7
  78. data/lib/raindown/watcher/changes.rb +33 -0
  79. data/lib/raindown/watcher/file_watcher.rb +58 -0
  80. data/lib/raindown/watcher/project.rb +47 -0
  81. data/lib/raindown/watcher/supervisor.rb +111 -0
  82. data/lib/raindown/{tui/workers → workers}/worker.rb +37 -29
  83. data/lib/raindown/workers/workers.rb +63 -0
  84. data/lib/raindown.rb +4 -4
  85. data/lib/version.rb +1 -1
  86. metadata +60 -9
  87. data/lib/raindown/project_worker.rb +0 -48
  88. data/lib/raindown/supervisor.rb +0 -160
  89. data/lib/raindown/tui/workers/workers.rb +0 -53
  90. data/lib/raindown/watcher.rb +0 -57
@@ -8,6 +8,16 @@ module Raindown
8
8
  module Text
9
9
  Segment = Data.define(:text, :style, :event)
10
10
  INDENT = ' '
11
+ WIDTHS = 10_000
12
+
13
+ # Printable ASCII, like most code and labels, is a column a character, without asking Unicode, and other text,
14
+ # like boxes' borders and magnified digits, is the same every frame, so its width's remembered, up to WIDTHS texts.
15
+ def self.display_width(text)
16
+ return text.length if text.match?(/\A[ -~]*\z/)
17
+
18
+ @widths = {} if @widths.nil? || @widths.size >= WIDTHS
19
+ @widths[text] ||= Unicode::DisplayWidth.of(text)
20
+ end
11
21
 
12
22
  private
13
23
 
@@ -27,7 +37,9 @@ module Raindown
27
37
  # Text cut or padded to the width, with a cut shown by an ellipsis.
28
38
  def fit(text, width)
29
39
  return '' if width <= 0
30
- return text + (' ' * (width - display_width(text))) if display_width(text) <= width
40
+
41
+ text_width = display_width(text)
42
+ return text + (' ' * (width - text_width)) if text_width <= width
31
43
 
32
44
  cut = +''
33
45
  cut_width = 0
@@ -50,34 +62,72 @@ module Raindown
50
62
  end
51
63
  end
52
64
 
65
+ # A line of highlighted code's [text, style] parts as segments, cut or padded to the width.
66
+ def code_segments(parts, width, event: nil)
67
+ segments = segments_between(parts.map { |text, style| Segment.new(text: printable(text), style:, event:) }, 0, width)
68
+ [*segments, segment('', :code, width: width - segments.sum { display_width(it.text) }, event:)]
69
+ end
70
+
71
+ # The text in lines that fit the width, broken at spaces where it can, and inside a word too long for a line.
72
+ def wrap(text, width)
73
+ return [text] unless width.positive?
74
+
75
+ text.scan(/\S+|\s+/).each_with_object([+'']) do |part, lines|
76
+ next lines.last << part if display_width(lines.last + part) <= width
77
+ next lines << +'' if part.strip.empty?
78
+
79
+ lines << +'' unless lines.last.empty?
80
+ break_word(part, width, lines)
81
+ end
82
+ end
83
+
84
+ # Adds a word too long for a line to the lines, a character at a time, starting a new line when one's full.
85
+ def break_word(word, width, lines)
86
+ word.each_char do |char|
87
+ lines << +'' if display_width(lines.last + char) > width
88
+ lines.last << char
89
+ end
90
+ end
91
+
53
92
  # The line's segments, with others drawn over its columns from the column on, like a box over the screen.
54
93
  def overlay_segments(line, segments, column:)
55
94
  width = segments.sum { display_width(it.text) }
56
95
  [*segments_between(line, 0, column), *segments, *segments_between(line, column + width, Float::INFINITY)]
57
96
  end
58
97
 
59
- # The parts of the line's segments between two columns, where a wide character cut in two leaves spaces.
98
+ # The parts of the line's segments between two columns, where a wide character cut in two leaves spaces. A segment
99
+ # wholly between them, or wholly outside, is kept or left out whole, without going through its characters.
60
100
  def segments_between(line, from, to)
61
101
  column = 0
62
102
  line.filter_map do |segment|
63
- text = segment.text.each_char.map do |char|
64
- start = column
65
- column += display_width(char)
66
- overlap = [column, to].min - [start, from].max
67
- overlap == display_width(char) ? char : ' ' * [overlap, 0].max
68
- end.join
69
- segment.with(text:) unless text.empty?
103
+ start = column
104
+ column += display_width(segment.text)
105
+ next if segment.text.empty? || column <= from || start >= to
106
+ next segment if start >= from && column <= to
107
+
108
+ segment.with(text: chars_between(segment.text, start, from, to))
70
109
  end
71
110
  end
72
111
 
112
+ # The text's characters between two columns, from the column it starts at, where a wide character cut in two
113
+ # leaves spaces.
114
+ def chars_between(text, column, from, to)
115
+ text.each_char.map do |char|
116
+ start = column
117
+ column += display_width(char)
118
+ overlap = [column, to].min - [start, from].max
119
+ overlap == display_width(char) ? char : ' ' * [overlap, 0].max
120
+ end.join
121
+ end
122
+
73
123
  # Tabs and control characters, like in a directive's output, would move the terminal's cursor.
74
124
  def printable(text)
125
+ return text unless text.match?(/[[:cntrl:]]/)
126
+
75
127
  text.gsub("\t", INDENT).gsub(/[[:cntrl:]]/, '')
76
128
  end
77
129
 
78
- def display_width(text)
79
- Unicode::DisplayWidth.of(text)
80
- end
130
+ def display_width(text) = Text.display_width(text)
81
131
  end
82
132
  end
83
133
  end
@@ -6,7 +6,7 @@ module Raindown
6
6
  module Title
7
7
  # The rain cloud without the emoji variation selector, like the nav's projects, which terminals draw one column wide,
8
8
  # as it's measured, so the title line reaches the edge.
9
- NAME = '🌧 Raindown'
9
+ NAME = '🌧 Raindown'
10
10
 
11
11
  private
12
12
 
@@ -5,9 +5,9 @@ require_relative '../events'
5
5
  module Raindown
6
6
  module TUI
7
7
  # Every chart in the projects' Markdown files, drawn from its data by its project's Worker, as cards in a grid
8
- # with a cursor. A chart is a chart: directive, or a project's own node that's a Raindown::Chart,
9
- # like "Sales < Raindown::Chart". Charts are drawn again every few seconds, so they follow changes to their data,
10
- # like a CSV file, without a save.
8
+ # with a cursor. A chart is a chart: directive, or a project's own node that's a Raindown::Chart, like
9
+ # "Sales < Raindown::Chart", or a Raindown::Stat, one big number, like "CodeDebt < Raindown::Stat". Charts are drawn
10
+ # again every few seconds, so they follow changes to their data, like a CSV file, without a save.
11
11
  # A key's next step, for the Navigator, is nil, or an Open or Edit of a chart's directive.
12
12
  class DashboardView
13
13
  # Seconds between drawing the charts again.
@@ -31,6 +31,8 @@ module Raindown
31
31
  def target = self
32
32
  def path = nil
33
33
  def changed? = false
34
+ def typing? = false
35
+ def pace(_hovered) = :active
34
36
 
35
37
  def status(_navigator)
36
38
  found = "#{charts.length} #{charts.length == 1 ? 'chart' : 'charts'} in #{File.basename(project || root)}"
@@ -45,7 +47,7 @@ module Raindown
45
47
 
46
48
  # The charts, as each project's Worker drew them, of the project they're narrowed to, if any.
47
49
  def charts
48
- project ? @cards.select { it.project == project } : @cards
50
+ project ? @cards.filter { it.project == project } : @cards
49
51
  end
50
52
 
51
53
  # Narrows the charts to a project's, or with nil, shows every project's.
@@ -109,7 +111,7 @@ module Raindown
109
111
  answer = worker.call(:charts, find:)
110
112
  @cards.concat(answer[:cards])
111
113
  @errors.concat(answer[:errors])
112
- rescue Worker::Error => e
114
+ rescue Workers::Worker::Error => e
113
115
  @errors << "#{File.basename(worker.root)}: #{e.message}"
114
116
  end
115
117
  @cards.sort_by! { [it.project, it.path, it.line] }
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative '../events'
4
- require_relative '../workers/worker'
4
+ require_relative '../../workers/worker'
5
5
 
6
6
  module Raindown
7
7
  module TUI
@@ -26,6 +26,8 @@ module Raindown
26
26
  def path = endpoint.path
27
27
  def status(_navigator) = [:info, endpoint.text]
28
28
  def changed? = false
29
+ def typing? = false
30
+ def pace(_hovered) = :active
29
31
  def next_step?(key) = %i[up down enter edit execute].include?(key)
30
32
 
31
33
  # Clicks on the response only move the focus to it.
@@ -38,7 +40,7 @@ module Raindown
38
40
  @language = lines.first.to_s[FENCE, 1]
39
41
  @response = @language ? lines[1...-1] : lines
40
42
  @error = nil
41
- rescue Worker::Error => e
43
+ rescue Workers::Worker::Error => e
42
44
  @response = []
43
45
  @error = e.message
44
46
  end
@@ -13,6 +13,8 @@ module Raindown
13
13
  end
14
14
 
15
15
  def renderer = ListRenderer
16
+ def typing? = false
17
+ def pace(_hovered) = :active
16
18
 
17
19
  def rows = @rows.call
18
20
  def selected = rows[cursor]
@@ -3,8 +3,9 @@
3
3
  require_relative '../../document'
4
4
  require_relative '../events'
5
5
  require_relative 'list_view'
6
+ require_relative '../highlighter'
6
7
  require_relative '../row'
7
- require_relative '../workers/worker'
8
+ require_relative '../../workers/worker'
8
9
 
9
10
  module Raindown
10
11
  module TUI
@@ -34,8 +35,9 @@ module Raindown
34
35
  @lines = outline.lines
35
36
  @headings = outline.headings
36
37
  @directives = outline.directives
38
+ @highlighted = {}
37
39
  @error = nil
38
- rescue Worker::Error, SystemCallError => e
40
+ rescue Workers::Worker::Error, SystemCallError => e
39
41
  @error = e.message
40
42
  end
41
43
 
@@ -104,11 +106,18 @@ module Raindown
104
106
  buttons: buttons(directive, expanded:), **directive_action(directive))
105
107
  return [row] unless expanded
106
108
 
107
- [row, *directive.body.map do |number|
108
- Row.new(text: @lines[number - 1].to_s, depth: depth + 1, kind: :body, target: number, open: Edit.new(path:, line: number))
109
+ code = highlighted(directive)
110
+ [row, *directive.body.each_with_index.map do |number, index|
111
+ Row.new(text: @lines[number - 1].to_s, depth: depth + 1, kind: :body, target: number, open: Edit.new(path:, line: number),
112
+ code: code[index])
109
113
  end]
110
114
  end
111
115
 
116
+ # The directive's body highlighted as Markdown, which highlights its code blocks in their languages, once a read.
117
+ def highlighted(directive)
118
+ @highlighted[directive.line] ||= Highlighter.lines(directive.body.map { @lines[it - 1].to_s }, language: 'markdown')
119
+ end
120
+
112
121
  # A directive with a body expands it, and one without opens in the editor, like a line of its body.
113
122
  def directive_action(directive)
114
123
  if directive.body then { fold: -> { expand(directive) } }
@@ -119,12 +128,13 @@ module Raindown
119
128
  def buttons(directive, expanded:)
120
129
  buttons = []
121
130
  buttons << [expanded ? '[-]' : "[+#{directive.body.size}]", :toggle] if directive.body
122
- buttons << ['[Run]', :execute] if directive.endpoint?
131
+ buttons << ['[Run]', :execute] if directive.endpoint? && directive.writes
123
132
  buttons
124
133
  end
125
134
 
135
+ # A self-closing directive, like <{ :toc }>, by its name after a colon, and the rest by theirs before one.
126
136
  def directive_text(directive)
127
- return ":#{directive.name}" unless directive.writes
137
+ return ":#{directive.name}" if !directive.writes && directive.line == directive.end_line
128
138
 
129
139
  [directive.name, ':', (" #{directive.args}" unless directive.args.empty?)].join
130
140
  end
data/lib/raindown/tui.rb CHANGED
@@ -4,26 +4,38 @@ require_relative 'projects'
4
4
  require_relative 'tui/navigator/navigator'
5
5
 
6
6
  module Raindown
7
- # The terminal UI, started with "raindown tui": a project's Markdown files beside the open file's outline,
7
+ # The terminal UI, started with "raindown": a project's Markdown files beside the open file's outline,
8
8
  # where directives fold, endpoints run and the editor opens at a line. Curses only loads here,
9
9
  # so watching and rendering start without it.
10
10
  module TUI
11
11
  module_function
12
12
 
13
- # PATH is a file to open, or a directory. Its project is the one it's in, or else the directory itself.
14
- def start(path)
13
+ # PATH is a file to open, or a directory. Its project is the one it's in, or else the directory itself. It returns
14
+ # true once it's quit. Without a terminal, like in a script or a service, it says so, once curses has loaded, and returns false, to be
15
+ # watched instead, as curses' own error would be "Error opening terminal: unknown". Bare and execute, like --bare and
16
+ # -x, allow bare directives in any file and run endpoints with no response as it writes back.
17
+ def start(path, bare: false, execute: false)
15
18
  require_relative 'tui/terminal/screen'
16
- # Without a terminal, curses' own error is "Error opening terminal: unknown".
17
- abort('raindown tui needs a terminal') unless $stdin.tty? && $stdout.tty?
19
+ unless $stdin.tty? && $stdout.tty?
20
+ warn("raindown needs a terminal, so it's watching instead")
21
+ return false
22
+ end
18
23
 
19
24
  path = File.expand_path(path)
20
25
  root = Projects.root(path) || (File.directory?(path) ? path : File.dirname(path))
21
26
  # The projects inside it are shown too. Each loads its own nodes in its worker's process, like when watching,
22
27
  # so two projects' nodes of the same name stay apart.
23
- workers = Workers.new(root:)
28
+ workers = Project.workers(root:, bare:, execute:)
24
29
 
25
- Screen.new(Navigator.new(root:, path: (path unless File.directory?(path)), workers:)).run
30
+ # Rouge loads here, rather than with Raindown, which doesn't highlight code outside the TUI.
31
+ require 'rouge'
32
+ # Saved files are written back, like when the folder's watched.
33
+ require_relative 'tui/saves'
34
+ saves = Saves.new(root)
35
+ Screen.new(Navigator.new(root:, path: (path unless File.directory?(path)), workers:, saves:, runs_endpoints: execute)).run
36
+ true
26
37
  ensure
38
+ saves&.stop
27
39
  workers&.stop
28
40
  end
29
41
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../projects'
4
+
5
+ module Raindown
6
+ module Watcher
7
+ # Listening skips these folders as they hold dependencies and builds, which Projects ignores too.
8
+ IGNORE = %r{(?:\A|/)(?:#{Projects::IGNORED.join('|')})/}
9
+
10
+ # A change to a project's nodes, or to one of its Markdown files, which is written back when it's been saved.
11
+ Change = Data.define(:root, :path, :nodes) do
12
+ def nodes? = nodes
13
+ def saved? = !nodes && File.exist?(path)
14
+ end
15
+
16
+ # A listener, still to start, that gives the block the paths saved, added or removed in the directory.
17
+ def self.listen(directory, &)
18
+ require 'listen'
19
+ Listen.to(directory, ignore: IGNORE) { |modified, added, removed| yield(modified + added + removed) }
20
+ end
21
+
22
+ # The changes among the paths, by the project each is in, the nearest one above it, or else the root, when there is
23
+ # one, leaving out files that are neither nodes nor Markdown, or are in ignored folders.
24
+ def self.changes(paths, root: nil)
25
+ paths.filter_map do |path|
26
+ project = Projects.root(path) || root || next
27
+ next Change.new(root: project, path:, nodes: true) if Projects.node?(path, root: project)
28
+
29
+ Change.new(root: project, path:, nodes: false) if Projects.markdown?(path, root: project)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../raindown'
4
+ require_relative '../rewriter'
5
+ require_relative 'changes'
6
+
7
+ module Raindown
8
+ module Watcher
9
+ # Watches one file and writes back to it as it's saved, like its TOC, tables and endpoint responses.
10
+ class FileWatcher
11
+ attr_reader :path, :bare, :execute
12
+
13
+ def initialize(path:, bare: false, execute: false)
14
+ @path = path
15
+ @bare = bare
16
+ @execute = execute
17
+ end
18
+
19
+ def start
20
+ $stdout.sync = true
21
+
22
+ safely { rewrite }
23
+ puts "Watching #{path} for changes..."
24
+
25
+ listener.start
26
+ sleep
27
+ end
28
+
29
+ private
30
+
31
+ def listener
32
+ directory = File.dirname(File.expand_path(path))
33
+ filename = File.basename(path)
34
+
35
+ Watcher.listen(directory) do |paths|
36
+ next unless paths.any? { File.basename(it) == filename && File.exist?(it) }
37
+
38
+ # Writing back saves the file again, and that save changes nothing, so it ends there.
39
+ safely { puts "Updated #{path}" if rewrite }
40
+ end
41
+ end
42
+
43
+ # An error in the file is shown rather than stopping the watcher, so it can be fixed and saved again.
44
+ def safely
45
+ yield
46
+ rescue StandardError => e
47
+ raise if ENV['RAINDOWN_DEBUG']
48
+
49
+ warn "raindown: #{path}: #{e.message}"
50
+ end
51
+
52
+ # The files' metadata is loaded again each time, so new and renamed files are listed.
53
+ def rewrite
54
+ Rewriter.update(path:, bare:, execute:, metadata: Pages.for(path))
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../raindown'
4
+ require_relative '../rewriter'
5
+
6
+ module Raindown
7
+ module Watcher
8
+ # Writes back to one project's Markdown files as they're saved, like its TOCs, tables and endpoint responses,
9
+ # with the project's own nodes, in its Workers::Worker. Each line it shows starts with the project's name.
10
+ class Project
11
+ attr_reader :root, :name
12
+
13
+ def initialize(root:, name: File.basename(root), bare: false, execute: false)
14
+ @root = root
15
+ @name = name
16
+ @bare = bare
17
+ @execute = execute
18
+ end
19
+
20
+ # Loads the project's nodes from its .raindown folder.
21
+ def start
22
+ Nodes.load(File.join(root, Nodes::FOLDER))
23
+ rescue StandardError, ScriptError => e
24
+ raise if ENV['RAINDOWN_DEBUG']
25
+
26
+ warn "[#{name}] raindown: #{Nodes::FOLDER}: #{e.message}"
27
+ end
28
+
29
+ # Writing back saves the file again, and that save changes nothing, so it ends there.
30
+ def update(path)
31
+ changed = Rewriter.update(path:, bare: @bare, execute: @execute, metadata: Pages.for(path))
32
+ puts "[#{name}] Updated #{relative(path)}" if changed
33
+ rescue StandardError => e
34
+ raise if ENV['RAINDOWN_DEBUG']
35
+
36
+ # An error in the file is shown rather than stopping the watcher, so it can be fixed and saved again.
37
+ warn "[#{name}] raindown: #{relative(path)}: #{e.message}"
38
+ end
39
+
40
+ private
41
+
42
+ def relative(path)
43
+ File.expand_path(path).delete_prefix(File.join(root, ''))
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../projects'
4
+ require_relative '../workers/workers'
5
+ require_relative 'changes'
6
+ require_relative 'project'
7
+
8
+ module Raindown
9
+ module Watcher
10
+ # Watches every Raindown project in a directory, like ~/dev, and writes back to each Markdown file as it's saved.
11
+ #
12
+ # Each project's nodes load in a process of its own, forked once the shared code has loaded, so starting a project
13
+ # costs almost nothing and two projects can have nodes of the same name. One listener watches the directory and
14
+ # sends each changed file to its project's process. Without fork, as on Windows, the projects share this process.
15
+ class Supervisor
16
+ attr_reader :directory, :workers
17
+
18
+ def initialize(directory:, bare: false, execute: false)
19
+ @directory = File.expand_path(directory)
20
+ @workers = Workers.new(root: @directory, fork: fork?, quiet: false) do |root|
21
+ Project.new(root:, name: name(root), bare:, execute:).tap(&:start)
22
+ end
23
+ @queues = {}
24
+ end
25
+
26
+ def start
27
+ $stdout.sync = true
28
+ preload
29
+
30
+ projects = Projects.roots(directory).each { workers[it].start }
31
+ puts "Watching #{projects.size} #{projects.size == 1 ? 'project' : 'projects'} in #{directory} for changes..."
32
+
33
+ trap_interrupt
34
+ Watcher.listen(directory) { handle(it) }.start
35
+ sleep
36
+ ensure
37
+ stop
38
+ end
39
+
40
+ # Sends each saved file to its project, starting a worker for a new project, or restarting one when its nodes
41
+ # change. With fork, each project takes its changes in order in a thread of its own, so a slow write-back, like an
42
+ # endpoint's request with -x, only holds up its own project. Without fork, the projects share this process, so
43
+ # they're taken in turn.
44
+ def handle(paths)
45
+ Watcher.changes(paths).each do |change|
46
+ next unless change.nodes? || change.saved?
47
+ next take(change) unless fork?
48
+
49
+ queue(change.root) << change
50
+ end
51
+ end
52
+
53
+ def stop
54
+ @queues.each_value(&:close)
55
+ workers.stop
56
+ end
57
+
58
+ private
59
+
60
+ # The project's changes still to take, by the thread that takes them, until the queue's closed.
61
+ def queue(root)
62
+ @queues[root] ||= Thread::Queue.new.tap do |queue|
63
+ Thread.new { while (change = queue.pop) do take(change) end }
64
+ end
65
+ end
66
+
67
+ # An error, like a worker stopping partway from a crash, is shown, and the project takes its next change, its
68
+ # worker starting again.
69
+ def take(change)
70
+ return restart(change.root) if change.nodes?
71
+
72
+ workers[change.root].call(:update, change.path)
73
+ rescue StandardError => e
74
+ warn "[#{name(change.root)}] raindown: #{e.message}"
75
+ end
76
+
77
+ # Without fork the projects share this process, where nodes can't be unloaded.
78
+ def restart(root)
79
+ return warn("[#{name(root)}] Restart raindown to load the node changes") unless fork?
80
+ return unless workers.restart(root)
81
+
82
+ puts "[#{name(root)}] Reloading nodes"
83
+ workers[root].start
84
+ end
85
+
86
+ # A project in the directory is named by its path from there, and the project the directory is in by its folder.
87
+ def name(root)
88
+ return File.basename(root) unless root.start_with?(File.join(directory, ''))
89
+
90
+ root.delete_prefix(File.join(directory, ''))
91
+ end
92
+
93
+ # Loaded before forking, so every project's process shares them instead of loading its own.
94
+ def preload
95
+ return unless fork?
96
+
97
+ require 'lownode'
98
+ require 'dirload'
99
+ end
100
+
101
+ def fork?
102
+ Process.respond_to?(:fork)
103
+ end
104
+
105
+ # Ctrl+C reaches every process in the terminal, so the workers stop with the watcher.
106
+ def trap_interrupt
107
+ trap('INT') { exit }
108
+ end
109
+ end
110
+ end
111
+ end
@@ -1,13 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'project'
4
-
5
3
  module Raindown
6
- module TUI
7
- # A Project in a process of its own, forked so it loads the project's nodes apart from other projects', like watching.
4
+ class Workers
5
+ # What answers a project's requests, made by the block, like the terminal UI's Project, or a Watcher::Project when
6
+ # watching, in a process of its own, forked so it loads the project's nodes apart from other projects'.
8
7
  # Requests and answers go through two pipes, as Marshal data, one at a time. Without fork, like on Windows,
9
- # or with fork off, like in specs, the Project answers in this process instead.
10
- # The process ends with exit!, since it's forked mid-call, so the terminal UI's ensure doesn't run in it.
8
+ # or with fork off, like in specs, it answers in this process instead. A quiet worker writes nothing, as the terminal
9
+ # UI's screen is its own. The process ends with exit!, since it's forked mid-call, so the parent's ensure doesn't run.
11
10
  class Worker
12
11
  class Error < StandardError; end
13
12
 
@@ -27,12 +26,14 @@ module Raindown
27
26
 
28
27
  attr_reader :root
29
28
 
30
- def initialize(root:, fork: self.class.fork?)
29
+ def initialize(root:, fork: self.class.fork?, quiet: true, &answering)
31
30
  @root = root
32
31
  @fork = fork
32
+ @quiet = quiet
33
+ @answering = answering
33
34
  end
34
35
 
35
- # The Project's answer, or a Worker::Error with its error's message. A worker that stopped starts again.
36
+ # The answer, or a Worker::Error with its error's message. A worker that stopped starts again.
36
37
  def call(name, *args, **options)
37
38
  return local(name, args, options) unless @fork
38
39
 
@@ -54,21 +55,12 @@ module Raindown
54
55
  @pid = nil
55
56
  end
56
57
 
57
- private
58
-
59
- def local(name, args, options)
60
- @project ||= Project.new(root:)
61
- @project.public_send(name, *args, **options)
62
- rescue StandardError => e
63
- raise Error, e.message
64
- end
65
-
58
+ # Starts the worker's process, or makes what answers in this one, so it's ready before the first request.
66
59
  def start
60
+ return @answerer ||= @answering.call unless @fork
61
+
67
62
  stop
68
- requests, @requests = IO.pipe
69
- @answers, answers = IO.pipe
70
- self.class.pipes.reject!(&:closed?)
71
- self.class.pipes.push(@requests, @answers)
63
+ requests, answers = open_pipes
72
64
  @pid = fork do
73
65
  self.class.pipes.each { it.close unless it.closed? }
74
66
  serve(requests, answers)
@@ -77,23 +69,39 @@ module Raindown
77
69
  answers.close
78
70
  end
79
71
 
80
- # Answers until the terminal UI closes its end. The screen is the terminal UI's, so nothing is written to it,
81
- # and Ctrl+C, which reaches every process in the terminal, is the terminal UI's to handle.
72
+ private
73
+
74
+ # The worker's ends of the pipes, while this process keeps the others.
75
+ def open_pipes
76
+ requests, @requests = IO.pipe
77
+ @answers, answers = IO.pipe
78
+ self.class.pipes.reject!(&:closed?)
79
+ self.class.pipes.push(@requests, @answers)
80
+ [requests, answers]
81
+ end
82
+
83
+ def local(name, args, options)
84
+ start.public_send(name, *args, **options)
85
+ rescue StandardError => e
86
+ raise Error, e.message
87
+ end
88
+
89
+ # Answers until the parent closes its end. Ctrl+C, which reaches every process in the terminal, is the parent's to
90
+ # handle, stopping its workers.
82
91
  def serve(requests, answers)
83
92
  trap('INT', 'IGNORE')
84
- $stdout.reopen(File::NULL)
85
- $stderr.reopen(File::NULL)
86
- project = Project.new(root:)
93
+ [$stdout, $stderr].each { it.reopen(File::NULL) } if @quiet
94
+ answerer = @answering.call
87
95
 
88
96
  while (request = read(requests))
89
- write(answers, answer(project, *request))
97
+ write(answers, answer(answerer, *request))
90
98
  end
91
99
  ensure
92
100
  exit!(0)
93
101
  end
94
102
 
95
- def answer(project, name, args, options)
96
- [:ok, project.public_send(name, *args, **options)]
103
+ def answer(answerer, name, args, options)
104
+ [:ok, answerer.public_send(name, *args, **options)]
97
105
  rescue StandardError, ScriptError => e
98
106
  [:error, e.message]
99
107
  end