raindown 0.5.0 → 0.6.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/actions.rb +1 -1
- data/lib/raindown/bare_directives.rb +14 -3
- data/lib/raindown/cli.rb +2 -2
- data/lib/raindown/document.rb +18 -7
- data/lib/raindown/endpoint/curl.rb +15 -0
- data/lib/raindown/endpoint/endpoint_runner.rb +1 -1
- data/lib/raindown/node_injector.rb +1 -1
- data/lib/raindown/projects.rb +8 -4
- 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 +35 -0
- data/lib/raindown/tui/file_tree.rb +53 -11
- data/lib/raindown/tui/layout.rb +60 -78
- data/lib/raindown/tui/menu_overlay.rb +59 -0
- data/lib/raindown/tui/navigator/commands.rb +84 -0
- data/lib/raindown/tui/navigator/focus.rb +25 -0
- data/lib/raindown/tui/navigator/navigator.rb +127 -0
- data/lib/raindown/tui/navigator/project_menu.rb +61 -0
- data/lib/raindown/tui/navigator/views.rb +49 -0
- data/lib/raindown/tui/renderers/dashboard_renderer.rb +102 -0
- data/lib/raindown/tui/renderers/endpoint_renderer.rb +77 -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 +15 -0
- data/lib/raindown/tui/row.rb +10 -3
- data/lib/raindown/tui/rows.rb +57 -0
- data/lib/raindown/tui/{input.rb → terminal/input.rb} +10 -11
- 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} +32 -17
- data/lib/raindown/tui/terminal/styles.rb +96 -0
- data/lib/raindown/tui/text.rb +83 -0
- data/lib/raindown/tui/title.rb +29 -0
- data/lib/raindown/tui/views/dashboard_view.rb +145 -0
- data/lib/raindown/tui/views/endpoint_view.rb +75 -0
- data/lib/raindown/tui/{pane.rb → views/list_view.rb} +9 -8
- data/lib/raindown/tui/{outline.rb → views/outline_view.rb} +39 -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 +8 -5
- data/lib/raindown.rb +2 -0
- data/lib/version.rb +1 -1
- metadata +32 -9
- data/lib/raindown/tui/commands.rb +0 -70
- data/lib/raindown/tui/navigator.rb +0 -123
- data/lib/raindown/tui/styles.rb +0 -36
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'unicode/display_width'
|
|
4
|
+
|
|
5
|
+
module Raindown
|
|
6
|
+
module TUI
|
|
7
|
+
# Text measured in a terminal's columns, where a wide character like 界 takes two, cut to fit them in styled Segments.
|
|
8
|
+
module Text
|
|
9
|
+
Segment = Data.define(:text, :style, :event)
|
|
10
|
+
INDENT = ' '
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
# A path from the home folder when it's in it, like ~/dev/raindown.
|
|
15
|
+
def from_home(path)
|
|
16
|
+
home = Dir.home
|
|
17
|
+
path == home || path.start_with?(File.join(home, '')) ? path.sub(home, '~') : path
|
|
18
|
+
rescue ArgumentError
|
|
19
|
+
path
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The text fit to the width, as a Segment in the style.
|
|
23
|
+
def segment(text, style, width: self.width, event: nil)
|
|
24
|
+
Segment.new(text: fit(printable(text), width), style:, event:)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Text cut or padded to the width, with a cut shown by an ellipsis.
|
|
28
|
+
def fit(text, width)
|
|
29
|
+
return '' if width <= 0
|
|
30
|
+
return text + (' ' * (width - display_width(text))) if display_width(text) <= width
|
|
31
|
+
|
|
32
|
+
cut = +''
|
|
33
|
+
cut_width = 0
|
|
34
|
+
text.each_char do |char|
|
|
35
|
+
break if cut_width + display_width(char) > width - 1
|
|
36
|
+
|
|
37
|
+
cut << char
|
|
38
|
+
cut_width += display_width(char)
|
|
39
|
+
end
|
|
40
|
+
"#{cut}…#{' ' * (width - 1 - cut_width)}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The widths of texts side by side in a width: each as wide as it is, and the last as wide as what's left,
|
|
44
|
+
# with none past the width.
|
|
45
|
+
def spread(texts, width)
|
|
46
|
+
texts.each_with_index.map do |text, index|
|
|
47
|
+
part = index == texts.length - 1 ? width : [display_width(text), width].min
|
|
48
|
+
width -= part
|
|
49
|
+
part
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The line's segments, with others drawn over its columns from the column on, like a box over the screen.
|
|
54
|
+
def overlay_segments(line, segments, column:)
|
|
55
|
+
width = segments.sum { display_width(it.text) }
|
|
56
|
+
[*segments_between(line, 0, column), *segments, *segments_between(line, column + width, Float::INFINITY)]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The parts of the line's segments between two columns, where a wide character cut in two leaves spaces.
|
|
60
|
+
def segments_between(line, from, to)
|
|
61
|
+
column = 0
|
|
62
|
+
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?
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Tabs and control characters, like in a directive's output, would move the terminal's cursor.
|
|
74
|
+
def printable(text)
|
|
75
|
+
text.gsub("\t", INDENT).gsub(/[[:cntrl:]]/, '')
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def display_width(text)
|
|
79
|
+
Unicode::DisplayWidth.of(text)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Raindown
|
|
4
|
+
module TUI
|
|
5
|
+
# A Layout's title line: Raindown's name, the folder the files are shown from, and the page.
|
|
6
|
+
module Title
|
|
7
|
+
# The rain cloud without the emoji variation selector, like the nav's projects, which terminals draw one column wide,
|
|
8
|
+
# as it's measured, so the title line reaches the edge.
|
|
9
|
+
NAME = '🌧 Raindown'
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
# The name, the folder, which opens the project menu when there's more than one project, and the page.
|
|
14
|
+
def title
|
|
15
|
+
parts = [[name, nil], ["#{location}#{' ▾' if navigator.projects?}", (:projects if navigator.projects?)],
|
|
16
|
+
[" › #{view_renderer.page}", nil]]
|
|
17
|
+
|
|
18
|
+
parts.zip(spread(parts.map(&:first), width)).map do |(text, event), part_width|
|
|
19
|
+
segment(text, :title, width: part_width, event:)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def name = " #{NAME} "
|
|
24
|
+
|
|
25
|
+
# The folder the files are shown from.
|
|
26
|
+
def location = from_home(navigator.base)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../events'
|
|
4
|
+
|
|
5
|
+
module Raindown
|
|
6
|
+
module TUI
|
|
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.
|
|
11
|
+
# A key's next step, for the Navigator, is nil, or an Open or Edit of a chart's directive.
|
|
12
|
+
class DashboardView
|
|
13
|
+
# Seconds between drawing the charts again.
|
|
14
|
+
REDRAW = 2
|
|
15
|
+
MOVES = %i[up down left right].freeze
|
|
16
|
+
# Columns between the cards, and the narrowest a card is, with its border.
|
|
17
|
+
GAP = 2
|
|
18
|
+
MIN_CARD_WIDTH = 28
|
|
19
|
+
|
|
20
|
+
attr_reader :root, :errors, :cursor, :project
|
|
21
|
+
|
|
22
|
+
def initialize(root:, workers:)
|
|
23
|
+
@root = root
|
|
24
|
+
@workers = workers
|
|
25
|
+
@cursor = 0
|
|
26
|
+
@offset = 0
|
|
27
|
+
@rows = []
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def renderer = DashboardRenderer
|
|
31
|
+
def target = self
|
|
32
|
+
def path = nil
|
|
33
|
+
def changed? = false
|
|
34
|
+
|
|
35
|
+
def status(_navigator)
|
|
36
|
+
found = "#{charts.length} #{charts.length == 1 ? 'chart' : 'charts'} in #{File.basename(project || root)}"
|
|
37
|
+
errors.empty? ? [:info, found] : [:error, "#{found}, but couldn't read #{errors.join(', ')}"]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Finds the charts again, like when it's opened, or a file is saved or added.
|
|
41
|
+
def load
|
|
42
|
+
draw(find: true)
|
|
43
|
+
@cursor = @cursor.clamp(0, [charts.length - 1, 0].max)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The charts, as each project's Worker drew them, of the project they're narrowed to, if any.
|
|
47
|
+
def charts
|
|
48
|
+
project ? @cards.select { it.project == project } : @cards
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Narrows the charts to a project's, or with nil, shows every project's.
|
|
52
|
+
def project=(project)
|
|
53
|
+
@project = project
|
|
54
|
+
@cursor = 0
|
|
55
|
+
@offset = 0
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def cards
|
|
59
|
+
draw if Process.clock_gettime(Process::CLOCK_MONOTONIC) - @drawn_at >= REDRAW
|
|
60
|
+
charts
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The charts' indexes in rows, as many of a project's as fit across the width after a column of margin, with each
|
|
64
|
+
# project's charts starting a row. They're kept, so up and down move a row.
|
|
65
|
+
def rows(width)
|
|
66
|
+
@rows = charts.each_index.chunk_while { |a, b| charts[a].project == charts[b].project }.flat_map { group_rows(it, width) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The widths a card can be: no narrower than MIN_CARD_WIDTH, when there's room, and no wider than the width.
|
|
70
|
+
def card_widths(width) = [MIN_CARD_WIDTH, width - 1].min..(width - 1)
|
|
71
|
+
|
|
72
|
+
def selected = charts[@cursor]
|
|
73
|
+
|
|
74
|
+
def first_in_row?
|
|
75
|
+
@rows.empty? || @rows.any? { it.first == @cursor }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Every key but Tab to the files, or left from the first card in a row.
|
|
79
|
+
def next_step?(key) = !%i[tab tick].include?(key) && !(key == :left && first_in_row?)
|
|
80
|
+
|
|
81
|
+
def next_step(key)
|
|
82
|
+
chart = selected
|
|
83
|
+
case key
|
|
84
|
+
when *MOVES then move(key).then { nil }
|
|
85
|
+
when :enter then chart && Open.new(path: chart.path, line: chart.line)
|
|
86
|
+
when :edit then chart && Edit.new(path: chart.path, line: chart.line)
|
|
87
|
+
when :reload then load.then { nil }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def cursor=(index)
|
|
92
|
+
@cursor = index.clamp(0, [charts.length - 1, 0].max)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The first row of cards to show, given each row's height, scrolled so the cursor's row is shown.
|
|
96
|
+
def scroll(row_heights, height)
|
|
97
|
+
row = row_of(@cursor)
|
|
98
|
+
@offset = row if row < @offset
|
|
99
|
+
@offset += 1 while @offset < row && row_heights[@offset..row].sum > height
|
|
100
|
+
@offset = @offset.clamp(0, [row_heights.length - 1, 0].max)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def draw(find: false)
|
|
106
|
+
@cards = []
|
|
107
|
+
@errors = []
|
|
108
|
+
@workers.each do |worker|
|
|
109
|
+
answer = worker.call(:charts, find:)
|
|
110
|
+
@cards.concat(answer[:cards])
|
|
111
|
+
@errors.concat(answer[:errors])
|
|
112
|
+
rescue Worker::Error => e
|
|
113
|
+
@errors << "#{File.basename(worker.root)}: #{e.message}"
|
|
114
|
+
end
|
|
115
|
+
@cards.sort_by! { [it.project, it.path, it.line] }
|
|
116
|
+
@drawn_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def group_rows(indexes, width)
|
|
120
|
+
indexes.each_with_object([[]]) do |index, rows|
|
|
121
|
+
rows << [] if rows.last.any? && row_width(rows.last + [index], width) > width
|
|
122
|
+
rows.last << index
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# A row of cards' width, after a column of margin, with a gap between each.
|
|
127
|
+
def row_width(row, width)
|
|
128
|
+
1 + row.sum { charts[it].box_width(within: card_widths(width)) } + (GAP * (row.length - 1))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Left and right move along the cards, and up and down to the card in the same place in the row above or below.
|
|
132
|
+
def move(name)
|
|
133
|
+
return self.cursor = @cursor + (name == :left ? -1 : 1) if %i[left right].include?(name)
|
|
134
|
+
|
|
135
|
+
row = row_of(@cursor)
|
|
136
|
+
target = @rows[(row + (name == :up ? -1 : 1)).clamp(0, [@rows.length - 1, 0].max)]
|
|
137
|
+
self.cursor = target ? target[[@rows[row].index(@cursor), target.length - 1].min] : @cursor
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def row_of(index)
|
|
141
|
+
@rows.index { it.include?(index) } || 0
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
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 next_step?(key) = %i[up down enter edit execute].include?(key)
|
|
30
|
+
|
|
31
|
+
# Clicks on the response only move the focus to it.
|
|
32
|
+
def cursor=(_index); end
|
|
33
|
+
|
|
34
|
+
# Reads the response again, like after the request's run.
|
|
35
|
+
def load
|
|
36
|
+
lines = output_lines
|
|
37
|
+
@fetched = lines.pop[FETCHED, 1] if lines.last&.match?(FETCHED)
|
|
38
|
+
@language = lines.first.to_s[FENCE, 1]
|
|
39
|
+
@response = @language ? lines[1...-1] : lines
|
|
40
|
+
@error = nil
|
|
41
|
+
rescue Worker::Error => e
|
|
42
|
+
@response = []
|
|
43
|
+
@error = e.message
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def next_step(key)
|
|
47
|
+
case key
|
|
48
|
+
when :up, :down then scroll(key == :up ? -1 : 1)
|
|
49
|
+
when :enter then Open.new(path: endpoint.path, line: endpoint.line)
|
|
50
|
+
when :edit then Edit.new(path: endpoint.path, line: endpoint.line)
|
|
51
|
+
when :execute then Execute.new(path: endpoint.path, line: endpoint.line, name: endpoint.text)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The response's lines that fit in the height, from where it's scrolled to.
|
|
56
|
+
def visible(height)
|
|
57
|
+
@offset = @offset.clamp(0, [response.length - height, 0].max)
|
|
58
|
+
response[@offset, height] || []
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def output_lines
|
|
64
|
+
outline = @workers.for(path).call(:outline, path)
|
|
65
|
+
directive = outline.directives.find { it.endpoint? && it.line == endpoint.line }
|
|
66
|
+
directive&.output ? outline.lines[(directive.output.begin - 1)...directive.output.end] : []
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def scroll(by)
|
|
70
|
+
@offset += by
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
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,8 @@ module Raindown
|
|
|
12
12
|
@offset = 0
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
+
def renderer = ListRenderer
|
|
16
|
+
|
|
15
17
|
def rows = @rows.call
|
|
16
18
|
def selected = rows[cursor]
|
|
17
19
|
|
|
@@ -20,13 +22,12 @@ module Raindown
|
|
|
20
22
|
@cursor.clamp(0, [rows.length - 1, 0].max)
|
|
21
23
|
end
|
|
22
24
|
|
|
25
|
+
# Moves past rows that can't be selected, like separators, and stays at the first or last row rather than moving off them.
|
|
23
26
|
def move(by)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
@cursor = 0
|
|
29
|
-
@offset = 0
|
|
27
|
+
rows = self.rows
|
|
28
|
+
index = cursor + by
|
|
29
|
+
index += by while index.between?(0, rows.length - 1) && !rows[index].selectable?
|
|
30
|
+
@cursor = index if index.between?(0, rows.length - 1)
|
|
30
31
|
end
|
|
31
32
|
|
|
32
33
|
# 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,41 @@
|
|
|
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 '../row'
|
|
7
|
+
require_relative '../workers/worker'
|
|
5
8
|
|
|
6
9
|
module Raindown
|
|
7
10
|
module TUI
|
|
8
|
-
# The outline
|
|
9
|
-
# and a directive's
|
|
10
|
-
|
|
11
|
+
# The outline: a file's headings and directives, read by its project's Worker. Headings fold the section below them,
|
|
12
|
+
# and a directive's body expands below it, like the lines a node wrote back or an endpoint's request and response.
|
|
13
|
+
# Both start folded away. It's read when it's opened, and it's a ListView, with its own cursor.
|
|
14
|
+
class OutlineView < ListView
|
|
11
15
|
attr_reader :path, :error
|
|
12
16
|
|
|
13
|
-
def initialize(path)
|
|
17
|
+
def initialize(path, workers:)
|
|
18
|
+
super()
|
|
14
19
|
@path = path
|
|
20
|
+
@workers = workers
|
|
15
21
|
@folded = Set.new
|
|
16
22
|
@expanded = Set.new
|
|
17
|
-
read
|
|
18
23
|
end
|
|
19
24
|
|
|
25
|
+
def renderer = OutlineRenderer
|
|
26
|
+
def target = path
|
|
27
|
+
def next_step?(_key) = false
|
|
28
|
+
def status(navigator) = [:info, "Opened #{navigator.relative(path)}"]
|
|
29
|
+
|
|
20
30
|
# Reads the file again, keeping what's folded.
|
|
21
|
-
def
|
|
31
|
+
def load
|
|
22
32
|
@mtime = File.mtime(path)
|
|
23
|
-
|
|
24
|
-
@lines =
|
|
25
|
-
@
|
|
33
|
+
outline = @workers.for(path).call(:outline, path)
|
|
34
|
+
@lines = outline.lines
|
|
35
|
+
@headings = outline.headings
|
|
36
|
+
@directives = outline.directives
|
|
26
37
|
@error = nil
|
|
27
|
-
rescue
|
|
38
|
+
rescue Worker::Error, SystemCallError => e
|
|
28
39
|
@error = e.message
|
|
29
40
|
end
|
|
30
41
|
|
|
@@ -72,34 +83,42 @@ module Raindown
|
|
|
72
83
|
|
|
73
84
|
# Headings a directive wrote are part of its output, so they show when it's expanded rather than on their own.
|
|
74
85
|
def items
|
|
75
|
-
outputs = @
|
|
76
|
-
@
|
|
86
|
+
outputs = @directives.filter_map(&:output)
|
|
87
|
+
(@headings + @directives).sort_by(&:line)
|
|
88
|
+
.reject { |item| item.is_a?(Document::Heading) && outputs.any? { it.cover?(item.line) } }
|
|
77
89
|
end
|
|
78
90
|
|
|
79
91
|
def min_level
|
|
80
|
-
@
|
|
92
|
+
@headings.map(&:level).min || 1
|
|
81
93
|
end
|
|
82
94
|
|
|
83
95
|
def heading_row(heading)
|
|
84
96
|
folded = @folded.include?(heading.line) ? ' …' : ''
|
|
85
97
|
Row.new(text: "#{'#' * heading.level} #{heading.text}#{folded}", depth: heading.level - min_level, kind: :heading,
|
|
86
|
-
target: heading)
|
|
98
|
+
target: heading, fold: -> { fold(heading) })
|
|
87
99
|
end
|
|
88
100
|
|
|
89
101
|
def directive_rows(directive, depth:)
|
|
90
102
|
expanded = @expanded.include?(directive.line)
|
|
91
103
|
row = Row.new(text: directive_text(directive), depth:, kind: :directive, target: directive,
|
|
92
|
-
buttons: buttons(directive, expanded:))
|
|
104
|
+
buttons: buttons(directive, expanded:), **directive_action(directive))
|
|
93
105
|
return [row] unless expanded
|
|
94
106
|
|
|
95
|
-
[row, *directive.
|
|
96
|
-
Row.new(text: @lines[number - 1].to_s, depth: depth + 1, kind: :
|
|
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))
|
|
97
109
|
end]
|
|
98
110
|
end
|
|
99
111
|
|
|
112
|
+
# A directive with a body expands it, and one without opens in the editor, like a line of its body.
|
|
113
|
+
def directive_action(directive)
|
|
114
|
+
if directive.body then { fold: -> { expand(directive) } }
|
|
115
|
+
else { open: Edit.new(path:, line: directive.line) }
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
100
119
|
def buttons(directive, expanded:)
|
|
101
120
|
buttons = []
|
|
102
|
-
buttons << [expanded ? '[-]' : "[+#{directive.
|
|
121
|
+
buttons << [expanded ? '[-]' : "[+#{directive.body.size}]", :toggle] if directive.body
|
|
103
122
|
buttons << ['[Run]', :execute] if directive.endpoint?
|
|
104
123
|
buttons
|
|
105
124
|
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 '../card'
|
|
13
|
+
require_relative '../endpoint'
|
|
14
|
+
require_relative '../../code_blocks'
|
|
15
|
+
require_relative '../../endpoint/curl'
|
|
16
|
+
|
|
17
|
+
module Raindown
|
|
18
|
+
module TUI
|
|
19
|
+
# What the terminal UI asks of a project, answered in the project's Worker with its own nodes loaded,
|
|
20
|
+
# so two projects' nodes of the same name stay apart. Answers are plain data, which a Worker sends back through a pipe.
|
|
21
|
+
class Project
|
|
22
|
+
Outline = Data.define(:lines, :headings, :directives)
|
|
23
|
+
|
|
24
|
+
attr_reader :root
|
|
25
|
+
|
|
26
|
+
# The projects inside this one have workers of their own, so their files are left out.
|
|
27
|
+
def initialize(root:)
|
|
28
|
+
@root = root
|
|
29
|
+
@load_errors = []
|
|
30
|
+
@file_errors = []
|
|
31
|
+
Nodes.load(File.join(root, Nodes::FOLDER))
|
|
32
|
+
rescue StandardError, ScriptError => e
|
|
33
|
+
@load_errors << "#{File.basename(root)}/#{Nodes::FOLDER}: #{e.message}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# A file's lines, headings and directives.
|
|
37
|
+
def outline(path)
|
|
38
|
+
document = Document.read(path)
|
|
39
|
+
Outline.new(lines: File.readlines(path, chomp: true), headings: document.headings, directives: document.directives)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Every chart in the project's files, drawn from its data, and the files that couldn't be read.
|
|
43
|
+
# The charts are found again when asked, and otherwise drawn again, so they follow changes to their data.
|
|
44
|
+
def charts(find: false)
|
|
45
|
+
if find || @charts.nil?
|
|
46
|
+
@file_errors = []
|
|
47
|
+
@charts = Projects.files(root).flat_map { |path| charts_in(path) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
{ cards: drawn, errors: @load_errors + @file_errors }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Every endpoint in the project's files, by its request, leaving out the ones whose snippet can't be read.
|
|
54
|
+
def endpoints
|
|
55
|
+
Projects.files(root).flat_map do |path|
|
|
56
|
+
lines = File.readlines(path, encoding: Encoding::UTF_8)
|
|
57
|
+
Document.read(path).directives.select(&:endpoint?).filter_map { |directive| endpoint(path, lines, directive) }
|
|
58
|
+
rescue StandardError
|
|
59
|
+
[]
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def write_back(path)
|
|
64
|
+
Actions.write_back(path)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Runs the endpoint on the line, replacing its response.
|
|
68
|
+
def execute(path, line)
|
|
69
|
+
directive = Document.read(path).directives.find { it.line == line && it.endpoint? }
|
|
70
|
+
raise Actions::NotAnEndpoint, "There's no endpoint on line #{line}" unless directive
|
|
71
|
+
|
|
72
|
+
Actions.execute(path, directive)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def endpoint(path, lines, directive)
|
|
78
|
+
snippet = CodeBlocks.endpoint(lines, directive.line).first&.dig(:body)
|
|
79
|
+
request = Curl.request(snippet.to_s)
|
|
80
|
+
Endpoint.new(project: root, path:, line: directive.line, **request.to_h) if request
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def charts_in(path)
|
|
84
|
+
content = File.read(path, encoding: Encoding::UTF_8)
|
|
85
|
+
bare = BareDirectives.enabled?(path:, content:)
|
|
86
|
+
lines = content.lines
|
|
87
|
+
headings = Document.parse(content, bare:).headings
|
|
88
|
+
|
|
89
|
+
NodeBlocks.find(lines, front_matter: FrontMatter.parse(content).first, bare:)
|
|
90
|
+
.select { |block| block[:node].is_a?(Raindown::Chart) }
|
|
91
|
+
.map { |block| chart(block, path:, lines:, headings:) }
|
|
92
|
+
rescue StandardError => e
|
|
93
|
+
@file_errors << "#{path.delete_prefix(File.join(root, ''))}: #{e.message}"
|
|
94
|
+
[]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def drawn
|
|
98
|
+
pages = {}
|
|
99
|
+
@charts.map do |chart|
|
|
100
|
+
lines = lines(chart, pages[File.dirname(chart[:path])] ||= Pages.for(chart[:path]))
|
|
101
|
+
Card.new(project: root, chart: lines, **chart.except(:node))
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def chart(block, path:, lines:, headings:)
|
|
106
|
+
line = block[:open] + 1
|
|
107
|
+
directive = lines[block[:open]].strip.delete_prefix('<{').delete_suffix('}>').strip
|
|
108
|
+
|
|
109
|
+
{ path:, line:, heading: headings.select { it.line < line }.last&.text, directive:, node: block[:node] }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Draws the chart as its Markdown would be written back, with the files its project lists.
|
|
113
|
+
def lines(chart, metadata)
|
|
114
|
+
node = chart[:node]
|
|
115
|
+
points = node.points(metadata:)
|
|
116
|
+
points.empty? ? ['No data'] : node.draw(points).map(&:rstrip)
|
|
117
|
+
rescue StandardError => e
|
|
118
|
+
["Can't chart: #{e.message}"]
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|