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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/lib/raindown/actions.rb +1 -1
  3. data/lib/raindown/bare_directives.rb +14 -3
  4. data/lib/raindown/cli.rb +2 -2
  5. data/lib/raindown/document.rb +18 -7
  6. data/lib/raindown/endpoint/curl.rb +15 -0
  7. data/lib/raindown/endpoint/endpoint_runner.rb +1 -1
  8. data/lib/raindown/node_injector.rb +1 -1
  9. data/lib/raindown/projects.rb +8 -4
  10. data/lib/raindown/tui/card.rb +56 -0
  11. data/lib/raindown/tui/endpoint.rb +16 -0
  12. data/lib/raindown/tui/endpoints.rb +40 -0
  13. data/lib/raindown/tui/events.rb +35 -0
  14. data/lib/raindown/tui/file_tree.rb +53 -11
  15. data/lib/raindown/tui/layout.rb +60 -78
  16. data/lib/raindown/tui/menu_overlay.rb +59 -0
  17. data/lib/raindown/tui/navigator/commands.rb +84 -0
  18. data/lib/raindown/tui/navigator/focus.rb +25 -0
  19. data/lib/raindown/tui/navigator/navigator.rb +127 -0
  20. data/lib/raindown/tui/navigator/project_menu.rb +61 -0
  21. data/lib/raindown/tui/navigator/views.rb +49 -0
  22. data/lib/raindown/tui/renderers/dashboard_renderer.rb +102 -0
  23. data/lib/raindown/tui/renderers/endpoint_renderer.rb +77 -0
  24. data/lib/raindown/tui/renderers/list_renderer.rb +15 -0
  25. data/lib/raindown/tui/renderers/outline_renderer.rb +21 -0
  26. data/lib/raindown/tui/renderers/renderer.rb +15 -0
  27. data/lib/raindown/tui/row.rb +10 -3
  28. data/lib/raindown/tui/rows.rb +57 -0
  29. data/lib/raindown/tui/{input.rb → terminal/input.rb} +10 -11
  30. data/lib/raindown/tui/terminal/ncurses_shim.rb +46 -0
  31. data/lib/raindown/tui/terminal/quiet.rb +32 -0
  32. data/lib/raindown/tui/{screen.rb → terminal/screen.rb} +32 -17
  33. data/lib/raindown/tui/terminal/styles.rb +96 -0
  34. data/lib/raindown/tui/text.rb +83 -0
  35. data/lib/raindown/tui/title.rb +29 -0
  36. data/lib/raindown/tui/views/dashboard_view.rb +145 -0
  37. data/lib/raindown/tui/views/endpoint_view.rb +75 -0
  38. data/lib/raindown/tui/{pane.rb → views/list_view.rb} +9 -8
  39. data/lib/raindown/tui/{outline.rb → views/outline_view.rb} +39 -20
  40. data/lib/raindown/tui/workers/project.rb +122 -0
  41. data/lib/raindown/tui/workers/worker.rb +124 -0
  42. data/lib/raindown/tui/workers/workers.rb +53 -0
  43. data/lib/raindown/tui.rb +8 -5
  44. data/lib/raindown.rb +2 -0
  45. data/lib/version.rb +1 -1
  46. metadata +32 -9
  47. data/lib/raindown/tui/commands.rb +0 -70
  48. data/lib/raindown/tui/navigator.rb +0 -123
  49. 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
- # A pane's cursor and how far it's scrolled, over rows that can change between draws, like when a folder folds.
6
- class Pane
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
- @cursor = (cursor + by).clamp(0, [rows.length - 1, 0].max)
25
- end
26
-
27
- def reset
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 '../document'
4
- require_relative 'row'
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 pane: a file's headings and directives, read from its Document. Headings fold the section below them,
9
- # and a directive's output, the lines Raindown wrote back, expands below it. Both start folded away.
10
- class Outline
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 read
31
+ def load
22
32
  @mtime = File.mtime(path)
23
- @document = Document.read(path)
24
- @lines = File.readlines(path, chomp: true)
25
- @document.directives # Finds an error in the file, like a missing end tag, now rather than while drawing.
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 StandardError => e
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 = @document.directives.filter_map(&:output)
76
- @document.outline.reject { |item| item.is_a?(Document::Heading) && outputs.any? { it.cover?(item.line) } }
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
- @document.headings.map(&:level).min || 1
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.output.map do |number|
96
- Row.new(text: @lines[number - 1].to_s, depth: depth + 1, kind: :output, target: number)
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.output.size}]", :toggle] if directive.output
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