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.
- checksums.yaml +4 -4
- data/lib/nodes/endpoint_node.rb +3 -2
- data/lib/raindown/actions.rb +5 -3
- data/lib/raindown/bare_directives.rb +14 -27
- data/lib/raindown/blueprints.rb +255 -0
- data/lib/raindown/chart/chart.rb +12 -1
- data/lib/raindown/cli.rb +39 -54
- data/lib/raindown/config.rb +2 -3
- data/lib/raindown/directives.rb +30 -13
- data/lib/raindown/document.rb +22 -20
- data/lib/raindown/endpoint/curl.rb +11 -7
- data/lib/raindown/endpoint/endpoint_runner.rb +13 -5
- data/lib/raindown/node_blocks.rb +31 -22
- data/lib/raindown/node_injector.rb +7 -10
- data/lib/raindown/nodes.rb +1 -0
- data/lib/raindown/pages.rb +1 -1
- data/lib/raindown/pre_renderer.rb +18 -0
- data/lib/raindown/stat.rb +53 -0
- data/lib/raindown/tui/blueprints/blueprints_renderer.rb +93 -0
- data/lib/raindown/tui/blueprints/blueprints_view.rb +146 -0
- data/lib/raindown/tui/blueprints/canvas.rb +58 -0
- data/lib/raindown/tui/blueprints/code_comments.rb +71 -0
- data/lib/raindown/tui/blueprints/filter.rb +77 -0
- data/lib/raindown/tui/blueprints/traced_code.rb +56 -0
- data/lib/raindown/tui/blueprints/tracing.rb +129 -0
- data/lib/raindown/tui/code_box.rb +143 -0
- data/lib/raindown/tui/endpoints.rb +2 -2
- data/lib/raindown/tui/events.rb +18 -1
- data/lib/raindown/tui/feelings/batch.rb +47 -0
- data/lib/raindown/tui/feelings/bins.rb +67 -0
- data/lib/raindown/tui/feelings/celebration/celebration.rb +28 -0
- data/lib/raindown/tui/feelings/celebration/fireworks.rb +75 -0
- data/lib/raindown/tui/feelings/celebration/rain.rb +66 -0
- data/lib/raindown/tui/feelings/celebration/scene.rb +40 -0
- data/lib/raindown/tui/feelings/cell_moves.rb +34 -0
- data/lib/raindown/tui/feelings/code_methods.rb +134 -0
- data/lib/raindown/tui/feelings/feelings_code_box.rb +40 -0
- data/lib/raindown/tui/feelings/feelings_log.rb +136 -0
- data/lib/raindown/tui/feelings/feelings_renderer.rb +183 -0
- data/lib/raindown/tui/feelings/feelings_view.rb +157 -0
- data/lib/raindown/tui/feelings/magnifier.rb +68 -0
- data/lib/raindown/tui/feelings/motion.rb +93 -0
- data/lib/raindown/tui/feelings/waffle/baked_waffle.rb +88 -0
- data/lib/raindown/tui/feelings/waffle/balloons.rb +81 -0
- data/lib/raindown/tui/feelings/waffle/confetti.rb +55 -0
- data/lib/raindown/tui/feelings/waffle/sparkles.rb +61 -0
- data/lib/raindown/tui/feelings/waffle/waffle.rb +62 -0
- data/lib/raindown/tui/feelings/weekly_batch.rb +51 -0
- data/lib/raindown/tui/file_tree.rb +6 -5
- data/lib/raindown/tui/highlighter.rb +45 -0
- data/lib/raindown/tui/layout.rb +32 -18
- data/lib/raindown/tui/navigator/commands.rb +46 -2
- data/lib/raindown/tui/navigator/mouse.rb +43 -0
- data/lib/raindown/tui/navigator/navigator.rb +54 -25
- data/lib/raindown/tui/navigator/teleport.rb +128 -0
- data/lib/raindown/tui/navigator/views.rb +5 -3
- data/lib/raindown/tui/{workers/project.rb → project.rb} +38 -27
- data/lib/raindown/tui/{menu_overlay.rb → project_box.rb} +2 -2
- data/lib/raindown/tui/renderers/dashboard_renderer.rb +4 -4
- data/lib/raindown/tui/renderers/endpoint_renderer.rb +14 -10
- data/lib/raindown/tui/renderers/outline_renderer.rb +4 -4
- data/lib/raindown/tui/renderers/renderer.rb +4 -0
- data/lib/raindown/tui/row.rb +4 -2
- data/lib/raindown/tui/rows.rb +2 -1
- data/lib/raindown/tui/saves.rb +22 -0
- data/lib/raindown/tui/search_box.rb +86 -0
- data/lib/raindown/tui/terminal/input.rb +95 -7
- data/lib/raindown/tui/terminal/screen.rb +37 -29
- data/lib/raindown/tui/terminal/styles.rb +111 -26
- data/lib/raindown/tui/terminal/terminal_title.rb +31 -0
- data/lib/raindown/tui/text.rb +62 -12
- data/lib/raindown/tui/title.rb +1 -1
- data/lib/raindown/tui/views/dashboard_view.rb +7 -5
- data/lib/raindown/tui/views/endpoint_view.rb +4 -2
- data/lib/raindown/tui/views/list_view.rb +2 -0
- data/lib/raindown/tui/views/outline_view.rb +16 -6
- data/lib/raindown/tui.rb +19 -7
- data/lib/raindown/watcher/changes.rb +33 -0
- data/lib/raindown/watcher/file_watcher.rb +58 -0
- data/lib/raindown/watcher/project.rb +47 -0
- data/lib/raindown/watcher/supervisor.rb +111 -0
- data/lib/raindown/{tui/workers → workers}/worker.rb +37 -29
- data/lib/raindown/workers/workers.rb +63 -0
- data/lib/raindown.rb +4 -4
- data/lib/version.rb +1 -1
- metadata +60 -9
- data/lib/raindown/project_worker.rb +0 -48
- data/lib/raindown/supervisor.rb +0 -160
- data/lib/raindown/tui/workers/workers.rb +0 -53
- data/lib/raindown/watcher.rb +0 -57
data/lib/raindown/tui/text.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
data/lib/raindown/tui/title.rb
CHANGED
|
@@ -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 = '🌧
|
|
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
|
-
#
|
|
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.
|
|
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 '
|
|
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
|
|
@@ -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 '
|
|
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
|
-
|
|
108
|
-
|
|
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}"
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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 =
|
|
28
|
+
workers = Project.workers(root:, bare:, execute:)
|
|
24
29
|
|
|
25
|
-
|
|
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
|
-
|
|
7
|
-
#
|
|
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,
|
|
10
|
-
# The process ends with exit!, since it's forked mid-call, so the
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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(
|
|
97
|
+
write(answers, answer(answerer, *request))
|
|
90
98
|
end
|
|
91
99
|
ensure
|
|
92
100
|
exit!(0)
|
|
93
101
|
end
|
|
94
102
|
|
|
95
|
-
def answer(
|
|
96
|
-
[:ok,
|
|
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
|