raindown 0.5.1 → 0.7.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/raindown/chart/chart.rb +12 -1
- data/lib/raindown/cli.rb +2 -2
- data/lib/raindown/document.rb +16 -9
- data/lib/raindown/endpoint/curl.rb +19 -0
- data/lib/raindown/node_blocks.rb +9 -6
- data/lib/raindown/nodes.rb +1 -0
- data/lib/raindown/projects.rb +8 -4
- data/lib/raindown/stat.rb +53 -0
- data/lib/raindown/tui/card.rb +56 -0
- data/lib/raindown/tui/endpoint.rb +16 -0
- data/lib/raindown/tui/endpoints.rb +40 -0
- data/lib/raindown/tui/events.rb +50 -0
- data/lib/raindown/tui/feelings/batch.rb +47 -0
- data/lib/raindown/tui/feelings/bins.rb +67 -0
- data/lib/raindown/tui/feelings/celebration.rb +25 -0
- data/lib/raindown/tui/feelings/code_box.rb +129 -0
- data/lib/raindown/tui/feelings/code_methods.rb +134 -0
- data/lib/raindown/tui/feelings/feelings_log.rb +133 -0
- data/lib/raindown/tui/feelings/feelings_renderer.rb +184 -0
- data/lib/raindown/tui/feelings/feelings_view.rb +161 -0
- data/lib/raindown/tui/feelings/fireworks.rb +75 -0
- data/lib/raindown/tui/feelings/rain.rb +66 -0
- data/lib/raindown/tui/feelings/scene.rb +37 -0
- data/lib/raindown/tui/feelings/waffle.rb +43 -0
- data/lib/raindown/tui/feelings/weekly_batch.rb +32 -0
- data/lib/raindown/tui/file_tree.rb +55 -12
- data/lib/raindown/tui/highlighter.rb +45 -0
- data/lib/raindown/tui/layout.rb +66 -77
- data/lib/raindown/tui/menu_overlay.rb +59 -0
- data/lib/raindown/tui/navigator/commands.rb +97 -0
- data/lib/raindown/tui/navigator/focus.rb +25 -0
- data/lib/raindown/tui/navigator/navigator.rb +160 -0
- data/lib/raindown/tui/navigator/project_menu.rb +61 -0
- data/lib/raindown/tui/navigator/search.rb +126 -0
- data/lib/raindown/tui/navigator/views.rb +52 -0
- data/lib/raindown/tui/renderers/dashboard_renderer.rb +102 -0
- data/lib/raindown/tui/renderers/endpoint_renderer.rb +81 -0
- data/lib/raindown/tui/renderers/list_renderer.rb +15 -0
- data/lib/raindown/tui/renderers/outline_renderer.rb +21 -0
- data/lib/raindown/tui/renderers/renderer.rb +18 -0
- data/lib/raindown/tui/row.rb +12 -3
- data/lib/raindown/tui/rows.rb +58 -0
- data/lib/raindown/tui/search_overlay.rb +73 -0
- data/lib/raindown/tui/terminal/input.rb +91 -0
- data/lib/raindown/tui/terminal/ncurses_shim.rb +46 -0
- data/lib/raindown/tui/terminal/quiet.rb +32 -0
- data/lib/raindown/tui/{screen.rb → terminal/screen.rb} +42 -24
- data/lib/raindown/tui/terminal/styles.rb +154 -0
- data/lib/raindown/tui/text.rb +110 -0
- data/lib/raindown/tui/title.rb +29 -0
- data/lib/raindown/tui/views/dashboard_view.rb +147 -0
- data/lib/raindown/tui/views/endpoint_view.rb +77 -0
- data/lib/raindown/tui/{pane.rb → views/list_view.rb} +11 -8
- data/lib/raindown/tui/{outline.rb → views/outline_view.rb} +48 -20
- data/lib/raindown/tui/workers/project.rb +122 -0
- data/lib/raindown/tui/workers/worker.rb +124 -0
- data/lib/raindown/tui/workers/workers.rb +53 -0
- data/lib/raindown/tui.rb +10 -5
- data/lib/version.rb +1 -1
- metadata +63 -9
- data/lib/raindown/tui/commands.rb +0 -70
- data/lib/raindown/tui/input.rb +0 -55
- data/lib/raindown/tui/navigator.rb +0 -123
- data/lib/raindown/tui/styles.rb +0 -36
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../events'
|
|
4
|
+
require_relative '../workers/worker'
|
|
5
|
+
|
|
6
|
+
module Raindown
|
|
7
|
+
module TUI
|
|
8
|
+
# An endpoint opened from the files, beside them: its request, and the response its file has, read by its project's
|
|
9
|
+
# Worker when it's opened, which scrolls.
|
|
10
|
+
# A key's next step, for the Navigator, is nil, an Open or Edit of its directive, or an Execute of it.
|
|
11
|
+
class EndpointView
|
|
12
|
+
# The date a response was fetched, on the line after it.
|
|
13
|
+
FETCHED = /raindown:fetched (\S+)/
|
|
14
|
+
FENCE = /\A\s*`{3,}(\S*)/
|
|
15
|
+
|
|
16
|
+
attr_reader :endpoint, :response, :language, :fetched, :error
|
|
17
|
+
|
|
18
|
+
def initialize(endpoint, workers:)
|
|
19
|
+
@endpoint = endpoint
|
|
20
|
+
@workers = workers
|
|
21
|
+
@offset = 0
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def renderer = EndpointRenderer
|
|
25
|
+
def target = endpoint
|
|
26
|
+
def path = endpoint.path
|
|
27
|
+
def status(_navigator) = [:info, endpoint.text]
|
|
28
|
+
def changed? = false
|
|
29
|
+
def typing? = false
|
|
30
|
+
def animating? = false
|
|
31
|
+
def next_step?(key) = %i[up down enter edit execute].include?(key)
|
|
32
|
+
|
|
33
|
+
# Clicks on the response only move the focus to it.
|
|
34
|
+
def cursor=(_index); end
|
|
35
|
+
|
|
36
|
+
# Reads the response again, like after the request's run.
|
|
37
|
+
def load
|
|
38
|
+
lines = output_lines
|
|
39
|
+
@fetched = lines.pop[FETCHED, 1] if lines.last&.match?(FETCHED)
|
|
40
|
+
@language = lines.first.to_s[FENCE, 1]
|
|
41
|
+
@response = @language ? lines[1...-1] : lines
|
|
42
|
+
@error = nil
|
|
43
|
+
rescue Worker::Error => e
|
|
44
|
+
@response = []
|
|
45
|
+
@error = e.message
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def next_step(key)
|
|
49
|
+
case key
|
|
50
|
+
when :up, :down then scroll(key == :up ? -1 : 1)
|
|
51
|
+
when :enter then Open.new(path: endpoint.path, line: endpoint.line)
|
|
52
|
+
when :edit then Edit.new(path: endpoint.path, line: endpoint.line)
|
|
53
|
+
when :execute then Execute.new(path: endpoint.path, line: endpoint.line, name: endpoint.text)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The response's lines that fit in the height, from where it's scrolled to.
|
|
58
|
+
def visible(height)
|
|
59
|
+
@offset = @offset.clamp(0, [response.length - height, 0].max)
|
|
60
|
+
response[@offset, height] || []
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def output_lines
|
|
66
|
+
outline = @workers.for(path).call(:outline, path)
|
|
67
|
+
directive = outline.directives.find { it.endpoint? && it.line == endpoint.line }
|
|
68
|
+
directive&.output ? outline.lines[(directive.output.begin - 1)...directive.output.end] : []
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def scroll(by)
|
|
72
|
+
@offset += by
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module Raindown
|
|
4
4
|
module TUI
|
|
5
|
-
#
|
|
6
|
-
class
|
|
5
|
+
# Rows with a cursor, and how far they're scrolled, which can change between draws, like when a folder folds.
|
|
6
|
+
class ListView
|
|
7
7
|
attr_writer :cursor
|
|
8
8
|
|
|
9
9
|
def initialize(&rows)
|
|
@@ -12,6 +12,10 @@ module Raindown
|
|
|
12
12
|
@offset = 0
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
+
def renderer = ListRenderer
|
|
16
|
+
def typing? = false
|
|
17
|
+
def animating? = false
|
|
18
|
+
|
|
15
19
|
def rows = @rows.call
|
|
16
20
|
def selected = rows[cursor]
|
|
17
21
|
|
|
@@ -20,13 +24,12 @@ module Raindown
|
|
|
20
24
|
@cursor.clamp(0, [rows.length - 1, 0].max)
|
|
21
25
|
end
|
|
22
26
|
|
|
27
|
+
# Moves past rows that can't be selected, like separators, and stays at the first or last row rather than moving off them.
|
|
23
28
|
def move(by)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
@cursor = 0
|
|
29
|
-
@offset = 0
|
|
29
|
+
rows = self.rows
|
|
30
|
+
index = cursor + by
|
|
31
|
+
index += by while index.between?(0, rows.length - 1) && !rows[index].selectable?
|
|
32
|
+
@cursor = index if index.between?(0, rows.length - 1)
|
|
30
33
|
end
|
|
31
34
|
|
|
32
35
|
# The rows that fit in the height, scrolled so the cursor's row is one of them, and the first one's index.
|
|
@@ -1,30 +1,43 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative '
|
|
4
|
-
require_relative '
|
|
3
|
+
require_relative '../../document'
|
|
4
|
+
require_relative '../events'
|
|
5
|
+
require_relative 'list_view'
|
|
6
|
+
require_relative '../highlighter'
|
|
7
|
+
require_relative '../row'
|
|
8
|
+
require_relative '../workers/worker'
|
|
5
9
|
|
|
6
10
|
module Raindown
|
|
7
11
|
module TUI
|
|
8
|
-
# The outline
|
|
9
|
-
# and a directive's
|
|
10
|
-
|
|
12
|
+
# The outline: a file's headings and directives, read by its project's Worker. Headings fold the section below them,
|
|
13
|
+
# and a directive's body expands below it, like the lines a node wrote back or an endpoint's request and response.
|
|
14
|
+
# Both start folded away. It's read when it's opened, and it's a ListView, with its own cursor.
|
|
15
|
+
class OutlineView < ListView
|
|
11
16
|
attr_reader :path, :error
|
|
12
17
|
|
|
13
|
-
def initialize(path)
|
|
18
|
+
def initialize(path, workers:)
|
|
19
|
+
super()
|
|
14
20
|
@path = path
|
|
21
|
+
@workers = workers
|
|
15
22
|
@folded = Set.new
|
|
16
23
|
@expanded = Set.new
|
|
17
|
-
read
|
|
18
24
|
end
|
|
19
25
|
|
|
26
|
+
def renderer = OutlineRenderer
|
|
27
|
+
def target = path
|
|
28
|
+
def next_step?(_key) = false
|
|
29
|
+
def status(navigator) = [:info, "Opened #{navigator.relative(path)}"]
|
|
30
|
+
|
|
20
31
|
# Reads the file again, keeping what's folded.
|
|
21
|
-
def
|
|
32
|
+
def load
|
|
22
33
|
@mtime = File.mtime(path)
|
|
23
|
-
|
|
24
|
-
@lines =
|
|
25
|
-
@
|
|
34
|
+
outline = @workers.for(path).call(:outline, path)
|
|
35
|
+
@lines = outline.lines
|
|
36
|
+
@headings = outline.headings
|
|
37
|
+
@directives = outline.directives
|
|
38
|
+
@highlighted = {}
|
|
26
39
|
@error = nil
|
|
27
|
-
rescue
|
|
40
|
+
rescue Worker::Error, SystemCallError => e
|
|
28
41
|
@error = e.message
|
|
29
42
|
end
|
|
30
43
|
|
|
@@ -72,34 +85,49 @@ module Raindown
|
|
|
72
85
|
|
|
73
86
|
# Headings a directive wrote are part of its output, so they show when it's expanded rather than on their own.
|
|
74
87
|
def items
|
|
75
|
-
outputs = @
|
|
76
|
-
@
|
|
88
|
+
outputs = @directives.filter_map(&:output)
|
|
89
|
+
(@headings + @directives).sort_by(&:line)
|
|
90
|
+
.reject { |item| item.is_a?(Document::Heading) && outputs.any? { it.cover?(item.line) } }
|
|
77
91
|
end
|
|
78
92
|
|
|
79
93
|
def min_level
|
|
80
|
-
@
|
|
94
|
+
@headings.map(&:level).min || 1
|
|
81
95
|
end
|
|
82
96
|
|
|
83
97
|
def heading_row(heading)
|
|
84
98
|
folded = @folded.include?(heading.line) ? ' …' : ''
|
|
85
99
|
Row.new(text: "#{'#' * heading.level} #{heading.text}#{folded}", depth: heading.level - min_level, kind: :heading,
|
|
86
|
-
target: heading)
|
|
100
|
+
target: heading, fold: -> { fold(heading) })
|
|
87
101
|
end
|
|
88
102
|
|
|
89
103
|
def directive_rows(directive, depth:)
|
|
90
104
|
expanded = @expanded.include?(directive.line)
|
|
91
105
|
row = Row.new(text: directive_text(directive), depth:, kind: :directive, target: directive,
|
|
92
|
-
buttons: buttons(directive, expanded:))
|
|
106
|
+
buttons: buttons(directive, expanded:), **directive_action(directive))
|
|
93
107
|
return [row] unless expanded
|
|
94
108
|
|
|
95
|
-
|
|
96
|
-
|
|
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])
|
|
97
113
|
end]
|
|
98
114
|
end
|
|
99
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
|
+
|
|
121
|
+
# A directive with a body expands it, and one without opens in the editor, like a line of its body.
|
|
122
|
+
def directive_action(directive)
|
|
123
|
+
if directive.body then { fold: -> { expand(directive) } }
|
|
124
|
+
else { open: Edit.new(path:, line: directive.line) }
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
100
128
|
def buttons(directive, expanded:)
|
|
101
129
|
buttons = []
|
|
102
|
-
buttons << [expanded ? '[-]' : "[+#{directive.
|
|
130
|
+
buttons << [expanded ? '[-]' : "[+#{directive.body.size}]", :toggle] if directive.body
|
|
103
131
|
buttons << ['[Run]', :execute] if directive.endpoint?
|
|
104
132
|
buttons
|
|
105
133
|
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../../actions'
|
|
4
|
+
require_relative '../../bare_directives'
|
|
5
|
+
require_relative '../../chart/chart'
|
|
6
|
+
require_relative '../../document'
|
|
7
|
+
require_relative '../../front_matter'
|
|
8
|
+
require_relative '../../node_blocks'
|
|
9
|
+
require_relative '../../nodes'
|
|
10
|
+
require_relative '../../pages'
|
|
11
|
+
require_relative '../../projects'
|
|
12
|
+
require_relative '../../stat'
|
|
13
|
+
require_relative '../card'
|
|
14
|
+
require_relative '../endpoint'
|
|
15
|
+
require_relative '../../code_blocks'
|
|
16
|
+
require_relative '../../endpoint/curl'
|
|
17
|
+
|
|
18
|
+
module Raindown
|
|
19
|
+
module TUI
|
|
20
|
+
# What the terminal UI asks of a project, answered in the project's Worker with its own nodes loaded,
|
|
21
|
+
# so two projects' nodes of the same name stay apart. Answers are plain data, which a Worker sends back through a pipe.
|
|
22
|
+
class Project
|
|
23
|
+
Outline = Data.define(:lines, :headings, :directives)
|
|
24
|
+
|
|
25
|
+
attr_reader :root
|
|
26
|
+
|
|
27
|
+
# The projects inside this one have workers of their own, so their files are left out.
|
|
28
|
+
def initialize(root:)
|
|
29
|
+
@root = root
|
|
30
|
+
@load_errors = []
|
|
31
|
+
@file_errors = []
|
|
32
|
+
Nodes.load(File.join(root, Nodes::FOLDER))
|
|
33
|
+
rescue StandardError, ScriptError => e
|
|
34
|
+
@load_errors << "#{File.basename(root)}/#{Nodes::FOLDER}: #{e.message}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# A file's lines, headings and directives.
|
|
38
|
+
def outline(path)
|
|
39
|
+
document = Document.read(path)
|
|
40
|
+
Outline.new(lines: File.readlines(path, chomp: true), headings: document.headings, directives: document.directives)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Every chart and stat in the project's files, or any node that draws a card, drawn from its data, and the files
|
|
44
|
+
# that couldn't be read.
|
|
45
|
+
# The charts are found again when asked, and otherwise drawn again, so they follow changes to their data.
|
|
46
|
+
def charts(find: false)
|
|
47
|
+
if find || @charts.nil?
|
|
48
|
+
@file_errors = []
|
|
49
|
+
@charts = Projects.files(root).flat_map { |path| charts_in(path) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
{ cards: drawn, errors: @load_errors + @file_errors }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Every endpoint in the project's files, by its request, leaving out the ones whose snippet can't be read.
|
|
56
|
+
def endpoints
|
|
57
|
+
Projects.files(root).flat_map do |path|
|
|
58
|
+
lines = File.readlines(path, encoding: Encoding::UTF_8)
|
|
59
|
+
Document.read(path).directives.select(&:endpoint?).filter_map { |directive| endpoint(path, lines, directive) }
|
|
60
|
+
rescue StandardError
|
|
61
|
+
[]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def write_back(path)
|
|
66
|
+
Actions.write_back(path)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Runs the endpoint on the line, replacing its response.
|
|
70
|
+
def execute(path, line)
|
|
71
|
+
directive = Document.read(path).directives.find { it.line == line && it.endpoint? }
|
|
72
|
+
raise Actions::NotAnEndpoint, "There's no endpoint on line #{line}" unless directive
|
|
73
|
+
|
|
74
|
+
Actions.execute(path, directive)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def endpoint(path, lines, directive)
|
|
80
|
+
snippet = CodeBlocks.endpoint(lines, directive.line).first&.dig(:body)
|
|
81
|
+
request = Curl.request(snippet.to_s)
|
|
82
|
+
Endpoint.new(project: root, path:, line: directive.line, **request.to_h) if request
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def charts_in(path)
|
|
86
|
+
content = File.read(path, encoding: Encoding::UTF_8)
|
|
87
|
+
bare = BareDirectives.enabled?(path:, content:)
|
|
88
|
+
lines = content.lines
|
|
89
|
+
headings = Document.parse(content, bare:).headings
|
|
90
|
+
|
|
91
|
+
NodeBlocks.find(lines, front_matter: FrontMatter.parse(content).first, bare:)
|
|
92
|
+
.select { it[:node].respond_to?(:card_lines) }
|
|
93
|
+
.map { |block| chart(block, path:, lines:, headings:) }
|
|
94
|
+
rescue StandardError => e
|
|
95
|
+
@file_errors << "#{path.delete_prefix(File.join(root, ''))}: #{e.message}"
|
|
96
|
+
[]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def drawn
|
|
100
|
+
pages = {}
|
|
101
|
+
@charts.map do |chart|
|
|
102
|
+
lines = lines(chart, pages[File.dirname(chart[:path])] ||= Pages.for(chart[:path]))
|
|
103
|
+
Card.new(project: root, chart: lines, **chart.except(:node))
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def chart(block, path:, lines:, headings:)
|
|
108
|
+
line = block[:open] + 1
|
|
109
|
+
directive = lines[block[:open]].strip.delete_prefix('<{').delete_suffix('}>').strip
|
|
110
|
+
|
|
111
|
+
{ path:, line:, heading: headings.select { it.line < line }.last&.text, directive:, node: block[:node] }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Draws the chart or stat as its Markdown would be written back, with the files its project lists.
|
|
115
|
+
def lines(chart, metadata)
|
|
116
|
+
chart[:node].card_lines(metadata:)
|
|
117
|
+
rescue StandardError => e
|
|
118
|
+
["Can't chart: #{e.message}"]
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'project'
|
|
4
|
+
|
|
5
|
+
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.
|
|
8
|
+
# 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.
|
|
11
|
+
class Worker
|
|
12
|
+
class Error < StandardError; end
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
attr_writer :fork
|
|
16
|
+
|
|
17
|
+
def fork?
|
|
18
|
+
@fork.nil? ? Process.respond_to?(:fork) : @fork
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Every worker's pipes, which a new worker closes the others of, since a worker holding another's pipe open
|
|
22
|
+
# would keep that worker from seeing it close.
|
|
23
|
+
def pipes
|
|
24
|
+
@pipes ||= []
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
attr_reader :root
|
|
29
|
+
|
|
30
|
+
def initialize(root:, fork: self.class.fork?)
|
|
31
|
+
@root = root
|
|
32
|
+
@fork = fork
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The Project's answer, or a Worker::Error with its error's message. A worker that stopped starts again.
|
|
36
|
+
def call(name, *args, **options)
|
|
37
|
+
return local(name, args, options) unless @fork
|
|
38
|
+
|
|
39
|
+
start unless alive?
|
|
40
|
+
write(@requests, [name, args, options])
|
|
41
|
+
status, value = read(@answers) || raise(Error, "The worker for #{File.basename(root)} stopped")
|
|
42
|
+
status == :ok ? value : raise(Error, value)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def stop
|
|
46
|
+
[@requests, @answers].compact.each { it.close unless it.closed? }
|
|
47
|
+
return unless @pid
|
|
48
|
+
|
|
49
|
+
Process.kill('TERM', @pid)
|
|
50
|
+
Process.wait(@pid)
|
|
51
|
+
rescue Errno::ECHILD, Errno::ESRCH
|
|
52
|
+
nil
|
|
53
|
+
ensure
|
|
54
|
+
@pid = nil
|
|
55
|
+
end
|
|
56
|
+
|
|
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
|
+
|
|
66
|
+
def start
|
|
67
|
+
stop
|
|
68
|
+
requests, @requests = IO.pipe
|
|
69
|
+
@answers, answers = IO.pipe
|
|
70
|
+
self.class.pipes.reject!(&:closed?)
|
|
71
|
+
self.class.pipes.push(@requests, @answers)
|
|
72
|
+
@pid = fork do
|
|
73
|
+
self.class.pipes.each { it.close unless it.closed? }
|
|
74
|
+
serve(requests, answers)
|
|
75
|
+
end
|
|
76
|
+
requests.close
|
|
77
|
+
answers.close
|
|
78
|
+
end
|
|
79
|
+
|
|
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.
|
|
82
|
+
def serve(requests, answers)
|
|
83
|
+
trap('INT', 'IGNORE')
|
|
84
|
+
$stdout.reopen(File::NULL)
|
|
85
|
+
$stderr.reopen(File::NULL)
|
|
86
|
+
project = Project.new(root:)
|
|
87
|
+
|
|
88
|
+
while (request = read(requests))
|
|
89
|
+
write(answers, answer(project, *request))
|
|
90
|
+
end
|
|
91
|
+
ensure
|
|
92
|
+
exit!(0)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def answer(project, name, args, options)
|
|
96
|
+
[:ok, project.public_send(name, *args, **options)]
|
|
97
|
+
rescue StandardError, ScriptError => e
|
|
98
|
+
[:error, e.message]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def alive?
|
|
102
|
+
@pid && Process.waitpid(@pid, Process::WNOHANG).nil?
|
|
103
|
+
rescue Errno::ECHILD
|
|
104
|
+
false
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# A message is its length, then its Marshal data.
|
|
108
|
+
def write(io, message)
|
|
109
|
+
data = Marshal.dump(message)
|
|
110
|
+
io.write([data.bytesize].pack('N'), data)
|
|
111
|
+
io.flush
|
|
112
|
+
rescue Errno::EPIPE, IOError
|
|
113
|
+
raise Error, "The worker for #{File.basename(root)} stopped"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def read(io)
|
|
117
|
+
length = io.read(4)&.unpack1('N')
|
|
118
|
+
Marshal.load(io.read(length)) if length # rubocop:disable Security/MarshalLoad -- from this process's own fork
|
|
119
|
+
rescue IOError
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../../projects'
|
|
4
|
+
require_relative 'worker'
|
|
5
|
+
|
|
6
|
+
module Raindown
|
|
7
|
+
module TUI
|
|
8
|
+
# A Worker for each project in the terminal UI's folder: the folder's own, and every project inside it.
|
|
9
|
+
# A file's requests go to the worker of the project it's in, the nearest one above it, even when it's new.
|
|
10
|
+
# Workers start when they're first asked something.
|
|
11
|
+
class Workers
|
|
12
|
+
attr_reader :root, :roots
|
|
13
|
+
|
|
14
|
+
def initialize(root:, fork: Worker.fork?)
|
|
15
|
+
@root = root
|
|
16
|
+
@fork = fork
|
|
17
|
+
@workers = {}
|
|
18
|
+
find_roots
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def for(path)
|
|
22
|
+
worker(Projects.root(path) || root)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# A worker for each project, as they were found when the workers last started.
|
|
26
|
+
def each(&)
|
|
27
|
+
roots.map { worker(it) }.each(&)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Stops the workers, so the next requests start them again, loading their projects' nodes again,
|
|
31
|
+
# and finds the projects again, like ones added since.
|
|
32
|
+
def restart
|
|
33
|
+
stop
|
|
34
|
+
@workers.clear
|
|
35
|
+
find_roots
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def stop
|
|
39
|
+
@workers.each_value(&:stop)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def worker(project)
|
|
45
|
+
@workers[project] ||= Worker.new(root: project, fork: @fork)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def find_roots
|
|
49
|
+
@roots = [root, *Projects.roots(root)].uniq.sort
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
data/lib/raindown/tui.rb
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative 'nodes'
|
|
4
3
|
require_relative 'projects'
|
|
5
|
-
require_relative 'tui/navigator'
|
|
4
|
+
require_relative 'tui/navigator/navigator'
|
|
6
5
|
|
|
7
6
|
module Raindown
|
|
8
7
|
# The terminal UI, started with "raindown tui": a project's Markdown files beside the open file's outline,
|
|
@@ -13,15 +12,21 @@ module Raindown
|
|
|
13
12
|
|
|
14
13
|
# PATH is a file to open, or a directory. Its project is the one it's in, or else the directory itself.
|
|
15
14
|
def start(path)
|
|
16
|
-
require_relative 'tui/screen'
|
|
15
|
+
require_relative 'tui/terminal/screen'
|
|
17
16
|
# Without a terminal, curses' own error is "Error opening terminal: unknown".
|
|
18
17
|
abort('raindown tui needs a terminal') unless $stdin.tty? && $stdout.tty?
|
|
19
18
|
|
|
20
19
|
path = File.expand_path(path)
|
|
21
20
|
root = Projects.root(path) || (File.directory?(path) ? path : File.dirname(path))
|
|
22
|
-
|
|
21
|
+
# The projects inside it are shown too. Each loads its own nodes in its worker's process, like when watching,
|
|
22
|
+
# so two projects' nodes of the same name stay apart.
|
|
23
|
+
workers = Workers.new(root:)
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
# Rouge loads here, rather than with Raindown, which doesn't highlight code outside the TUI.
|
|
26
|
+
require 'rouge'
|
|
27
|
+
Screen.new(Navigator.new(root:, path: (path unless File.directory?(path)), workers:)).run
|
|
28
|
+
ensure
|
|
29
|
+
workers&.stop
|
|
25
30
|
end
|
|
26
31
|
end
|
|
27
32
|
end
|
data/lib/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: raindown
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- maedi
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-10-
|
|
10
|
+
date: 2026-10-28 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: antlers
|
|
@@ -135,6 +135,20 @@ dependencies:
|
|
|
135
135
|
- - ">="
|
|
136
136
|
- !ruby/object:Gem::Version
|
|
137
137
|
version: '0'
|
|
138
|
+
- !ruby/object:Gem::Dependency
|
|
139
|
+
name: rouge
|
|
140
|
+
requirement: !ruby/object:Gem::Requirement
|
|
141
|
+
requirements:
|
|
142
|
+
- - "~>"
|
|
143
|
+
- !ruby/object:Gem::Version
|
|
144
|
+
version: '5.1'
|
|
145
|
+
type: :runtime
|
|
146
|
+
prerelease: false
|
|
147
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
148
|
+
requirements:
|
|
149
|
+
- - "~>"
|
|
150
|
+
- !ruby/object:Gem::Version
|
|
151
|
+
version: '5.1'
|
|
138
152
|
- !ruby/object:Gem::Dependency
|
|
139
153
|
name: trees
|
|
140
154
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -212,20 +226,60 @@ files:
|
|
|
212
226
|
- lib/raindown/project_worker.rb
|
|
213
227
|
- lib/raindown/projects.rb
|
|
214
228
|
- lib/raindown/rewriter.rb
|
|
229
|
+
- lib/raindown/stat.rb
|
|
215
230
|
- lib/raindown/supervisor.rb
|
|
216
231
|
- lib/raindown/table_formatter.rb
|
|
217
232
|
- lib/raindown/toc.rb
|
|
218
233
|
- lib/raindown/tui.rb
|
|
219
|
-
- lib/raindown/tui/
|
|
234
|
+
- lib/raindown/tui/card.rb
|
|
235
|
+
- lib/raindown/tui/endpoint.rb
|
|
236
|
+
- lib/raindown/tui/endpoints.rb
|
|
237
|
+
- lib/raindown/tui/events.rb
|
|
238
|
+
- lib/raindown/tui/feelings/batch.rb
|
|
239
|
+
- lib/raindown/tui/feelings/bins.rb
|
|
240
|
+
- lib/raindown/tui/feelings/celebration.rb
|
|
241
|
+
- lib/raindown/tui/feelings/code_box.rb
|
|
242
|
+
- lib/raindown/tui/feelings/code_methods.rb
|
|
243
|
+
- lib/raindown/tui/feelings/feelings_log.rb
|
|
244
|
+
- lib/raindown/tui/feelings/feelings_renderer.rb
|
|
245
|
+
- lib/raindown/tui/feelings/feelings_view.rb
|
|
246
|
+
- lib/raindown/tui/feelings/fireworks.rb
|
|
247
|
+
- lib/raindown/tui/feelings/rain.rb
|
|
248
|
+
- lib/raindown/tui/feelings/scene.rb
|
|
249
|
+
- lib/raindown/tui/feelings/waffle.rb
|
|
250
|
+
- lib/raindown/tui/feelings/weekly_batch.rb
|
|
220
251
|
- lib/raindown/tui/file_tree.rb
|
|
221
|
-
- lib/raindown/tui/
|
|
252
|
+
- lib/raindown/tui/highlighter.rb
|
|
222
253
|
- lib/raindown/tui/layout.rb
|
|
223
|
-
- lib/raindown/tui/
|
|
224
|
-
- lib/raindown/tui/
|
|
225
|
-
- lib/raindown/tui/
|
|
254
|
+
- lib/raindown/tui/menu_overlay.rb
|
|
255
|
+
- lib/raindown/tui/navigator/commands.rb
|
|
256
|
+
- lib/raindown/tui/navigator/focus.rb
|
|
257
|
+
- lib/raindown/tui/navigator/navigator.rb
|
|
258
|
+
- lib/raindown/tui/navigator/project_menu.rb
|
|
259
|
+
- lib/raindown/tui/navigator/search.rb
|
|
260
|
+
- lib/raindown/tui/navigator/views.rb
|
|
261
|
+
- lib/raindown/tui/renderers/dashboard_renderer.rb
|
|
262
|
+
- lib/raindown/tui/renderers/endpoint_renderer.rb
|
|
263
|
+
- lib/raindown/tui/renderers/list_renderer.rb
|
|
264
|
+
- lib/raindown/tui/renderers/outline_renderer.rb
|
|
265
|
+
- lib/raindown/tui/renderers/renderer.rb
|
|
226
266
|
- lib/raindown/tui/row.rb
|
|
227
|
-
- lib/raindown/tui/
|
|
228
|
-
- lib/raindown/tui/
|
|
267
|
+
- lib/raindown/tui/rows.rb
|
|
268
|
+
- lib/raindown/tui/search_overlay.rb
|
|
269
|
+
- lib/raindown/tui/terminal/input.rb
|
|
270
|
+
- lib/raindown/tui/terminal/ncurses_shim.rb
|
|
271
|
+
- lib/raindown/tui/terminal/quiet.rb
|
|
272
|
+
- lib/raindown/tui/terminal/screen.rb
|
|
273
|
+
- lib/raindown/tui/terminal/styles.rb
|
|
274
|
+
- lib/raindown/tui/text.rb
|
|
275
|
+
- lib/raindown/tui/title.rb
|
|
276
|
+
- lib/raindown/tui/views/dashboard_view.rb
|
|
277
|
+
- lib/raindown/tui/views/endpoint_view.rb
|
|
278
|
+
- lib/raindown/tui/views/list_view.rb
|
|
279
|
+
- lib/raindown/tui/views/outline_view.rb
|
|
280
|
+
- lib/raindown/tui/workers/project.rb
|
|
281
|
+
- lib/raindown/tui/workers/worker.rb
|
|
282
|
+
- lib/raindown/tui/workers/workers.rb
|
|
229
283
|
- lib/raindown/variables.rb
|
|
230
284
|
- lib/raindown/watcher.rb
|
|
231
285
|
- lib/version.rb
|