raindown 0.8.1 → 0.9.1
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/document.rb +1 -1
- data/lib/raindown/gitignore.rb +50 -0
- data/lib/raindown/js/host.mjs +51 -0
- data/lib/raindown/js/js_nodes.rb +91 -0
- data/lib/raindown/nodes.rb +8 -5
- data/lib/raindown/page.rb +1 -1
- data/lib/raindown/pages.rb +2 -1
- data/lib/raindown/projects.rb +24 -11
- data/lib/raindown/rewriter.rb +2 -1
- data/lib/raindown/tui/blueprints/blueprints_view.rb +33 -7
- data/lib/raindown/tui/blueprints/code_comments.rb +4 -3
- data/lib/raindown/tui/blueprints/filter.rb +6 -5
- data/lib/raindown/tui/blueprints/traced_code.rb +2 -1
- data/lib/raindown/tui/feelings/bins.rb +1 -1
- data/lib/raindown/tui/feelings/code_methods.rb +17 -2
- data/lib/raindown/tui/feelings/feelings_log.rb +31 -3
- data/lib/raindown/tui/feelings/feelings_renderer.rb +3 -6
- data/lib/raindown/tui/feelings/feelings_view.rb +25 -8
- data/lib/raindown/tui/feelings/weekly_batch.rb +5 -0
- data/lib/raindown/tui/file_tree.rb +2 -2
- data/lib/raindown/tui/navigator/navigator.rb +3 -3
- data/lib/raindown/tui/navigator/project_menu.rb +4 -2
- data/lib/raindown/tui/project.rb +2 -2
- data/lib/raindown/tui/renderers/dashboard_renderer.rb +4 -4
- data/lib/raindown/tui/renderers/renderer.rb +20 -0
- data/lib/raindown/tui/terminal/input.rb +11 -4
- data/lib/raindown/tui/terminal/styles.rb +4 -3
- data/lib/raindown/utils.rb +21 -0
- data/lib/raindown/workers/workers.rb +1 -1
- data/lib/raindown.rb +2 -1
- data/lib/version.rb +1 -1
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3288235ed874ddbeae5b31b5ef6707e59864f5cca305bb9bc74fcf74a9c36366
|
|
4
|
+
data.tar.gz: c7bfc9dcdec92857c433d4abacbc80d221bb6029617972d362af43163c235c39
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1cb1aaaf0f325d154393656cad4040aa8a2205464e577ce69f23931bd2f2727d28222aba9a6d3a059bd2f7531e59050f0056b413a1a72cb72fe66e5e785b300a
|
|
7
|
+
data.tar.gz: f89b1e87119328272a1df69a6c2f9c04159f98db03c749774bac1ae533307eff32bc833b111c41ef569456ed9e0f2ec421afc938744b31102e4f6c8c848fcace
|
data/lib/raindown/actions.rb
CHANGED
|
@@ -31,7 +31,7 @@ module Raindown
|
|
|
31
31
|
raise NotAnEndpoint, "#{directive.name} on line #{directive.line} isn't an endpoint" unless directive.endpoint?
|
|
32
32
|
raise NotAnEndpoint, "The endpoint on line #{directive.line} only renders, so put it in comments to run it" unless directive.writes
|
|
33
33
|
|
|
34
|
-
content =
|
|
34
|
+
content = Utils.read_markdown(path)
|
|
35
35
|
rewritten = EndpointRunner.new(content:, bare: BareDirectives.enabled?(path:, content:), only: directive.line - 1).rewrite
|
|
36
36
|
return false if rewritten == content
|
|
37
37
|
|
data/lib/raindown/document.rb
CHANGED
|
@@ -39,7 +39,7 @@ module Raindown
|
|
|
39
39
|
# they're allowed, like with --bare.
|
|
40
40
|
# A project's own nodes are only directives once they're loaded, like with Nodes.load(path), the same as when the file renders.
|
|
41
41
|
def self.read(path, bare: false)
|
|
42
|
-
new(markdown:
|
|
42
|
+
new(markdown: Utils.read_markdown(path), bare: bare || BareDirectives.file?(path))
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
def initialize(markdown:, bare: false)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Raindown
|
|
4
|
+
# The folders .gitignore files leave out, like a Drupal site's web/core, so finding the projects in a folder of
|
|
5
|
+
# them, like ~/dev, skips them. Only lines naming a folder are read, like /vendor/, node_modules or
|
|
6
|
+
# /web/modules/contrib/*, as a wildcard could leave out a project, and a ! takes a folder back out.
|
|
7
|
+
class Gitignore
|
|
8
|
+
WILDCARD = /[*?\[]/
|
|
9
|
+
|
|
10
|
+
# The rules for the folders inside a folder: its .gitignore's and those of the folders above it, up to its
|
|
11
|
+
# repository, as a repository's rules don't reach the repositories inside it.
|
|
12
|
+
def initialize(folder, above = nil)
|
|
13
|
+
above = nil if File.exist?(File.join(folder, '.git'))
|
|
14
|
+
@paths = Set.new(above&.paths)
|
|
15
|
+
@names = Set.new(above&.names)
|
|
16
|
+
file = File.join(folder, '.gitignore')
|
|
17
|
+
File.foreach(file, chomp: true) { read(it.strip, folder) } if File.file?(file)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def ignores?(path)
|
|
21
|
+
@paths.include?(path) || @names.include?(File.basename(path))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
protected
|
|
25
|
+
|
|
26
|
+
attr_reader :paths, :names
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
# A folder with a slash, like /vendor or web/files, is from the .gitignore's folder, and one without, like
|
|
31
|
+
# node_modules, is any folder of that name below it.
|
|
32
|
+
def read(line, folder)
|
|
33
|
+
pattern = line.delete_suffix('/*').delete_suffix('/')
|
|
34
|
+
negated = pattern.delete_prefix!('!')
|
|
35
|
+
return if pattern.empty? || pattern.start_with?('#') || pattern.match?(WILDCARD)
|
|
36
|
+
|
|
37
|
+
if pattern.include?('/')
|
|
38
|
+
update(@paths, File.join(folder, pattern.delete_prefix('/')), negated:)
|
|
39
|
+
else
|
|
40
|
+
update(@names, pattern, negated:)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def update(rules, rule, negated:)
|
|
45
|
+
return rules.delete(rule) if negated
|
|
46
|
+
|
|
47
|
+
rules.add(rule)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Runs a project's JavaScript Components for Raindown, which starts it with node -e, since inside Raindown's package
|
|
2
|
+
// the script is where NodeJS can't read it. Each line on stdin is a request, answered by one line of JSON on stdout:
|
|
3
|
+
// { op: 'load', folder } → { nodes: [{ name, render }] }
|
|
4
|
+
// { op: 'new', id, name, args, props } → {}
|
|
5
|
+
// { op: 'call', id, method, context } → { value }
|
|
6
|
+
// A request that throws is answered with { error }.
|
|
7
|
+
import { readdirSync } from 'node:fs'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
import { createInterface } from 'node:readline'
|
|
10
|
+
import { pathToFileURL } from 'node:url'
|
|
11
|
+
|
|
12
|
+
// stdout carries the replies, so what nodes log goes to stderr.
|
|
13
|
+
console.log = console.info = console.error
|
|
14
|
+
|
|
15
|
+
const classes = {}
|
|
16
|
+
const nodes = {}
|
|
17
|
+
|
|
18
|
+
const requests = {
|
|
19
|
+
// Every exported class with replace is a node, named by its export, or by its class for a default export.
|
|
20
|
+
async load({ folder }) {
|
|
21
|
+
for (const file of readdirSync(folder).filter((file) => /\.m?js$/.test(file))) {
|
|
22
|
+
const exports = await import(pathToFileURL(join(folder, file)).href)
|
|
23
|
+
|
|
24
|
+
for (const [name, value] of Object.entries(exports)) {
|
|
25
|
+
if (typeof value?.prototype?.replace === 'function') classes[name === 'default' ? value.name : name] = value
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const nodes = Object.entries(classes).map(([name, node]) => ({ name, render: typeof node.prototype.render === 'function' }))
|
|
30
|
+
return { nodes }
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
new({ id, name, args, props }) {
|
|
34
|
+
nodes[id] = new classes[name](props, ...args)
|
|
35
|
+
return {}
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
async call({ id, method, context }) {
|
|
39
|
+
return { value: await nodes[id][method](context) }
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
for await (const line of createInterface({ input: process.stdin })) {
|
|
44
|
+
const request = JSON.parse(line)
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
process.stdout.write(`${JSON.stringify(await requests[request.op](request))}\n`)
|
|
48
|
+
} catch (error) {
|
|
49
|
+
process.stdout.write(`${JSON.stringify({ error: String(error?.message ?? error) })}\n`)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'open3'
|
|
5
|
+
|
|
6
|
+
require_relative '../node'
|
|
7
|
+
|
|
8
|
+
module Raindown
|
|
9
|
+
# JavaScript Components, the .js and .mjs files in a project's .raindown folder, which export classes with replace and
|
|
10
|
+
# optionally render, like Ruby nodes. They run in a NodeJS process of Raindown's own, started when a folder has them,
|
|
11
|
+
# so only projects with JavaScript Components need NodeJS.
|
|
12
|
+
module JSNodes
|
|
13
|
+
EXTENSIONS = %w[.js .mjs].freeze
|
|
14
|
+
SCRIPT = File.join(__dir__, 'host.mjs')
|
|
15
|
+
|
|
16
|
+
class Error < StandardError; end
|
|
17
|
+
|
|
18
|
+
# The NodeJS process running the nodes, which answers each request with a line of JSON. NodeJS can't read the script
|
|
19
|
+
# inside Raindown's package, so it's passed to node -e.
|
|
20
|
+
class Host
|
|
21
|
+
def initialize
|
|
22
|
+
@input, @output, = Open3.popen2('node', '--input-type=module', '-e', File.read(SCRIPT))
|
|
23
|
+
rescue Errno::ENOENT
|
|
24
|
+
raise Error, 'JavaScript Components need NodeJS 22 or later on your PATH'
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def request(**message)
|
|
28
|
+
@input.puts(JSON.generate(message))
|
|
29
|
+
reply = JSON.parse(@output.gets || raise(Error, 'NodeJS stopped running the JavaScript Components'))
|
|
30
|
+
raise Error, reply['error'] if reply['error']
|
|
31
|
+
|
|
32
|
+
reply
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
# Loads the JavaScript Components in a .raindown folder, starting NodeJS the first time a folder has any.
|
|
39
|
+
def load(folder)
|
|
40
|
+
return if Dir.glob(EXTENSIONS.map { "*#{it}" }, base: folder).empty?
|
|
41
|
+
|
|
42
|
+
host.request(op: 'load', folder:).fetch('nodes').each do |node|
|
|
43
|
+
node_classes[node['name']] ||= JSNode.for(name: node['name'], render: node['render'])
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def node_class(name)
|
|
48
|
+
node_classes[name]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def host
|
|
52
|
+
@host ||= Host.new
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def node_classes
|
|
56
|
+
@node_classes ||= {}
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# A JavaScript Component, made in the NodeJS process with its props, where its replace and render are sent.
|
|
61
|
+
class JSNode < Node
|
|
62
|
+
# A class for each JavaScript Component, which renders its own HTML when the component has render.
|
|
63
|
+
def self.for(name:, render:)
|
|
64
|
+
Class.new(self) do
|
|
65
|
+
define_singleton_method(:name) { name }
|
|
66
|
+
define_method(:render) { |**context| js_output(:render, context) } if render
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def initialize(*args, **props)
|
|
71
|
+
super()
|
|
72
|
+
JSNodes.host.request(op: 'new', id: object_id, name: self.class.name, args:, props:)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def replace(**context)
|
|
76
|
+
js_output(:replace, context)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def js_output(method, context)
|
|
82
|
+
JSNodes.host.request(op: 'call', id: object_id, method:, context: json_context(**context)).fetch('value').to_s
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# JSON can't hold Pages, so the metadata is its root and each file's front matter and path.
|
|
86
|
+
def json_context(metadata: NoMetadata, **context)
|
|
87
|
+
root = metadata.root if metadata.respond_to?(:root)
|
|
88
|
+
{ **context, metadata: { root:, files: metadata.list.map(&:metadata) } }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
data/lib/raindown/nodes.rb
CHANGED
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative 'chart/chart'
|
|
4
4
|
require_relative 'files'
|
|
5
|
+
require_relative 'js/js_nodes'
|
|
5
6
|
require_relative 'node'
|
|
6
7
|
require_relative 'stat'
|
|
7
8
|
require_relative 'toc'
|
|
8
9
|
|
|
9
10
|
module Raindown
|
|
10
11
|
# Nodes are LowNodes that a node block writes into the Markdown and renders into the HTML.
|
|
11
|
-
# A project keeps them in a .raindown folder, which is loaded with Dirload.
|
|
12
|
+
# A project keeps them in a .raindown folder, which is loaded with Dirload, beside its JavaScript Components, if any.
|
|
12
13
|
# A node writes Markdown with #replace, and HTML with #render if it has one. Either can take the keywords
|
|
13
14
|
# document: (the Markdown being rendered), metadata: and bare:, and gets the ones it asks for.
|
|
14
15
|
module Nodes
|
|
@@ -29,6 +30,7 @@ module Raindown
|
|
|
29
30
|
require 'dirload'
|
|
30
31
|
loaded << folder
|
|
31
32
|
dirload(folder)
|
|
33
|
+
JSNodes.load(folder)
|
|
32
34
|
end
|
|
33
35
|
|
|
34
36
|
def find_folder(directory)
|
|
@@ -54,11 +56,12 @@ module Raindown
|
|
|
54
56
|
loaded_class(BUILT_INS[name]) || Raindown.const_get(BUILT_INS[name])
|
|
55
57
|
end
|
|
56
58
|
|
|
59
|
+
# A Ruby node of the name, or else a JavaScript one.
|
|
57
60
|
def loaded_class(name)
|
|
58
|
-
klass = Object.const_get(name)
|
|
59
|
-
klass if node?(klass)
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
klass = Object.const_get(name) if Object.const_defined?(name)
|
|
62
|
+
return klass if node?(klass)
|
|
63
|
+
|
|
64
|
+
JSNodes.node_class(name)
|
|
62
65
|
end
|
|
63
66
|
|
|
64
67
|
# A LowNode, or one of Raindown's own nodes, that writes Markdown. In an app, LowNodes are also routes and pages,
|
data/lib/raindown/page.rb
CHANGED
|
@@ -17,7 +17,7 @@ module Raindown
|
|
|
17
17
|
# It follows the file's project config, like render_file, unless the render options say otherwise.
|
|
18
18
|
def content
|
|
19
19
|
@content ||= begin
|
|
20
|
-
markdown =
|
|
20
|
+
markdown = Utils.read_markdown(file_path)
|
|
21
21
|
|
|
22
22
|
if FrontMatter.parse(markdown).last.strip.empty? then ''
|
|
23
23
|
else Raindown.render(markdown:, bare: BareDirectives.file?(file_path), **Config.render_options(file_path).merge(@render_options))
|
data/lib/raindown/pages.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative 'front_matter'
|
|
4
4
|
require_relative 'page'
|
|
5
|
+
require_relative 'utils'
|
|
5
6
|
|
|
6
7
|
module Raindown
|
|
7
8
|
# The metadata of a project's Markdown files, which list: and files: list by tag.
|
|
@@ -62,7 +63,7 @@ module Raindown
|
|
|
62
63
|
end
|
|
63
64
|
|
|
64
65
|
def process(file_path:)
|
|
65
|
-
metadata = implicit_metadata(file_path:).merge(FrontMatter.parse(
|
|
66
|
+
metadata = implicit_metadata(file_path:).merge(FrontMatter.parse(Utils.read_markdown(file_path)).first)
|
|
66
67
|
@metadata[file_path] = metadata
|
|
67
68
|
metadata.each { |type, tag| Array(tag).each { |item| tag(type:, tag: item, file_path:) } }
|
|
68
69
|
end
|
data/lib/raindown/projects.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'find'
|
|
4
|
+
require_relative 'gitignore'
|
|
4
5
|
require_relative 'nodes'
|
|
5
6
|
require_relative 'pages'
|
|
6
7
|
|
|
@@ -10,22 +11,29 @@ module Raindown
|
|
|
10
11
|
module Projects
|
|
11
12
|
# Folders of dependencies, builds and tooling, which aren't searched for projects or watched, nor are hidden folders.
|
|
12
13
|
IGNORED = %w[node_modules vendor tmp pkg build dist coverage].freeze
|
|
14
|
+
# Ruby nodes and JavaScript Components.
|
|
15
|
+
NODE_EXTENSIONS = ['.rb', '.rbx', *JSNodes::EXTENSIONS].freeze
|
|
13
16
|
|
|
14
17
|
module_function
|
|
15
18
|
|
|
16
19
|
# The roots of the projects in a directory: the project the directory is in, if any, and every project below it.
|
|
17
20
|
def roots(directory)
|
|
18
21
|
directory = File.expand_path(directory)
|
|
19
|
-
|
|
22
|
+
[root(directory), *project_folders(directory)].compact.uniq
|
|
23
|
+
end
|
|
20
24
|
|
|
21
|
-
|
|
22
|
-
|
|
25
|
+
# The folders below a directory with a .raindown folder. Only folders are walked, leaving out ignored, linked and
|
|
26
|
+
# gitignored ones, so it stays quick in a big folder like ~/dev.
|
|
27
|
+
def project_folders(directory, gitignore = Gitignore.new(directory))
|
|
28
|
+
Dir.glob('*/', base: directory).flat_map do |folder|
|
|
29
|
+
path = File.join(directory, folder.chomp('/'))
|
|
30
|
+
next [] if ignored?(folder.chomp('/')) || File.symlink?(path) || gitignore.ignores?(path)
|
|
23
31
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
end
|
|
32
|
+
inside = project_folders(path, Gitignore.new(path, gitignore))
|
|
33
|
+
next inside unless project?(path)
|
|
27
34
|
|
|
28
|
-
|
|
35
|
+
[path, *inside]
|
|
36
|
+
end
|
|
29
37
|
end
|
|
30
38
|
|
|
31
39
|
# The root of the project a file or directory is in, or nil when it isn't in one.
|
|
@@ -36,13 +44,13 @@ module Raindown
|
|
|
36
44
|
end
|
|
37
45
|
|
|
38
46
|
# The project's Markdown files, sorted, leaving out ignored folders. The projects inside it are left out too,
|
|
39
|
-
# since they have nodes of their own
|
|
40
|
-
def files(root
|
|
47
|
+
# since they have nodes of their own.
|
|
48
|
+
def files(root)
|
|
41
49
|
files = []
|
|
42
50
|
|
|
43
51
|
Find.find(root) do |path|
|
|
44
52
|
if File.directory?(path)
|
|
45
|
-
Find.prune if path != root && (ignored?(File.basename(path)) ||
|
|
53
|
+
Find.prune if path != root && (ignored?(File.basename(path)) || project?(path))
|
|
46
54
|
elsif markdown?(path, root:)
|
|
47
55
|
files << path
|
|
48
56
|
end
|
|
@@ -61,7 +69,12 @@ module Raindown
|
|
|
61
69
|
|
|
62
70
|
# Whether a file is one of a project's own nodes, which load when its watcher starts.
|
|
63
71
|
def node?(path, root:)
|
|
64
|
-
File.expand_path(path).start_with?(File.join(root, Nodes::FOLDER, '')) &&
|
|
72
|
+
File.expand_path(path).start_with?(File.join(root, Nodes::FOLDER, '')) && NODE_EXTENSIONS.include?(File.extname(path))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The roots that aren't inside another of them, as a project's code includes the projects inside it.
|
|
76
|
+
def outer_roots(roots)
|
|
77
|
+
roots.reject { |root| roots.any? { root != it && root.start_with?(File.join(it, '')) } }
|
|
65
78
|
end
|
|
66
79
|
|
|
67
80
|
def project?(folder)
|
data/lib/raindown/rewriter.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative 'node_injector'
|
|
4
4
|
require_relative 'endpoint/endpoint_runner'
|
|
5
5
|
require_relative 'table_formatter'
|
|
6
|
+
require_relative 'utils'
|
|
6
7
|
|
|
7
8
|
module Raindown
|
|
8
9
|
# Rewrites the generated parts of a Markdown file in place before it's rendered:
|
|
@@ -21,7 +22,7 @@ module Raindown
|
|
|
21
22
|
|
|
22
23
|
# Returns true when the file changed.
|
|
23
24
|
def update(path:, bare: false, execute: false, metadata: NoMetadata)
|
|
24
|
-
content =
|
|
25
|
+
content = Utils.read_markdown(path)
|
|
25
26
|
rewritten = rewrite(content:, path:, bare:, execute:, metadata:)
|
|
26
27
|
return false if rewritten == content
|
|
27
28
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative '../code_box'
|
|
4
4
|
require_relative '../events'
|
|
5
5
|
require_relative '../../blueprints'
|
|
6
|
+
require_relative '../../projects'
|
|
6
7
|
require_relative 'traced_code'
|
|
7
8
|
require_relative 'canvas'
|
|
8
9
|
require_relative 'code_comments'
|
|
@@ -32,9 +33,10 @@ module Raindown
|
|
|
32
33
|
|
|
33
34
|
attr_reader :cursor
|
|
34
35
|
|
|
35
|
-
def initialize(root:)
|
|
36
|
+
def initialize(root:, projects: [root])
|
|
36
37
|
@root = root
|
|
37
|
-
@
|
|
38
|
+
@projects = projects
|
|
39
|
+
@paths = projects.map { File.join(it, Blueprints::FILE) }
|
|
38
40
|
@all = []
|
|
39
41
|
@cursor = 0
|
|
40
42
|
select_box(0)
|
|
@@ -51,7 +53,7 @@ module Raindown
|
|
|
51
53
|
end
|
|
52
54
|
|
|
53
55
|
# Whether there are any blueprints yet, saved or in the code's comments, which are looked for once until it's loaded.
|
|
54
|
-
def any? = File.exist?(
|
|
56
|
+
def any? = @paths.any? { File.exist?(it) } || commented.any?
|
|
55
57
|
|
|
56
58
|
# Whether the file's changed since it was read, so it's read again on the next tick.
|
|
57
59
|
def changed? = mtime != @read_at
|
|
@@ -60,13 +62,26 @@ module Raindown
|
|
|
60
62
|
@read_at = mtime
|
|
61
63
|
newest = @all.first&.time
|
|
62
64
|
@commented = nil
|
|
63
|
-
@all =
|
|
65
|
+
@all = saved + commented
|
|
64
66
|
self.cursor = 0 unless @all.first&.time == newest
|
|
65
67
|
self.cursor = @cursor
|
|
66
68
|
end
|
|
67
69
|
|
|
68
70
|
def selected = blueprints[@cursor]
|
|
69
71
|
|
|
72
|
+
# Narrows the blueprints to a project's, or with nil, shows every project's.
|
|
73
|
+
def project=(project)
|
|
74
|
+
@project = project
|
|
75
|
+
self.cursor = 0
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The blueprints in the project they're narrowed to, if any, which the filter's keywords narrow further.
|
|
79
|
+
def in_project
|
|
80
|
+
return @all unless @project
|
|
81
|
+
|
|
82
|
+
@all.select { File.expand_path(it.path, @root).start_with?(File.join(@project, '')) }
|
|
83
|
+
end
|
|
84
|
+
|
|
70
85
|
# Another blueprint's shown from its first box, while the same one keeps its box and canvas, like when it's clicked.
|
|
71
86
|
def cursor=(index)
|
|
72
87
|
shown = selected
|
|
@@ -115,7 +130,7 @@ module Raindown
|
|
|
115
130
|
method = code
|
|
116
131
|
return Edit.new(path: method.path, line: method.line) if method
|
|
117
132
|
|
|
118
|
-
Edit.new(path: File.
|
|
133
|
+
Edit.new(path: File.expand_path(selected.path, @root), line: selected.line)
|
|
119
134
|
end
|
|
120
135
|
|
|
121
136
|
# A click on a box drawn in the blueprint, which moves on to the next code it names when it's already selected.
|
|
@@ -138,9 +153,20 @@ module Raindown
|
|
|
138
153
|
|
|
139
154
|
def count(number, noun) = "#{number} #{number == 1 ? noun : "#{noun}s"}"
|
|
140
155
|
|
|
141
|
-
def mtime = File.exist?(
|
|
156
|
+
def mtime = @paths.map { File.exist?(it) ? File.mtime(it) : nil }
|
|
157
|
+
|
|
158
|
+
# Every project's saved blueprints, newest first, each by its file from the root.
|
|
159
|
+
def saved
|
|
160
|
+
@projects.flat_map { |project| Blueprints.read(project).map { it.with(path: from_root(File.join(project, it.path))) } }
|
|
161
|
+
.sort_by(&:time).reverse
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def from_root(path) = path.delete_prefix(File.join(@root, ''))
|
|
165
|
+
|
|
166
|
+
# The projects whose code is read, as a project's code includes the projects inside it.
|
|
167
|
+
def code_projects = @code_projects ||= Projects.outer_roots(@projects)
|
|
142
168
|
|
|
143
|
-
def commented = @commented ||= CodeComments.blueprints(@root)
|
|
169
|
+
def commented = @commented ||= CodeComments.blueprints(@root, projects: code_projects)
|
|
144
170
|
end
|
|
145
171
|
end
|
|
146
172
|
end
|
|
@@ -16,9 +16,10 @@ module Raindown
|
|
|
16
16
|
|
|
17
17
|
module_function
|
|
18
18
|
|
|
19
|
-
# The blueprints in the comments of the
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
# The blueprints in the comments of the projects' code, by file from the root, from the top of each. In a folder of
|
|
20
|
+
# projects, like ~/dev, only the projects' code is read.
|
|
21
|
+
def blueprints(root, projects: [root])
|
|
22
|
+
projects.flat_map { CodeMethods.files(it) }.flat_map { |path| in_file(path, path.delete_prefix(File.join(root, ''))) }
|
|
22
23
|
end
|
|
23
24
|
|
|
24
25
|
def in_file(path, relative)
|
|
@@ -15,10 +15,11 @@ module Raindown
|
|
|
15
15
|
|
|
16
16
|
def typing? = @filtering == true
|
|
17
17
|
|
|
18
|
-
# The blueprints with every keyword, or all of them,
|
|
18
|
+
# The blueprints with every keyword, or all of them, in the project they're narrowed to, if any, filtered again only when
|
|
19
|
+
# the keywords, the project or the blueprints change,
|
|
19
20
|
# as what's drawn asks for the selected one many times a frame.
|
|
20
21
|
def blueprints
|
|
21
|
-
@filtered = [query, @all, filtered(query.to_s.downcase.split)] unless filtered_now?
|
|
22
|
+
@filtered = [query, @all, @project, filtered(query.to_s.downcase.split)] unless filtered_now?
|
|
22
23
|
@filtered.last
|
|
23
24
|
end
|
|
24
25
|
|
|
@@ -38,12 +39,12 @@ module Raindown
|
|
|
38
39
|
|
|
39
40
|
private
|
|
40
41
|
|
|
41
|
-
def filtered_now? = @filtered && @filtered[0] == query && @filtered[1].equal?(@all)
|
|
42
|
+
def filtered_now? = @filtered && @filtered[0] == query && @filtered[1].equal?(@all) && @filtered[2] == @project
|
|
42
43
|
|
|
43
44
|
def filtered(keywords)
|
|
44
|
-
return
|
|
45
|
+
return in_project if keywords.empty?
|
|
45
46
|
|
|
46
|
-
|
|
47
|
+
in_project.select { |blueprint| keywords.all? { blueprint.words.downcase.include?(it) || blueprint.kind.include?(it) } }
|
|
47
48
|
end
|
|
48
49
|
|
|
49
50
|
def open_filter
|
|
@@ -50,7 +50,8 @@ module Raindown
|
|
|
50
50
|
|
|
51
51
|
def first_method(owner) = methods.select { it.owner == owner }.min_by { [it.path, it.line] }
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
# The projects' methods. In a folder of projects, like ~/dev, only the projects' code is read.
|
|
54
|
+
def methods = @methods ||= code_projects.flat_map { CodeMethods.list(it) }
|
|
54
55
|
end
|
|
55
56
|
end
|
|
56
57
|
end
|
|
@@ -18,7 +18,7 @@ module Raindown
|
|
|
18
18
|
|
|
19
19
|
# The five bins, each a box with its number, its temper, and a bar of how many feelings it holds.
|
|
20
20
|
def bins
|
|
21
|
-
counts = view.
|
|
21
|
+
counts = view.counts
|
|
22
22
|
most = [counts.values.max, 1].max
|
|
23
23
|
chosen = chosen_temper
|
|
24
24
|
boxes = counts.each_with_index.map { |(temper, count), index| bin(index + 1, temper, count, most, chosen) }
|
|
@@ -41,9 +41,24 @@ module Raindown
|
|
|
41
41
|
|
|
42
42
|
module_function
|
|
43
43
|
|
|
44
|
+
# Git log runs in a thread of its own while the methods are read, as it's as slow as reading them. Waiting on git's
|
|
45
|
+
# output frees the main thread to read, and the two meet when each method gets its file's changes, for its drift:
|
|
46
|
+
# Thread main thread
|
|
47
|
+
# ┌──────────────────────────┐ ┌─────────────────────────┐
|
|
48
|
+
# │ CodeMethods.file_changes │ │ CodeMethods.tagged │
|
|
49
|
+
# │ git log, in a subprocess │ │ or CodeMethods.indented │
|
|
50
|
+
# └────────────┬─────────────┘ └────────────┬────────────┘
|
|
51
|
+
# │ changes.value waits for it │
|
|
52
|
+
# └───────────────────┬───────────────────┘
|
|
53
|
+
# │
|
|
54
|
+
# ▼
|
|
55
|
+
# ┌──────────────────────┐
|
|
56
|
+
# │ CodeMethods.drifting │
|
|
57
|
+
# └──────────────────────┘
|
|
44
58
|
def list(root)
|
|
45
|
-
changes = file_changes(root)
|
|
46
|
-
|
|
59
|
+
changes = Thread.new { file_changes(root) }
|
|
60
|
+
methods = tagged(root) || indented(root)
|
|
61
|
+
drifting(methods.map { it.with(changes: changes.value.fetch(it.path, 0)) })
|
|
47
62
|
end
|
|
48
63
|
|
|
49
64
|
# Each method with its drift, from 0 for the lightest to 9 for the heaviest, where methods that weigh the same
|
|
@@ -81,9 +81,9 @@ module Raindown
|
|
|
81
81
|
# How the method last felt, while its code's the same as when it was felt, or nil.
|
|
82
82
|
def temper(method) = rows.reverse.find { felt_row?(it, method) }&.fetch(:temper)
|
|
83
83
|
|
|
84
|
-
# How many feelings there are of each temper, in the bins' order.
|
|
85
|
-
def counts
|
|
86
|
-
tally = rows.map { it[:temper] }.tally
|
|
84
|
+
# How many feelings there are of each temper, in the bins' order, of those within a folder, like a project, if one's given.
|
|
85
|
+
def counts(within: nil)
|
|
86
|
+
tally = rows.select { within.nil? || File.join(@root, path(it)).start_with?(File.join(within, '')) }.map { it[:temper] }.tally
|
|
87
87
|
TEMPERS.to_h { [it, tally.fetch(it, 0)] }
|
|
88
88
|
end
|
|
89
89
|
|
|
@@ -132,5 +132,33 @@ module Raindown
|
|
|
132
132
|
nil
|
|
133
133
|
end
|
|
134
134
|
end
|
|
135
|
+
|
|
136
|
+
# The feelings about each project's code, in its own FeelingsLog, so in a folder of projects, like ~/dev, each project
|
|
137
|
+
# keeps its own feelings, and the folder isn't made a project.
|
|
138
|
+
class FeelingsLogs
|
|
139
|
+
def initialize(projects:)
|
|
140
|
+
@logs = projects.to_h { [it, FeelingsLog.new(root: it)] }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def add(feeling) = log_of(feeling.method).add(feeling)
|
|
144
|
+
def felt?(method) = log_of(method).felt?(method)
|
|
145
|
+
def temper(method) = log_of(method).temper(method)
|
|
146
|
+
def rows = @logs.values.flat_map(&:rows)
|
|
147
|
+
|
|
148
|
+
# Each project's feelings about methods that have changed, or gone, from its methods there are now, to its memories.
|
|
149
|
+
def move_stale_to_memories(methods)
|
|
150
|
+
by_log = methods.group_by { log_of(it) }
|
|
151
|
+
@logs.each_value { it.move_stale_to_memories(by_log.fetch(it, [])) }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def counts(within: nil)
|
|
155
|
+
empty = FeelingsLog::TEMPERS.to_h { [it, 0] }
|
|
156
|
+
@logs.values.map { it.counts(within:) }.reduce(empty) { |sum, counts| sum.merge(counts) { |_, felt, more| felt + more } }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
def log_of(method) = @logs.find { |root, _| method.path.start_with?(File.join(root, '')) }&.last
|
|
162
|
+
end
|
|
135
163
|
end
|
|
136
164
|
end
|
|
@@ -76,9 +76,10 @@ module Raindown
|
|
|
76
76
|
|
|
77
77
|
def view = navigator.view
|
|
78
78
|
|
|
79
|
-
# Instead of the grid, that there's nothing to feel, or the celebration.
|
|
79
|
+
# Instead of the grid, that there's nothing to feel, as there are no projects or no methods, or the celebration.
|
|
80
80
|
def instead
|
|
81
|
-
return
|
|
81
|
+
return alert(NO_PROJECTS) if navigator.projects.empty?
|
|
82
|
+
return alert(["No functions or methods in #{File.basename(navigator.root)} to feel."]) if view.code_methods.empty?
|
|
82
83
|
|
|
83
84
|
celebration(view.celebration)
|
|
84
85
|
end
|
|
@@ -174,10 +175,6 @@ module Raindown
|
|
|
174
175
|
lines[(height - shown.length) / 2, shown.length] = shown
|
|
175
176
|
lines.map { [segment(it.center(width), :too_small)] }
|
|
176
177
|
end
|
|
177
|
-
|
|
178
|
-
def blank(message)
|
|
179
|
-
[[segment(message, :feelings)], *Array.new([height - 1, 0].max) { [segment('', :blank)] }].first(height)
|
|
180
|
-
end
|
|
181
178
|
end
|
|
182
179
|
end
|
|
183
180
|
end
|
|
@@ -29,12 +29,13 @@ module Raindown
|
|
|
29
29
|
KEYS = %i[up down left right edit execute enter toggle expand celebrate].freeze
|
|
30
30
|
|
|
31
31
|
# The temper being noted, and the note typed so far, while it's asking why, whether the selected method's code
|
|
32
|
-
# shows all that fits, until another's selected,
|
|
33
|
-
|
|
32
|
+
# shows all that fits, until another's selected, the rain then the waffle, once the batch is refined, and the project
|
|
33
|
+
# its methods and counts are narrowed to, if any.
|
|
34
|
+
attr_reader :noting, :note, :cursor, :log, :celebration, :project
|
|
34
35
|
|
|
35
|
-
def initialize(
|
|
36
|
-
@
|
|
37
|
-
@log =
|
|
36
|
+
def initialize(projects:)
|
|
37
|
+
@projects = projects
|
|
38
|
+
@log = FeelingsLogs.new(projects:)
|
|
38
39
|
@last_feelings = {}
|
|
39
40
|
@cursor = 0
|
|
40
41
|
@week = WeeklyBatch.new(log: @log)
|
|
@@ -63,12 +64,20 @@ module Raindown
|
|
|
63
64
|
# the memories.
|
|
64
65
|
def load
|
|
65
66
|
was = selected
|
|
66
|
-
every = CodeMethods.list(
|
|
67
|
-
@log.move_stale_to_memories(every)
|
|
68
|
-
@week.read(
|
|
67
|
+
@every = @projects.flat_map { CodeMethods.list(it) }
|
|
68
|
+
@log.move_stale_to_memories(@every)
|
|
69
|
+
@week.read(in_project)
|
|
69
70
|
reselect(was)
|
|
70
71
|
end
|
|
71
72
|
|
|
73
|
+
# Narrows the methods to a project's, or with nil, shows every project's, picking the batch again from them.
|
|
74
|
+
def project=(project)
|
|
75
|
+
@project = project
|
|
76
|
+
@week.read(in_project)
|
|
77
|
+
@week.pick_again
|
|
78
|
+
self.cursor = 0
|
|
79
|
+
end
|
|
80
|
+
|
|
72
81
|
# Selects the method again, by its file and name, wherever it is now, or keeps the cursor where it was.
|
|
73
82
|
def reselect(was)
|
|
74
83
|
self.cursor = code_methods.index { [it.path, it.label] == [was&.path, was&.label] } || @cursor
|
|
@@ -78,6 +87,9 @@ module Raindown
|
|
|
78
87
|
def batch = @week.batch
|
|
79
88
|
def code_methods = batch.code_methods
|
|
80
89
|
|
|
90
|
+
# How many feelings there are of each temper, in the project the methods are narrowed to, if any.
|
|
91
|
+
def counts = log.counts(within: project)
|
|
92
|
+
|
|
81
93
|
def selected = code_methods[@cursor]
|
|
82
94
|
|
|
83
95
|
def cursor=(index)
|
|
@@ -106,6 +118,11 @@ module Raindown
|
|
|
106
118
|
|
|
107
119
|
private
|
|
108
120
|
|
|
121
|
+
# The methods of the project they're narrowed to, if any, which are none until they're read.
|
|
122
|
+
def in_project
|
|
123
|
+
@every.to_a.select { @project.nil? || it.path.start_with?(File.join(@project, '')) }
|
|
124
|
+
end
|
|
125
|
+
|
|
109
126
|
# Moves between the methods, or acts on the selected one.
|
|
110
127
|
def method_step(key)
|
|
111
128
|
case key
|
|
@@ -28,8 +28,8 @@ module Raindown
|
|
|
28
28
|
PROJECT = '🌧'
|
|
29
29
|
|
|
30
30
|
def load
|
|
31
|
-
@
|
|
32
|
-
@
|
|
31
|
+
@projects = @workers.roots
|
|
32
|
+
@files = @projects.flat_map { Projects.files(it) }.sort
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
# The folder the tree starts from: the scope's, or the root's.
|
|
@@ -45,10 +45,10 @@ module Raindown
|
|
|
45
45
|
@files = FileTree.new(root:, workers:)
|
|
46
46
|
@endpoints = Endpoints.new(workers:)
|
|
47
47
|
@dashboard = DashboardView.new(root:, workers:)
|
|
48
|
-
@feelings = FeelingsView.new(
|
|
49
|
-
@blueprints = BlueprintsView.new(root:)
|
|
48
|
+
@feelings = FeelingsView.new(projects: Projects.outer_roots(workers.roots))
|
|
49
|
+
@blueprints = BlueprintsView.new(root:, projects: workers.roots)
|
|
50
50
|
@nav = ListView.new { nav_rows }
|
|
51
|
-
first =
|
|
51
|
+
first = @files.files.first
|
|
52
52
|
# The dashboard is the first page, unless a file is asked for, and "d" goes from it to the first file.
|
|
53
53
|
@view = OutlineView.new(first, workers:) if first
|
|
54
54
|
path ? open_at(path, nil) : open_view(dashboard)
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
module Raindown
|
|
4
4
|
module TUI
|
|
5
5
|
# The list of projects that drops down from the header's path, with "p" or a click, when there's more than one.
|
|
6
|
-
# Choosing one narrows the files
|
|
7
|
-
# It stays open until a project's chosen, with Enter or a click, while up and down move its cursor.
|
|
6
|
+
# Choosing one narrows the files, the dashboard, Code feelings and Blueprints to its own, and "All projects" shows every
|
|
7
|
+
# project's again. It stays open until a project's chosen, with Enter or a click, while up and down move its cursor.
|
|
8
8
|
# Esc or "p" close it without choosing, and other keys and clicks do nothing while it's open.
|
|
9
9
|
module ProjectMenu
|
|
10
10
|
# The menu's cursor, while it's open.
|
|
@@ -54,6 +54,8 @@ module Raindown
|
|
|
54
54
|
@files.scope = project
|
|
55
55
|
@endpoints.project = project
|
|
56
56
|
dashboard.project = project
|
|
57
|
+
feelings.project = project
|
|
58
|
+
blueprints.project = project
|
|
57
59
|
open_view(dashboard)
|
|
58
60
|
end
|
|
59
61
|
end
|
data/lib/raindown/tui/project.rb
CHANGED
|
@@ -65,7 +65,7 @@ module Raindown
|
|
|
65
65
|
# the ones whose snippet can't be read.
|
|
66
66
|
def endpoints
|
|
67
67
|
Projects.files(root).flat_map do |path|
|
|
68
|
-
lines =
|
|
68
|
+
lines = Utils.read_markdown(path).lines
|
|
69
69
|
runnable = Document.read(path, bare: @bare).directives.select { it.endpoint? && it.writes }
|
|
70
70
|
runnable.filter_map { |directive| endpoint(path, lines, directive) }
|
|
71
71
|
rescue StandardError
|
|
@@ -94,7 +94,7 @@ module Raindown
|
|
|
94
94
|
end
|
|
95
95
|
|
|
96
96
|
def charts_in(path)
|
|
97
|
-
content =
|
|
97
|
+
content = Utils.read_markdown(path)
|
|
98
98
|
bare = @bare || BareDirectives.enabled?(path:, content:)
|
|
99
99
|
lines = content.lines
|
|
100
100
|
headings = Document.parse(content, bare:).headings
|
|
@@ -28,8 +28,10 @@ module Raindown
|
|
|
28
28
|
def page = 'Dashboard'
|
|
29
29
|
|
|
30
30
|
def lines
|
|
31
|
+
return alert(NO_PROJECTS) if navigator.projects.empty?
|
|
32
|
+
|
|
31
33
|
cards = dashboard.cards
|
|
32
|
-
return
|
|
34
|
+
return alert(["No charts in #{File.basename(navigator.root)} yet.", "Add one like <{ chart: '3 5 2 8' }>"]) if cards.empty?
|
|
33
35
|
|
|
34
36
|
rows = dashboard.rows(width)
|
|
35
37
|
@headings = headings(rows, cards)
|
|
@@ -94,9 +96,7 @@ module Raindown
|
|
|
94
96
|
[segment(" #{from_home(project)}", :project)]
|
|
95
97
|
end
|
|
96
98
|
|
|
97
|
-
def blank(count
|
|
98
|
-
[[segment(message, :info)], *Array.new([count - 1, 0].max) { [segment('', :blank)] }].first(count)
|
|
99
|
-
end
|
|
99
|
+
def blank(count) = Array.new(count) { [segment('', :blank)] }
|
|
100
100
|
end
|
|
101
101
|
end
|
|
102
102
|
end
|
|
@@ -6,6 +6,9 @@ require_relative '../text'
|
|
|
6
6
|
|
|
7
7
|
module Raindown
|
|
8
8
|
module TUI
|
|
9
|
+
# Said in place of a page, like the charts or the code to feel, when the folder has no projects, with how to make one.
|
|
10
|
+
NO_PROJECTS = ['No projects found in this directory.', %(Run "raindown init" in a project's folder.)].freeze
|
|
11
|
+
|
|
9
12
|
# 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
13
|
# Like the Layout, it's made again for each draw, so it draws what the Navigator and its view hold then.
|
|
11
14
|
Renderer = Struct.new(:navigator, :pane, :width, :height) do
|
|
@@ -14,6 +17,23 @@ module Raindown
|
|
|
14
17
|
|
|
15
18
|
# Its side of the line above the panes, which is blank unless it has something to say there, like a progress.
|
|
16
19
|
def spacer = [segment('', :blank)]
|
|
20
|
+
|
|
21
|
+
# The lines in a purple box, like an alert, at the top left, with blank below it, like when there's nothing to show.
|
|
22
|
+
# It's no wider than the pane, cutting the lines to fit.
|
|
23
|
+
def alert(messages)
|
|
24
|
+
inner = [messages.map { display_width(it) }.max, width - 5].min.clamp(0..)
|
|
25
|
+
box = [alert_edge('╔', '╗', inner), *messages.map { alert_line(it, inner) }, alert_edge('╚', '╝', inner)]
|
|
26
|
+
(box + Array.new(height) { [segment('', :blank)] }).first(height)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def alert_edge(left, right, inner)
|
|
30
|
+
padded([segment('', :blank, width: 1), segment("#{left}#{'═' * (inner + 2)}#{right}", :alert, width: inner + 4)])
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def alert_line(message, inner)
|
|
34
|
+
padded([segment('', :blank, width: 1), segment('║ ', :alert, width: 2), segment(message, :alert, width: inner),
|
|
35
|
+
segment(' ║', :alert, width: 2)])
|
|
36
|
+
end
|
|
17
37
|
end
|
|
18
38
|
end
|
|
19
39
|
end
|
|
@@ -34,7 +34,7 @@ module Raindown
|
|
|
34
34
|
Curses.stdscr.timeout = timeout
|
|
35
35
|
key = Curses.stdscr.getch
|
|
36
36
|
return :tick if key.nil?
|
|
37
|
-
return mouse(zones) if key == Curses::KEY_MOUSE
|
|
37
|
+
return mouse(zones) if MOUSE && key == Curses::KEY_MOUSE
|
|
38
38
|
|
|
39
39
|
Curses.stdscr.clear if key == Curses::KEY_RESIZE
|
|
40
40
|
key_event(key, typing:)
|
|
@@ -60,22 +60,27 @@ module Raindown
|
|
|
60
60
|
TYPING_KEYS.fetch(key) { Type.new(text: key) if key.is_a?(String) && key.match?(/\A[[:print:]]\z/) }
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
+
# Windows' curses has no mouse, so the curses gem leaves out its constants and the mouse does nothing there.
|
|
64
|
+
MOUSE = Curses.respond_to?(:mousemask)
|
|
65
|
+
|
|
63
66
|
# The mouse buttons that click, which are the left button's. Scrolling does nothing, and since older ncurses,
|
|
64
67
|
# like macOS's, has no button for scrolling down, it can't be mistaken for a click.
|
|
65
|
-
CLICKS = Curses::BUTTON1_PRESSED | Curses::BUTTON1_CLICKED
|
|
68
|
+
CLICKS = MOUSE && Curses::BUTTON1_PRESSED | Curses::BUTTON1_CLICKED
|
|
66
69
|
|
|
67
70
|
# Letting go of the button, which older ncurses, like macOS's, also reports for each move while it's held, as it
|
|
68
71
|
# doesn't know the moves terminals send then.
|
|
69
|
-
RELEASE = Curses::BUTTON1_RELEASED
|
|
72
|
+
RELEASE = MOUSE && Curses::BUTTON1_RELEASED
|
|
70
73
|
|
|
71
74
|
# Moving the mouse, which terminals report once they're asked to, with any motion tracking.
|
|
72
|
-
MOTION = Curses::REPORT_MOUSE_POSITION
|
|
75
|
+
MOTION = MOUSE && Curses::REPORT_MOUSE_POSITION
|
|
73
76
|
ANY_MOTION = "\e[?1003h"
|
|
74
77
|
NO_MOTION = "\e[?1003l"
|
|
75
78
|
|
|
76
79
|
# The terminal sends the app its mouse events, clicks and moves, so scrolling doesn't scroll the terminal either.
|
|
77
80
|
# ncurses only asks some terminals to report every move, so it's asked directly too, until untrack_mouse.
|
|
78
81
|
def track_mouse
|
|
82
|
+
return unless MOUSE
|
|
83
|
+
|
|
79
84
|
Curses.mousemask(CLICKS | RELEASE | MOTION)
|
|
80
85
|
Curses.mouseinterval(0)
|
|
81
86
|
$stdout.write(ANY_MOTION)
|
|
@@ -83,6 +88,8 @@ module Raindown
|
|
|
83
88
|
end
|
|
84
89
|
|
|
85
90
|
def untrack_mouse
|
|
91
|
+
return unless MOUSE
|
|
92
|
+
|
|
86
93
|
$stdout.write(NO_MOTION)
|
|
87
94
|
$stdout.flush
|
|
88
95
|
end
|
|
@@ -85,7 +85,7 @@ module Raindown
|
|
|
85
85
|
cursor: Curses::A_UNDERLINE, cursor_button: Curses::A_UNDERLINE | Curses::A_BOLD, heading: Curses::A_BOLD,
|
|
86
86
|
body: Curses::A_DIM, error: Curses::A_BOLD, button: Curses::A_BOLD, folder: Curses::A_BOLD, project: Curses::A_BOLD, page: Curses::A_BOLD,
|
|
87
87
|
key: Curses::A_REVERSE, border: Curses::A_DIM, divider: Curses::A_BOLD,
|
|
88
|
-
code_keyword: Curses::A_BOLD, code_comment: Curses::A_DIM
|
|
88
|
+
code_keyword: Curses::A_BOLD, code_comment: Curses::A_DIM, alert: Curses::A_BOLD
|
|
89
89
|
)
|
|
90
90
|
end
|
|
91
91
|
|
|
@@ -112,11 +112,12 @@ module Raindown
|
|
|
112
112
|
Hash.new(text).merge(plain.transform_values { it | text })
|
|
113
113
|
end
|
|
114
114
|
|
|
115
|
-
# Headings, the waffle's butter's shine and its syrup, and Code feelings' text and lines, bold around the method the
|
|
115
|
+
# Headings, alerts, the waffle's butter's shine and its syrup, and Code feelings' text and lines, bold around the method the
|
|
116
116
|
# mouse is over, and its purple.
|
|
117
117
|
def emphasis
|
|
118
118
|
{ heading: pair(:heading) | Curses::A_BOLD, feelings: pair(:feelings), nearby: pair(:feelings) | Curses::A_BOLD,
|
|
119
|
-
feelings_purple: pair(:feelings_purple),
|
|
119
|
+
feelings_purple: pair(:feelings_purple), alert: pair(:feelings_purple) | Curses::A_BOLD,
|
|
120
|
+
shine: pair(:shine) | Curses::A_BOLD, syrup: pair(:syrup) }
|
|
120
121
|
end
|
|
121
122
|
|
|
122
123
|
# Highlighted code's tokens, like keywords and comments, with comments dim in 8 colours, where there's no grey.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Raindown
|
|
4
|
+
module Utils
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
# A Markdown file's text, with its line endings as LF, "\n", whatever they are in the file.
|
|
8
|
+
#
|
|
9
|
+
# Windows ends lines with CRLF, "\r\n", and Git for Windows checks files out that way, so a file written or cloned on
|
|
10
|
+
# Windows can reach Raindown with them on any platform. Raindown finds directives, like a bare toc:, front matter
|
|
11
|
+
# and code fences line by line, with patterns that end at "\n", so a "\r" left at the end of a line stops them
|
|
12
|
+
# matching: toc: would be left as text instead of writing its table of contents.
|
|
13
|
+
#
|
|
14
|
+
# Text mode ("rt") reads CRLF as LF on every platform, as Ruby does on Windows by default, so the rest of Raindown
|
|
15
|
+
# only ever sees "\n". Writing a file back then gives it LF line endings, except on Windows, where text mode writes
|
|
16
|
+
# CRLF again.
|
|
17
|
+
def read_markdown(path)
|
|
18
|
+
File.read(path, mode: 'rt', encoding: Encoding::UTF_8)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -25,7 +25,7 @@ module Raindown
|
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
# The folder's project and those inside it, found when they're first asked for, and again after a restart.
|
|
28
|
-
def roots = @roots ||=
|
|
28
|
+
def roots = @roots ||= Projects.roots(root).sort
|
|
29
29
|
|
|
30
30
|
# A worker for each project, as they were found when the workers last started.
|
|
31
31
|
def each(&)
|
data/lib/raindown.rb
CHANGED
|
@@ -15,6 +15,7 @@ require_relative 'raindown/heading_ids'
|
|
|
15
15
|
require_relative 'raindown/node_blocks'
|
|
16
16
|
require_relative 'raindown/pages'
|
|
17
17
|
require_relative 'raindown/pre_renderer'
|
|
18
|
+
require_relative 'raindown/utils'
|
|
18
19
|
require_relative 'raindown/variables'
|
|
19
20
|
|
|
20
21
|
module Raindown
|
|
@@ -40,7 +41,7 @@ module Raindown
|
|
|
40
41
|
# Code is highlighted, unless the file's project changes it in .raindown/config.yml.
|
|
41
42
|
def render_file(path:, metadata: NoMetadata, bare: false, **options)
|
|
42
43
|
options = Config.render_options(path).merge(options)
|
|
43
|
-
render(markdown:
|
|
44
|
+
render(markdown: Utils.read_markdown(path), metadata:, bare: bare || BareDirectives.file?(path), **options)
|
|
44
45
|
end
|
|
45
46
|
|
|
46
47
|
# Front matter keys are variables, like {title} or <{ title }>.
|
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.9.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- maedi
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-11-
|
|
10
|
+
date: 2026-11-03 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: antlers
|
|
@@ -216,7 +216,10 @@ files:
|
|
|
216
216
|
- lib/raindown/files.rb
|
|
217
217
|
- lib/raindown/fragments.rb
|
|
218
218
|
- lib/raindown/front_matter.rb
|
|
219
|
+
- lib/raindown/gitignore.rb
|
|
219
220
|
- lib/raindown/heading_ids.rb
|
|
221
|
+
- lib/raindown/js/host.mjs
|
|
222
|
+
- lib/raindown/js/js_nodes.rb
|
|
220
223
|
- lib/raindown/node.rb
|
|
221
224
|
- lib/raindown/node_blocks.rb
|
|
222
225
|
- lib/raindown/node_injector.rb
|
|
@@ -295,6 +298,7 @@ files:
|
|
|
295
298
|
- lib/raindown/tui/views/endpoint_view.rb
|
|
296
299
|
- lib/raindown/tui/views/list_view.rb
|
|
297
300
|
- lib/raindown/tui/views/outline_view.rb
|
|
301
|
+
- lib/raindown/utils.rb
|
|
298
302
|
- lib/raindown/variables.rb
|
|
299
303
|
- lib/raindown/watcher/changes.rb
|
|
300
304
|
- lib/raindown/watcher/file_watcher.rb
|