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
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Raindown
|
|
4
|
+
module TUI
|
|
5
|
+
# What a Navigator does with the mouse: a click where it's clicked, the mouse moving over something, which it keeps
|
|
6
|
+
# as what's hovered, and dragging with the button held, which the view takes, like a blueprint's canvas, if it can.
|
|
7
|
+
module Mouse
|
|
8
|
+
# What the mouse is over, which a click there would send, like a Click on a method's cells, or nil.
|
|
9
|
+
attr_reader :hovered
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
# A click moves the cursor where it's clicked, and does the click's action there, like a key, unless that would
|
|
14
|
+
# leave the terminal UI, like for the editor, so a stray click only moves the cursor.
|
|
15
|
+
# While the project menu or Teleport is open, only its items are clicked.
|
|
16
|
+
def click(click)
|
|
17
|
+
return if menu && click.pane != :menu
|
|
18
|
+
return if teleporting? && click.pane != :teleport
|
|
19
|
+
|
|
20
|
+
point(click)
|
|
21
|
+
effect = route(click.action)
|
|
22
|
+
effect unless effect.is_a?(Run)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def point(click)
|
|
26
|
+
return @menu = click.index if click.pane == :menu
|
|
27
|
+
return @match = click.index if click.pane == :teleport
|
|
28
|
+
|
|
29
|
+
content(click.pane).cursor = click.index
|
|
30
|
+
@focus = click.pane
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def hover(hover)
|
|
34
|
+
@hovered = hover.target
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def drag(drag)
|
|
39
|
+
view.drag(drag) if view.respond_to?(:drag)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -4,12 +4,14 @@ require_relative '../file_tree'
|
|
|
4
4
|
require_relative 'commands'
|
|
5
5
|
require_relative '../endpoints'
|
|
6
6
|
require_relative 'focus'
|
|
7
|
+
require_relative 'mouse'
|
|
7
8
|
require_relative '../events'
|
|
8
9
|
require_relative '../views/outline_view'
|
|
9
10
|
require_relative '../views/list_view'
|
|
10
11
|
require_relative 'project_menu'
|
|
12
|
+
require_relative 'teleport'
|
|
11
13
|
require_relative 'views'
|
|
12
|
-
require_relative '../
|
|
14
|
+
require_relative '../project'
|
|
13
15
|
|
|
14
16
|
module Raindown
|
|
15
17
|
module TUI
|
|
@@ -20,20 +22,31 @@ module Raindown
|
|
|
20
22
|
include Commands
|
|
21
23
|
include Views
|
|
22
24
|
include ProjectMenu
|
|
25
|
+
include Teleport
|
|
23
26
|
include Focus
|
|
27
|
+
include Mouse
|
|
24
28
|
|
|
25
29
|
# Keys of the events that move the cursor or the focus, and those that act on what's selected.
|
|
26
30
|
MOVES = { up: -1, down: 1 }.freeze
|
|
27
31
|
PANES = { tab: nil, left: :nav, right: :main }.freeze
|
|
28
32
|
ACTIONS = { execute: :execute, edit: :edit, write_back: :write_back, reload: :reload, tick: :refresh }.freeze
|
|
33
|
+
# Keys that open Teleport, the project menu, the Dashboard, Code feelings or Blueprints, from anywhere.
|
|
34
|
+
OPENERS = { search: -> { open_teleport }, projects: -> { toggle_menu }, dashboard: -> { toggle_dashboard },
|
|
35
|
+
feelings: -> { open_view(feelings) }, blueprints: -> { open_view(blueprints) } }.freeze
|
|
29
36
|
|
|
30
37
|
attr_reader :focus, :status
|
|
31
38
|
|
|
32
|
-
|
|
39
|
+
# Saves tells of the files saved in the folder, which are written back on the next tick, like when it's watched,
|
|
40
|
+
# running endpoints with no response as they are when it runs_endpoints, like with -x.
|
|
41
|
+
def initialize(root:, path: nil, workers: Project.workers(root:), saves: nil, runs_endpoints: false)
|
|
33
42
|
@workers = workers
|
|
43
|
+
@saves = saves
|
|
44
|
+
@runs_endpoints = runs_endpoints
|
|
34
45
|
@files = FileTree.new(root:, workers:)
|
|
35
46
|
@endpoints = Endpoints.new(workers:)
|
|
36
47
|
@dashboard = DashboardView.new(root:, workers:)
|
|
48
|
+
@feelings = FeelingsView.new(root:)
|
|
49
|
+
@blueprints = BlueprintsView.new(root:)
|
|
37
50
|
@nav = ListView.new { nav_rows }
|
|
38
51
|
first = Projects.files(root, nested: true).first
|
|
39
52
|
# The dashboard is the first page, unless a file is asked for, and "d" goes from it to the first file.
|
|
@@ -46,13 +59,18 @@ module Raindown
|
|
|
46
59
|
case event
|
|
47
60
|
when nil then nil
|
|
48
61
|
when Click then click(event)
|
|
62
|
+
when Type then type(event)
|
|
63
|
+
when Hover then hover(event)
|
|
64
|
+
when Drag then drag(event)
|
|
49
65
|
else route(event)
|
|
50
66
|
end
|
|
51
67
|
end
|
|
52
68
|
|
|
53
|
-
# After the editor closes,
|
|
69
|
+
# After the editor closes, a Markdown file is written back, like a save while it's watched, and read again.
|
|
70
|
+
# Other files, like code that was felt, aren't written back, and the view reads them again.
|
|
54
71
|
def ran
|
|
55
|
-
write_back(@edited)
|
|
72
|
+
write_back(@edited) if @files.files.include?(@edited)
|
|
73
|
+
view.load
|
|
56
74
|
@endpoints.load
|
|
57
75
|
end
|
|
58
76
|
|
|
@@ -62,37 +80,39 @@ module Raindown
|
|
|
62
80
|
# The folder the files are shown from: the chosen project's, or the root's.
|
|
63
81
|
def base = @files.base
|
|
64
82
|
|
|
83
|
+
# While Teleport, or the view, like a feeling's note, takes what's typed, rather than keys' events.
|
|
84
|
+
def typing? = teleporting? || view.typing?
|
|
85
|
+
|
|
86
|
+
# How often the screen's drawn, which is up to the view, like faster for Code feelings' rain.
|
|
87
|
+
def pace = view.pace(hovered)
|
|
88
|
+
|
|
65
89
|
# What's in a pane: the nav's ListView on the left, or the view on the right, which keeps its own cursor.
|
|
66
90
|
def content(pane) = pane == :nav ? @nav : view
|
|
67
91
|
|
|
68
92
|
private
|
|
69
93
|
|
|
94
|
+
# A typed character goes to Teleport, or to the view while it's typing.
|
|
95
|
+
def type(event)
|
|
96
|
+
return type_query(query + event.text) if teleporting?
|
|
97
|
+
|
|
98
|
+
view.next_step(event)&.take(self) if view.typing?
|
|
99
|
+
end
|
|
100
|
+
|
|
70
101
|
def route(name)
|
|
71
102
|
return :quit if name == :quit
|
|
103
|
+
return teleport_event(name) if teleporting?
|
|
72
104
|
return menu_event(name) if menu
|
|
73
|
-
return
|
|
74
|
-
return toggle_dashboard if name == :dashboard
|
|
105
|
+
return instance_exec(&OPENERS[name]) if opener?(name)
|
|
75
106
|
return view.next_step(name)&.take(self) if @focus == :main && view.next_step?(name)
|
|
76
107
|
|
|
77
108
|
browser_event(name)
|
|
78
109
|
end
|
|
79
110
|
|
|
80
|
-
#
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def click(click)
|
|
84
|
-
return if menu && click.pane != :menu
|
|
111
|
+
# Whether the key opens something from anywhere, unless the view's focused and keeps the key, like f in Blueprints.
|
|
112
|
+
def opener?(name)
|
|
113
|
+
return false if @focus == :main && view.respond_to?(:own_key?) && view.own_key?(name)
|
|
85
114
|
|
|
86
|
-
|
|
87
|
-
effect = route(click.action)
|
|
88
|
-
effect unless effect.is_a?(Run)
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
def point(click)
|
|
92
|
-
return @menu = click.index if click.pane == :menu
|
|
93
|
-
|
|
94
|
-
content(click.pane).cursor = click.index
|
|
95
|
-
@focus = click.pane
|
|
115
|
+
OPENERS.key?(name)
|
|
96
116
|
end
|
|
97
117
|
|
|
98
118
|
def browser_event(name)
|
|
@@ -103,11 +123,20 @@ module Raindown
|
|
|
103
123
|
send(ACTIONS[name]) if ACTIONS.key?(name)
|
|
104
124
|
end
|
|
105
125
|
|
|
106
|
-
# The Dashboard,
|
|
107
|
-
#
|
|
126
|
+
# The Dashboard, Code feelings and Blueprints, then the files and the endpoints, with a blank line between each section that
|
|
127
|
+
# has rows.
|
|
108
128
|
def nav_rows
|
|
109
|
-
|
|
110
|
-
[
|
|
129
|
+
rows = pages.map { |text, view| Row.new(text:, depth: 0, kind: :page, target: view, open: OpenView.new(view:)) }
|
|
130
|
+
[rows, @files.rows, @endpoints.rows].reject(&:empty?).flat_map { [Row::SEPARATOR, *it] }.drop(1)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The pages above the files, by how they're shown, in the files and Teleport, with emoji every terminal draws two
|
|
134
|
+
# columns wide. Blueprints is listed once the project has some, from Claude Code or its code's comments.
|
|
135
|
+
def pages
|
|
136
|
+
pages = { '📊 Dashboard' => dashboard, '🎭 Feelings' => feelings }
|
|
137
|
+
return pages unless blueprints.any?
|
|
138
|
+
|
|
139
|
+
pages.merge('🌀 Blueprints' => blueprints)
|
|
111
140
|
end
|
|
112
141
|
|
|
113
142
|
def selected
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../events'
|
|
4
|
+
require_relative '../views/endpoint_view'
|
|
5
|
+
require_relative '../../workers/worker'
|
|
6
|
+
|
|
7
|
+
module Raindown
|
|
8
|
+
module TUI
|
|
9
|
+
# Teleport: "/" opens a search of the Dashboard, Code feelings, every file, heading and directive in the files, and every endpoint,
|
|
10
|
+
# matched as it's typed by letters in order, so "stp" finds "## Setup". Up and down move between the matches,
|
|
11
|
+
# Enter or a click opens the one selected, and Esc closes it. Other keys and clicks do nothing while it's open.
|
|
12
|
+
module Teleport
|
|
13
|
+
# The most matches it shows.
|
|
14
|
+
LIMIT = 10
|
|
15
|
+
|
|
16
|
+
# A place to teleport to: its text, where it is, like "guides/setup.md:12", and the step that opens it.
|
|
17
|
+
Place = Data.define(:text, :where, :open) do
|
|
18
|
+
# How well the query matches, or nil when it doesn't: in the text alone, which ranks above a match that needs
|
|
19
|
+
# where it is too, like "setup notes" for "## Setup" in notes.md.
|
|
20
|
+
def score(query)
|
|
21
|
+
in_text = letters_score(text, query)
|
|
22
|
+
return in_text + query.length if in_text
|
|
23
|
+
|
|
24
|
+
letters_score("#{text} #{where}", query)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# How well the query's letters appear in order in the text, ignoring case: higher for runs of them and for
|
|
30
|
+
# the starts of words, or nil when they don't all appear.
|
|
31
|
+
def letters_score(text, query)
|
|
32
|
+
haystack = text.downcase
|
|
33
|
+
last = -1
|
|
34
|
+
score = 0
|
|
35
|
+
query.downcase.each_char do |char|
|
|
36
|
+
index = haystack.index(char, last + 1)
|
|
37
|
+
return nil unless index
|
|
38
|
+
|
|
39
|
+
score += 1
|
|
40
|
+
score += 2 if index == last + 1
|
|
41
|
+
score += 2 if index.zero? || haystack[index - 1].match?(%r{[\s/#:._-]})
|
|
42
|
+
last = index
|
|
43
|
+
end
|
|
44
|
+
score
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# What's typed, while Teleport is open, and the selected match.
|
|
49
|
+
attr_reader :query, :match
|
|
50
|
+
|
|
51
|
+
def teleporting? = !query.nil?
|
|
52
|
+
|
|
53
|
+
# The first match the box lists, as it lists them all.
|
|
54
|
+
def first_match = 0
|
|
55
|
+
|
|
56
|
+
# The places that match what's typed, best first, then in the order they're in the files.
|
|
57
|
+
def matches
|
|
58
|
+
@places.each_with_index.filter_map { |place, index| (score = place.score(query)) && [-score, index, place] }
|
|
59
|
+
.min(LIMIT).map(&:last)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def open_teleport
|
|
65
|
+
@places = places
|
|
66
|
+
@match = 0
|
|
67
|
+
@query = ''
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def teleport_event(name)
|
|
72
|
+
case name
|
|
73
|
+
when :up, :down then move_match(name == :up ? -1 : 1)
|
|
74
|
+
when :enter then teleport
|
|
75
|
+
when :escape then close_teleport
|
|
76
|
+
when :backspace then type_query(query[0...-1])
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def type_query(query)
|
|
81
|
+
@query = query
|
|
82
|
+
@match = 0
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def move_match(by)
|
|
87
|
+
@match = (@match + by).clamp(0, [matches.length - 1, 0].max)
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def teleport
|
|
92
|
+
place = matches[@match]
|
|
93
|
+
close_teleport
|
|
94
|
+
place&.open&.take(self)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def close_teleport
|
|
98
|
+
@query = nil
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def places
|
|
103
|
+
[*pages.map { |text, view| Place.new(text:, where: '', open: OpenView.new(view:)) },
|
|
104
|
+
*@files.files.flat_map { file_places(it) },
|
|
105
|
+
*@endpoints.all.map { Place.new(text: it.text, where: "#{relative(it.path)}:#{it.line}", open: open_endpoint(it)) }]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The file, then its headings and directives, in the order they're in it.
|
|
109
|
+
def file_places(path)
|
|
110
|
+
outline = @workers.for(path).call(:outline, path)
|
|
111
|
+
where = relative(path)
|
|
112
|
+
items = (outline.headings + outline.directives).sort_by(&:line)
|
|
113
|
+
[Place.new(text: where, where: '', open: Open.new(path:, line: nil)),
|
|
114
|
+
*items.map { Place.new(text: item_text(it), where: "#{where}:#{it.line}", open: Open.new(path:, line: it.line)) }]
|
|
115
|
+
rescue Workers::Worker::Error
|
|
116
|
+
[]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def item_text(item)
|
|
120
|
+
return "#{'#' * item.level} #{item.text}" if item.is_a?(Document::Heading)
|
|
121
|
+
|
|
122
|
+
"#{item.name}: #{item.args}".strip
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def open_endpoint(endpoint) = OpenView.new(view: EndpointView.new(endpoint, workers: @workers))
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative '../views/dashboard_view'
|
|
4
|
+
require_relative '../blueprints/blueprints_view'
|
|
4
5
|
require_relative '../views/endpoint_view'
|
|
6
|
+
require_relative '../feelings/feelings_view'
|
|
5
7
|
require_relative '../views/outline_view'
|
|
6
8
|
|
|
7
9
|
module Raindown
|
|
@@ -11,7 +13,7 @@ module Raindown
|
|
|
11
13
|
# A view has the pane that draws it, a next step for the keys it takes while it has the focus, and a target,
|
|
12
14
|
# what's opened it in the files. It loads when it's opened, and again after a change.
|
|
13
15
|
module Views
|
|
14
|
-
attr_reader :view, :dashboard
|
|
16
|
+
attr_reader :view, :dashboard, :feelings, :blueprints
|
|
15
17
|
|
|
16
18
|
# The path of the file the view shows, if any.
|
|
17
19
|
def path = view.path
|
|
@@ -27,12 +29,12 @@ module Raindown
|
|
|
27
29
|
nil
|
|
28
30
|
end
|
|
29
31
|
|
|
30
|
-
# Opens the file in the outline, on the directive at the line.
|
|
32
|
+
# Opens the file in the outline, on the heading or directive at the line.
|
|
31
33
|
def open_at(path, line)
|
|
32
34
|
path = File.expand_path(path)
|
|
33
35
|
@files.reveal(path)
|
|
34
36
|
open_view(OutlineView.new(path, workers: @workers))
|
|
35
|
-
view.cursor = view.rows.index { it.kind
|
|
37
|
+
view.cursor = view.rows.index { %i[heading directive].include?(it.kind) && it.target.line == line } || 0
|
|
36
38
|
nil
|
|
37
39
|
end
|
|
38
40
|
|
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative '
|
|
4
|
-
require_relative '
|
|
5
|
-
require_relative '
|
|
6
|
-
require_relative '
|
|
7
|
-
require_relative '
|
|
8
|
-
require_relative '
|
|
9
|
-
require_relative '
|
|
10
|
-
require_relative '
|
|
11
|
-
require_relative '
|
|
12
|
-
require_relative '../
|
|
13
|
-
require_relative '../
|
|
14
|
-
require_relative '
|
|
15
|
-
require_relative '
|
|
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 '../workers/workers'
|
|
13
|
+
require_relative '../stat'
|
|
14
|
+
require_relative 'card'
|
|
15
|
+
require_relative 'endpoint'
|
|
16
|
+
require_relative '../code_blocks'
|
|
17
|
+
require_relative '../endpoint/curl'
|
|
16
18
|
|
|
17
19
|
module Raindown
|
|
18
20
|
module TUI
|
|
@@ -23,9 +25,17 @@ module Raindown
|
|
|
23
25
|
|
|
24
26
|
attr_reader :root
|
|
25
27
|
|
|
28
|
+
# Workers for the projects in the folder, each answering as a Project, which allows bare directives in any file,
|
|
29
|
+
# and runs endpoints with no response as it writes back, when asked, like with --bare and -x.
|
|
30
|
+
def self.workers(root:, bare: false, execute: false, **)
|
|
31
|
+
Workers.new(root:, **) { new(root: it, bare:, execute:) }
|
|
32
|
+
end
|
|
33
|
+
|
|
26
34
|
# The projects inside this one have workers of their own, so their files are left out.
|
|
27
|
-
def initialize(root:)
|
|
35
|
+
def initialize(root:, bare: false, execute: false)
|
|
28
36
|
@root = root
|
|
37
|
+
@bare = bare
|
|
38
|
+
@execute = execute
|
|
29
39
|
@load_errors = []
|
|
30
40
|
@file_errors = []
|
|
31
41
|
Nodes.load(File.join(root, Nodes::FOLDER))
|
|
@@ -35,11 +45,12 @@ module Raindown
|
|
|
35
45
|
|
|
36
46
|
# A file's lines, headings and directives.
|
|
37
47
|
def outline(path)
|
|
38
|
-
document = Document.read(path)
|
|
48
|
+
document = Document.read(path, bare: @bare)
|
|
39
49
|
Outline.new(lines: File.readlines(path, chomp: true), headings: document.headings, directives: document.directives)
|
|
40
50
|
end
|
|
41
51
|
|
|
42
|
-
# Every chart in the project's files, drawn from its data, and the files
|
|
52
|
+
# Every chart and stat in the project's files, or any node that draws a card, drawn from its data, and the files
|
|
53
|
+
# that couldn't be read.
|
|
43
54
|
# The charts are found again when asked, and otherwise drawn again, so they follow changes to their data.
|
|
44
55
|
def charts(find: false)
|
|
45
56
|
if find || @charts.nil?
|
|
@@ -50,23 +61,25 @@ module Raindown
|
|
|
50
61
|
{ cards: drawn, errors: @load_errors + @file_errors }
|
|
51
62
|
end
|
|
52
63
|
|
|
53
|
-
# Every endpoint in the project's files
|
|
64
|
+
# Every endpoint in the project's files that writes its response back, so it can run, by its request, leaving out
|
|
65
|
+
# the ones whose snippet can't be read.
|
|
54
66
|
def endpoints
|
|
55
67
|
Projects.files(root).flat_map do |path|
|
|
56
68
|
lines = File.readlines(path, encoding: Encoding::UTF_8)
|
|
57
|
-
Document.read(path).directives.select
|
|
69
|
+
runnable = Document.read(path, bare: @bare).directives.select { it.endpoint? && it.writes }
|
|
70
|
+
runnable.filter_map { |directive| endpoint(path, lines, directive) }
|
|
58
71
|
rescue StandardError
|
|
59
72
|
[]
|
|
60
73
|
end
|
|
61
74
|
end
|
|
62
75
|
|
|
63
76
|
def write_back(path)
|
|
64
|
-
Actions.write_back(path)
|
|
77
|
+
Actions.write_back(path, bare: @bare, execute: @execute)
|
|
65
78
|
end
|
|
66
79
|
|
|
67
80
|
# Runs the endpoint on the line, replacing its response.
|
|
68
81
|
def execute(path, line)
|
|
69
|
-
directive = Document.read(path).directives.find { it.line == line && it.endpoint? }
|
|
82
|
+
directive = Document.read(path, bare: @bare).directives.find { it.line == line && it.endpoint? }
|
|
70
83
|
raise Actions::NotAnEndpoint, "There's no endpoint on line #{line}" unless directive
|
|
71
84
|
|
|
72
85
|
Actions.execute(path, directive)
|
|
@@ -82,12 +95,12 @@ module Raindown
|
|
|
82
95
|
|
|
83
96
|
def charts_in(path)
|
|
84
97
|
content = File.read(path, encoding: Encoding::UTF_8)
|
|
85
|
-
bare = BareDirectives.enabled?(path:, content:)
|
|
98
|
+
bare = @bare || BareDirectives.enabled?(path:, content:)
|
|
86
99
|
lines = content.lines
|
|
87
100
|
headings = Document.parse(content, bare:).headings
|
|
88
101
|
|
|
89
102
|
NodeBlocks.find(lines, front_matter: FrontMatter.parse(content).first, bare:)
|
|
90
|
-
.select {
|
|
103
|
+
.select { it[:node].respond_to?(:card_lines) }
|
|
91
104
|
.map { |block| chart(block, path:, lines:, headings:) }
|
|
92
105
|
rescue StandardError => e
|
|
93
106
|
@file_errors << "#{path.delete_prefix(File.join(root, ''))}: #{e.message}"
|
|
@@ -104,16 +117,14 @@ module Raindown
|
|
|
104
117
|
|
|
105
118
|
def chart(block, path:, lines:, headings:)
|
|
106
119
|
line = block[:open] + 1
|
|
107
|
-
directive = lines[block[:open]]
|
|
120
|
+
directive = Directives.text(lines[block[:open]])
|
|
108
121
|
|
|
109
122
|
{ path:, line:, heading: headings.select { it.line < line }.last&.text, directive:, node: block[:node] }
|
|
110
123
|
end
|
|
111
124
|
|
|
112
|
-
# Draws the chart as its Markdown would be written back, with the files its project lists.
|
|
125
|
+
# Draws the chart or stat as its Markdown would be written back, with the files its project lists.
|
|
113
126
|
def lines(chart, metadata)
|
|
114
|
-
|
|
115
|
-
points = node.points(metadata:)
|
|
116
|
-
points.empty? ? ['No data'] : node.draw(points).map(&:rstrip)
|
|
127
|
+
chart[:node].card_lines(metadata:)
|
|
117
128
|
rescue StandardError => e
|
|
118
129
|
["Can't chart: #{e.message}"]
|
|
119
130
|
end
|
|
@@ -5,13 +5,13 @@ require_relative 'text'
|
|
|
5
5
|
module Raindown
|
|
6
6
|
module TUI
|
|
7
7
|
# The project menu, drawn over the screen's lines in a box below the header's path:
|
|
8
|
-
# 🌧
|
|
8
|
+
# 🌧 Raindown ~/dev ▾ › Dashboard
|
|
9
9
|
# ┌──────────────────────┐
|
|
10
10
|
# │ All projects ✓ │
|
|
11
11
|
# │ ~/dev/raindown │
|
|
12
12
|
# └──────────────────────┘
|
|
13
13
|
# Each item is clicked to choose it.
|
|
14
|
-
class
|
|
14
|
+
class ProjectBox
|
|
15
15
|
include Text
|
|
16
16
|
|
|
17
17
|
def initialize(navigator, width:, column:)
|
|
@@ -15,14 +15,14 @@ module Raindown
|
|
|
15
15
|
# ╰─────────────────────────────╯ ╰────────────────────────────────╯
|
|
16
16
|
class DashboardRenderer < Renderer
|
|
17
17
|
BUTTONS = [['Tab', 'Pane', :tab], ['Enter', 'Open', :enter], ['e', 'Edit', :edit], ['r', 'Reload', :reload],
|
|
18
|
-
['q', 'Quit', :quit]].freeze
|
|
18
|
+
['/', 'Search', :search], ['q', 'Quit', :quit]].freeze
|
|
19
19
|
PROJECTS_BUTTON = ['p', 'Projects', :projects].freeze
|
|
20
20
|
|
|
21
21
|
# Room for the project menu's button, when there's more than one project.
|
|
22
22
|
def buttons
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
return BUTTONS unless navigator.projects?
|
|
24
|
+
|
|
25
|
+
BUTTONS.dup.insert(3, PROJECTS_BUTTON)
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
def page = 'Dashboard'
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative '../highlighter'
|
|
3
4
|
require_relative 'renderer'
|
|
4
5
|
require_relative 'outline_renderer'
|
|
5
6
|
|
|
@@ -8,7 +9,7 @@ module Raindown
|
|
|
8
9
|
# An endpoint opened from the files, right of them: its URL in a bar with rounded corners, and a button to run it,
|
|
9
10
|
# then its response:
|
|
10
11
|
# ╭──────────────────────────────────────────╮
|
|
11
|
-
# │ GET https://httpbin.org/uuid
|
|
12
|
+
# │ GET https://httpbin.org/uuid [Run] │
|
|
12
13
|
# ╰──────────────────────────────────────────╯
|
|
13
14
|
#
|
|
14
15
|
# Response · json · fetched 2026-10-26
|
|
@@ -16,7 +17,7 @@ module Raindown
|
|
|
16
17
|
# "uuid": "48024974-f1b1-431a-a0cf-5cac0099a1a5"
|
|
17
18
|
# }
|
|
18
19
|
class EndpointRenderer < Renderer
|
|
19
|
-
RUN = '[
|
|
20
|
+
RUN = '[Run]'
|
|
20
21
|
RUN_WIDTH = RUN.length
|
|
21
22
|
BUTTONS = OutlineRenderer::BUTTONS.reject { it.last == :write_back }.freeze
|
|
22
23
|
|
|
@@ -35,9 +36,12 @@ module Raindown
|
|
|
35
36
|
# A click on the response moves the focus to it, to scroll it.
|
|
36
37
|
def focus = Click.new(pane:, index: 0, action: :focus)
|
|
37
38
|
|
|
39
|
+
# The response's lines that fit, highlighted in the language of its code block, like json.
|
|
38
40
|
def response(view, lines)
|
|
39
|
-
|
|
40
|
-
Array.new([lines, 0].max)
|
|
41
|
+
code = Highlighter.lines(view.visible([lines, 0].max), language: view.language)
|
|
42
|
+
Array.new([lines, 0].max) do |line|
|
|
43
|
+
[segment(' ', :response, width: 1, event: focus), *code_segments(code[line] || [], width - 1, event: focus)]
|
|
44
|
+
end
|
|
41
45
|
end
|
|
42
46
|
|
|
43
47
|
# The bar is a column in from each side of the pane, with the URL and the button inside its border.
|
|
@@ -57,7 +61,7 @@ module Raindown
|
|
|
57
61
|
run_button, segment(' │', :border, width: 2)]
|
|
58
62
|
end
|
|
59
63
|
|
|
60
|
-
def run_button = segment(RUN, :
|
|
64
|
+
def run_button = segment(RUN, :button, width: RUN_WIDTH, event: Click.new(pane:, index: 0, action: :execute))
|
|
61
65
|
|
|
62
66
|
def edge(left, right, inner)
|
|
63
67
|
padded([margin, segment("#{left}#{'─' * (inner + 2)}#{right}", :border, width: inner + 4)])
|
|
@@ -66,11 +70,11 @@ module Raindown
|
|
|
66
70
|
def margin = segment('', :blank, width: 1)
|
|
67
71
|
|
|
68
72
|
def about(view)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
[segment(" #{
|
|
73
|
+
return [segment(" #{view.error}", :error, width:)] if view.error
|
|
74
|
+
return [segment(' No response yet. X runs the request.', :heading, width:)] if view.response.empty?
|
|
75
|
+
|
|
76
|
+
details = ['Response', view.language, ("fetched #{view.fetched}" if view.fetched)].reject { it.to_s.empty? }
|
|
77
|
+
[segment(" #{details.join(' · ')}", :heading, width:)]
|
|
74
78
|
end
|
|
75
79
|
end
|
|
76
80
|
end
|
|
@@ -7,14 +7,14 @@ module Raindown
|
|
|
7
7
|
# The open file's outline, right of the files: a row on each line, with its buttons.
|
|
8
8
|
class OutlineRenderer < ListRenderer
|
|
9
9
|
BUTTONS = [['Tab', 'Pane', :tab], ['Enter', 'Open', :enter], ['x', 'Run', :execute], ['e', 'Edit', :edit],
|
|
10
|
-
['w', 'Write back', :write_back], ['r', 'Reload', :reload], ['q', 'Quit', :quit]].freeze
|
|
10
|
+
['w', 'Write back', :write_back], ['r', 'Reload', :reload], ['/', 'Search', :search], ['q', 'Quit', :quit]].freeze
|
|
11
11
|
|
|
12
12
|
def buttons = BUTTONS
|
|
13
13
|
|
|
14
14
|
def page
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
return 'No Markdown files' unless navigator.path
|
|
16
|
+
|
|
17
|
+
navigator.relative(navigator.path)
|
|
18
18
|
end
|
|
19
19
|
end
|
|
20
20
|
end
|
|
@@ -7,9 +7,13 @@ require_relative '../text'
|
|
|
7
7
|
module Raindown
|
|
8
8
|
module TUI
|
|
9
9
|
# Draws what's in a pane, :nav or :main, as lines of the pane's width and height, which the Layout puts in it.
|
|
10
|
+
# Like the Layout, it's made again for each draw, so it draws what the Navigator and its view hold then.
|
|
10
11
|
Renderer = Struct.new(:navigator, :pane, :width, :height) do
|
|
11
12
|
include Rows
|
|
12
13
|
include Text
|
|
14
|
+
|
|
15
|
+
# Its side of the line above the panes, which is blank unless it has something to say there, like a progress.
|
|
16
|
+
def spacer = [segment('', :blank)]
|
|
13
17
|
end
|
|
14
18
|
end
|
|
15
19
|
end
|
data/lib/raindown/tui/row.rb
CHANGED
|
@@ -5,8 +5,10 @@ module Raindown
|
|
|
5
5
|
# A line of a pane. Target is what it's about: a folder or file path, a Heading or Directive, or a line number.
|
|
6
6
|
# Buttons are [label, event] pairs, like ['[Run]', :execute], and a tag is an Endpoint, whose method is before its text.
|
|
7
7
|
# Open is the Navigator's next step on Enter, like an Open of a file, and fold, a block that folds or expands the row.
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
# Code is the text highlighted, as [text, style] parts, for a line of code, like a directive's body.
|
|
9
|
+
# Each optional field has a default, which is why initialize takes so many.
|
|
10
|
+
Row = Data.define(:text, :depth, :kind, :target, :buttons, :tag, :open, :fold, :code) do
|
|
11
|
+
def initialize(buttons: [], tag: nil, open: nil, fold: nil, code: nil, **) = super # rubocop:disable Metrics/ParameterLists
|
|
10
12
|
|
|
11
13
|
# A separator is space between a pane's sections, which the cursor skips and clicks don't reach.
|
|
12
14
|
def selectable? = kind != :separator
|
data/lib/raindown/tui/rows.rb
CHANGED
|
@@ -29,9 +29,10 @@ module Raindown
|
|
|
29
29
|
parts.zip(spread(parts.map(&:first), width)).map { |(text, style), part| segment(text, style, width: part, event:) }
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
# The row's text, after its tag in the tag's style, unless the row's selected.
|
|
32
|
+
# The row's text, after its tag in the tag's style, or highlighted when it's code, unless the row's selected.
|
|
33
33
|
def row_parts(row, style, selected)
|
|
34
34
|
lead = " #{Text::INDENT * row.depth}"
|
|
35
|
+
return [[lead, style], *row.code] if row.code && !selected
|
|
35
36
|
return [["#{lead}#{row.text}", style]] unless row.tag
|
|
36
37
|
|
|
37
38
|
[["#{lead}#{row.tag.method} ", selected ? style : row.tag.style], [row.text, style]]
|