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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ae1eb87d8271ddcfcc3fb956311e11229421188a33f5bd7e0af70cffd2f49719
4
- data.tar.gz: 380a98d835cfa9de899c5870828ba157e89aa0d3a4b8621a495f1a0ecd1e9bf6
3
+ metadata.gz: c9a61e7a842dcd5dc4cc5f6d004db388173ef2d5c48af1a50e32fea6c317b678
4
+ data.tar.gz: 7bbae724e020d48a3f095ba1cc8e1c45b9ee5dd483daf0af5f8edd4333d83205
5
5
  SHA512:
6
- metadata.gz: b3e74f64840850c572446865664e5368915df5065f09e9483d530d58392acb13f0e20ce872b9f8ee1930154af82d4c3baf8bf6fbd7c370399315e3b7a3a3b480
7
- data.tar.gz: '09a95ee90ef2568aff8c783ecee3b2ac06590f0a71f1bde6e14e99e7e8f5c6e05776ca6ff3c7dcce22fd55a56e456273ecb0de2475fceb0785a312a022cd48fa'
6
+ metadata.gz: b8f312582ede3900be43490c52c54ce3df6292764c04c177f54510e397a4814c9a9184cbb0710113055bb0dd140f00ced0bcf309f59583d34485a900579ac64b
7
+ data.tar.gz: 932d51981cd38660a402a40bbf9b0015da35baf62bad04152cdfdc262287f9593643daf66d2b60b6282e4df8a3de553a9ebba1793b37bec0972c145ea275c5a0
@@ -30,7 +30,7 @@ module Raindown
30
30
  raise NotAnEndpoint, "#{directive.name} on line #{directive.line} isn't an endpoint" unless directive.endpoint?
31
31
 
32
32
  content = File.read(path, encoding: Encoding::UTF_8)
33
- rewritten = EndpointRunner.new(content:, bare: BareDirectives.file?(path), only: directive.line - 1).rewrite
33
+ rewritten = EndpointRunner.new(content:, bare: BareDirectives.enabled?(path:, content:), only: directive.line - 1).rewrite
34
34
  return false if rewritten == content
35
35
 
36
36
  File.write(path, rewritten)
@@ -5,7 +5,8 @@ require_relative 'front_matter'
5
5
 
6
6
  module Raindown
7
7
  # Raindown files (.rd/.raindown) can write a directive like <{ if: draft }> as a bare if: draft on its own line.
8
- # Markdown files opt in with the --bare flag. Nodes like toc: and files: are handled by NodeBlocks, before the rest.
8
+ # Markdown files opt in with "raindown: true" in their front matter, or with the --bare flag.
9
+ # Nodes like toc: and files: are handled by NodeBlocks, before the rest.
9
10
  #
10
11
  # Bare directives have no end tag. A bare block directive closes itself after the content it applies to,
11
12
  # so where a block needs to wrap more than that, write it with delimiters and an end tag instead:
@@ -24,8 +25,8 @@ module Raindown
24
25
 
25
26
  module_function
26
27
 
27
- def enabled?(path: nil, bare: false)
28
- bare || file?(path)
28
+ def enabled?(path: nil, content: nil, bare: false)
29
+ bare || file?(path) || front_matter?(content)
29
30
  end
30
31
 
31
32
  def file?(path)
@@ -34,6 +35,16 @@ module Raindown
34
35
  EXTENSIONS.include?(File.extname(path).downcase)
35
36
  end
36
37
 
38
+ # Whether the front matter has "raindown: true". Front matter that isn't valid YAML fails when the file renders,
39
+ # so here it only means no.
40
+ def front_matter?(content)
41
+ return false unless content&.start_with?('---')
42
+
43
+ FrontMatter.parse(content).first[:raindown] == true
44
+ rescue Psych::Exception
45
+ false
46
+ end
47
+
37
48
  # Wrap each bare directive in Antlers delimiters and close its block, leaving code and front matter as-is.
38
49
  def expand(content)
39
50
  lines = content.lines
data/lib/raindown/cli.rb CHANGED
@@ -23,8 +23,8 @@ module Raindown
23
23
 
24
24
  Commands:
25
25
  init [DIRECTORY] Create a .raindown folder for the project's config and nodes (default: current directory)
26
- tui [PATH] Browse a project's files and outlines in the terminal, fold directives, run endpoints
27
- and open the editor at a line
26
+ tui [PATH] Browse a project's files and outlines in the terminal, fold directives, run endpoints,
27
+ open the editor at a line and see a dashboard of the project's charts
28
28
 
29
29
  Options:
30
30
  --html [OUTPUT] Render FILE to HTML once, writing to OUTPUT or else printing to stdout
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'commonmarker'
4
4
 
5
+ require_relative '../raindown'
5
6
  require_relative 'bare_directives'
6
7
  require_relative 'code_blocks'
7
8
  require_relative 'endpoint/endpoint_runner'
@@ -17,9 +18,10 @@ module Raindown
17
18
 
18
19
  # A content directive: a node, like toc: or a project's Changelog:, or an endpoint.
19
20
  # It spans line to end_line, its end tag, or the last line of a bare block.
21
+ # Body is the lines inside it, between it and its end tag, like an endpoint's request and response.
20
22
  # Output is the lines Raindown writes back, like a node's Markdown or an endpoint's response, and is nil
21
23
  # when nothing's written yet. A self-closing directive like <{ :toc }> only renders, so it never writes.
22
- Directive = Data.define(:name, :args, :line, :end_line, :output, :writes) do
24
+ Directive = Data.define(:name, :args, :line, :end_line, :body, :output, :writes) do
23
25
  def endpoint? = name == 'endpoint'
24
26
  end
25
27
 
@@ -27,13 +29,14 @@ module Raindown
27
29
  TEXT_NODES = %i[text code].freeze
28
30
  BREAK_NODES = %i[softbreak linebreak].freeze
29
31
  ENDPOINT_END_TAG = /\A {0,3}<\{\s*:endpoint\s*\}>[ \t]*$/
32
+ END_TAG = /\A {0,3}<\{\s*:[\w:]+\s*\}>[ \t]*$/
30
33
 
31
34
  def self.parse(markdown, bare: false)
32
35
  new(markdown:, bare:)
33
36
  end
34
37
 
35
- # Raindown files read bare directives. A project's own nodes are only directives once they're loaded,
36
- # like with Nodes.load(path), the same as when the file renders.
38
+ # Raindown files read bare directives, as do Markdown files with "raindown: true" front matter.
39
+ # A project's own nodes are only directives once they're loaded, like with Nodes.load(path), the same as when the file renders.
37
40
  def self.read(path)
38
41
  new(markdown: File.read(path, encoding: Encoding::UTF_8), bare: BareDirectives.file?(path))
39
42
  end
@@ -41,7 +44,7 @@ module Raindown
41
44
  def initialize(markdown:, bare: false)
42
45
  @markdown = markdown
43
46
  @lines = markdown.lines
44
- @bare = bare
47
+ @bare = bare || BareDirectives.front_matter?(markdown)
45
48
  end
46
49
 
47
50
  # The headings in the order they appear, with their source text, like "Notes for {name}" before it's rendered.
@@ -139,10 +142,18 @@ module Raindown
139
142
  first..last if first <= last
140
143
  end
141
144
 
142
- # From a range of indexes to line numbers.
145
+ # From a range of indexes to line numbers. The body is the lines after the directive, up to its end tag,
146
+ # or to the end of a bare block, which has none.
143
147
  def directive(name, args, span, output:, writes:)
144
- output = (output.begin + 1)..(output.end + 1) if output
145
- Directive.new(name:, args:, line: span.begin + 1, end_line: span.end + 1, output:, writes:)
148
+ end_tag = span.end > span.begin && @lines[span.end].match?(END_TAG)
149
+ body = trimmed(span.begin + 1, end_tag ? span.end - 1 : span.end)
150
+
151
+ Directive.new(name:, args:, line: span.begin + 1, end_line: span.end + 1, body: lines(body), output: lines(output),
152
+ writes:)
153
+ end
154
+
155
+ def lines(range)
156
+ (range.begin + 1)..(range.end + 1) if range
146
157
  end
147
158
  end
148
159
  end
@@ -10,6 +10,7 @@ module Raindown
10
10
  # are allowed: nothing that reads or writes local files, loads a config, or uses another protocol.
11
11
  module Curl
12
12
  class Error < StandardError; end
13
+ Request = Data.define(:method, :url)
13
14
 
14
15
  TIMEOUT = '30'
15
16
  # --disable must come first and skips ~/.curlrc. The protocol options stop redirects to file:// and the like.
@@ -64,6 +65,20 @@ module Raindown
64
65
  raise Error, 'curl is not installed'
65
66
  end
66
67
 
68
+ # The snippet's method and URL, like GET https://httpbin.org/uuid, or nil when it can't be read.
69
+ # A snippet that sends data is a POST, unless it says otherwise, as with curl.
70
+ def request(snippet)
71
+ args = arguments(snippet)
72
+ url = args.find { it.match?(URL) }
73
+ method = args[args.index('--request') + 1] if args.include?('--request')
74
+ method ||= 'HEAD' if args.include?('--head')
75
+ method ||= 'POST' if args.intersect?(VALUE_CHECKS.keys.grep(/data|json/))
76
+
77
+ Request.new(method: (method || 'GET').upcase, url:) if url
78
+ rescue Error
79
+ nil
80
+ end
81
+
67
82
  def arguments(snippet)
68
83
  words = Shellwords.split(snippet.gsub(/\\\r?\n/, ' ').strip.delete_prefix('$ '))
69
84
  raise Error, 'Endpoint snippets must start with curl' unless words.first == 'curl'
@@ -26,7 +26,7 @@ module Raindown
26
26
  FETCHED = /\A {0,3}<!-- raindown:fetched [^\n]*? -->[ \t]*$/
27
27
 
28
28
  def self.rewrite(content:, path: nil, bare: false, execute: false, **)
29
- new(content:, bare: BareDirectives.enabled?(path:, bare:), execute:).rewrite
29
+ new(content:, bare: BareDirectives.enabled?(path:, content:, bare:), execute:).rewrite
30
30
  end
31
31
 
32
32
  def initialize(content:, bare: false, execute: false, only: nil)
@@ -18,7 +18,7 @@ module Raindown
18
18
 
19
19
  def initialize(content:, path: nil, bare: false, metadata: NoMetadata)
20
20
  @content = content
21
- @bare = BareDirectives.enabled?(path:, bare:)
21
+ @bare = BareDirectives.enabled?(path:, content:, bare:)
22
22
  @metadata = metadata
23
23
  end
24
24
 
@@ -35,14 +35,14 @@ module Raindown
35
35
  folder && File.dirname(folder)
36
36
  end
37
37
 
38
- # The project's Markdown files, sorted. Ignored folders and the projects inside it are left out,
39
- # since those projects have nodes of their own.
40
- def files(root)
38
+ # 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, unless they're asked for, like by the terminal UI, which lists them all.
40
+ def files(root, nested: false)
41
41
  files = []
42
42
 
43
43
  Find.find(root) do |path|
44
44
  if File.directory?(path)
45
- Find.prune if path != root && (ignored?(File.basename(path)) || File.directory?(File.join(path, Nodes::FOLDER)))
45
+ Find.prune if path != root && (ignored?(File.basename(path)) || (!nested && project?(path)))
46
46
  elsif markdown?(path, root:)
47
47
  files << path
48
48
  end
@@ -64,6 +64,10 @@ module Raindown
64
64
  File.expand_path(path).start_with?(File.join(root, Nodes::FOLDER, '')) && %w[.rb .rbx].include?(File.extname(path))
65
65
  end
66
66
 
67
+ def project?(folder)
68
+ File.directory?(File.join(folder, Nodes::FOLDER))
69
+ end
70
+
67
71
  def ignored?(folder)
68
72
  folder.start_with?('.') || IGNORED.include?(folder)
69
73
  end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'unicode/display_width'
4
+
5
+ module Raindown
6
+ module TUI
7
+ # A chart on the dashboard, drawn from its data by its project's Worker: a title of its file, from its project,
8
+ # and the heading above it, then its directive, then the chart, each line with its Layout style,
9
+ # in a border with rounded corners. It's as wide as the chart, so the title and directive are cut to fit it:
10
+ # ╭───────────────────────────────────────╮
11
+ # │ markdown.md › Charts │
12
+ # │ chart: 'data/downloads.csv' width: 30 │
13
+ # │ Jun ██████▊ 640 │
14
+ # ╰───────────────────────────────────────╯
15
+ class Card < Data.define(:project, :path, :line, :heading, :directive, :chart)
16
+ # The border's columns either side of a card's lines, "│ " and " │", and its lines above and below them.
17
+ BORDER_WIDTH = 4
18
+ BORDER_HEIGHT = 2
19
+
20
+ def title = [path.delete_prefix(File.join(project, '')), heading].compact.join(' › ')
21
+
22
+ def lines = parts.map(&:first)
23
+
24
+ # Each line, with its style.
25
+ def parts = [[title, :heading], [directive, :directive], *chart.map { [it, :chart] }]
26
+
27
+ # As wide as its chart.
28
+ def width = chart.map { Unicode::DisplayWidth.of(it) }.max || 0
29
+
30
+ def height = lines.length
31
+
32
+ # With its border, within a range of widths, like a pane's.
33
+ def box_width(within: nil) = within ? (width + BORDER_WIDTH).clamp(within) : width + BORDER_WIDTH
34
+ def box_height = height + BORDER_HEIGHT
35
+
36
+ # A line of the card in its border, as [text, style, width] parts, for a card the box width wide.
37
+ # The border's top is line 0, and below its bottom, the lines are blank, for a card shorter than its row.
38
+ # The title's style is title_style, like when the card's selected.
39
+ def format_line(line, box_width:, title_style: :heading)
40
+ width = [box_width - BORDER_WIDTH, 0].max
41
+ return [border(width, '╭', '╮')] if line.zero?
42
+ return [border(width, '╰', '╯')] if line == height + 1
43
+ return [['', :blank, width + BORDER_WIDTH]] if line > height + 1
44
+
45
+ text, style = parts[line - 1]
46
+ [['│ ', :border, 2], [text, style == :heading ? title_style : style, width], [' │', :border, 2]]
47
+ end
48
+
49
+ private
50
+
51
+ def border(width, left, right)
52
+ ["#{left}#{'─' * (width + 2)}#{right}", :border, width + BORDER_WIDTH]
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raindown
4
+ module TUI
5
+ # An endpoint in a project's files, listed in the nav pane by its request, like "GET httpbin.org/uuid",
6
+ # with its method in its style, like GET in :method_get.
7
+ Endpoint = Data.define(:project, :path, :line, :method, :url) do
8
+ def text = "#{method} #{address}"
9
+
10
+ # The URL without its scheme.
11
+ def address = url.sub(%r{\Ahttps?://}i, '')
12
+
13
+ def style = :"method_#{method.downcase}"
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'endpoint'
4
+ require_relative 'views/endpoint_view'
5
+ require_relative 'row'
6
+ require_relative 'workers/worker'
7
+
8
+ module Raindown
9
+ module TUI
10
+ # Every endpoint in the projects' files, found by each project's Worker, listed below the files.
11
+ # Like the dashboard, it can be narrowed to a project's own.
12
+ class Endpoints
13
+ # The project the endpoints are narrowed to, or nil for every project's.
14
+ attr_accessor :project
15
+
16
+ def initialize(workers:)
17
+ @workers = workers
18
+ load
19
+ end
20
+
21
+ # Finds the endpoints again, like after a file's changed.
22
+ def load
23
+ @endpoints = @workers.each.flat_map do |worker|
24
+ worker.call(:endpoints)
25
+ rescue Worker::Error
26
+ []
27
+ end
28
+ end
29
+
30
+ def all = project ? @endpoints.select { it.project == project } : @endpoints
31
+
32
+ def rows
33
+ all.map do
34
+ Row.new(text: it.address, tag: it, depth: 0, kind: :endpoint, target: it,
35
+ open: OpenView.new(view: EndpointView.new(it, workers: @workers)))
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raindown
4
+ module TUI
5
+ # A click on the screen: the cursor moves to the pane's row, or the dashboard's card or the menu's item,
6
+ # then the action's done there, like :enter, or a button's, like :toggle or :execute.
7
+ # Keys are their events' names, like :down or :enter.
8
+ Click = Data.define(:pane, :index, :action)
9
+
10
+ # What the Screen does after an event, other than drawing again, or stopping for :quit.
11
+ # Leaves the screen for a command, like the editor, then tells the Navigator it ran.
12
+ Run = Data.define(:command)
13
+
14
+ # Shows the message while the task runs, like an endpoint's request.
15
+ Wait = Data.define(:message, :task)
16
+
17
+ # Steps a row or a view asks of the Navigator, which each takes on it, returning what the Screen does next: to open
18
+ # a view right of the files, or a file in the outline at a directive, to edit a file at a line, or to run an endpoint.
19
+ OpenView = Data.define(:view) do
20
+ def take(navigator) = navigator.open_view(view)
21
+ end
22
+
23
+ Open = Data.define(:path, :line) do
24
+ def take(navigator) = navigator.open_at(path, line)
25
+ end
26
+
27
+ Edit = Data.define(:path, :line) do
28
+ def take(navigator) = navigator.edit_at(path, line)
29
+ end
30
+
31
+ Execute = Data.define(:path, :line, :name) do
32
+ def take(navigator) = navigator.run(path, line, name)
33
+ end
34
+ end
35
+ end
@@ -1,52 +1,94 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative '../projects'
4
+ require_relative 'events'
5
+ require_relative 'views/outline_view'
4
6
  require_relative 'row'
5
7
 
6
8
  module Raindown
7
9
  module TUI
8
- # The files pane: a project's Markdown files as a tree, with folders before files, where folders fold.
10
+ # The nav pane's files: a project's Markdown files as a tree, with folders before files,
11
+ # including the files of the projects inside it, whose folders are marked with Raindown's cloud.
12
+ # Folders start folded, and open with Enter, or when a file in them opens.
13
+ # A scope narrows it to one project's own files, from the project's folder, like when it's chosen in the header.
9
14
  class FileTree
10
15
  attr_reader :root
16
+ # The project the files are narrowed to, or nil for all of them.
17
+ attr_accessor :scope
11
18
 
12
- def initialize(root:)
19
+ def initialize(root:, workers:)
13
20
  @root = root
14
- @folded = Set.new
21
+ @workers = workers
22
+ @expanded = Set.new
15
23
  load
16
24
  end
17
25
 
26
+ # The rain cloud without its emoji variation selector, which every terminal draws one column wide, so it lines up
27
+ # the files with the panes beside them. With the selector, as in the title, some terminals draw it two.
28
+ PROJECT = '🌧'
29
+
18
30
  def load
19
- @files = Projects.files(root)
31
+ @files = Projects.files(root, nested: true)
32
+ @projects = Projects.roots(root).to_set
20
33
  end
21
34
 
35
+ # The folder the tree starts from: the scope's, or the root's.
36
+ def base = scope || root
37
+
22
38
  def rows
23
39
  tree = {}
24
- @files.each { |path| relative(path).split('/').reduce(tree) { |node, part| node[part] ||= {} } }
40
+ files.each { |path| relative(path).split('/').reduce(tree) { |node, part| node[part] ||= {} } }
25
41
  tree_rows(tree, '', 0)
26
42
  end
27
43
 
28
44
  def toggle(folder)
29
- @folded.delete?(folder) || @folded.add(folder)
45
+ @expanded.delete?(folder) || @expanded.add(folder)
46
+ end
47
+
48
+ # Opens the folders the file is in, so its row is shown.
49
+ def reveal(path)
50
+ folder = File.dirname(path)
51
+ while folder.start_with?(File.join(root, ''))
52
+ @expanded.add(folder)
53
+ folder = File.dirname(folder)
54
+ end
30
55
  end
31
56
 
32
57
  def relative(path)
33
- path.delete_prefix(File.join(root, ''))
58
+ path.delete_prefix(File.join(base, ''))
34
59
  end
35
60
 
36
61
  private
37
62
 
63
+ # A scope's own files, without those of the projects inside it, which are projects of their own.
64
+ def files
65
+ return @files unless scope
66
+
67
+ @files.select { |path| owner(path) == scope }
68
+ end
69
+
70
+ def owner(path)
71
+ [root, *@projects].select { path.start_with?(File.join(it, '')) }.max_by(&:length)
72
+ end
73
+
38
74
  def tree_rows(tree, prefix, depth)
39
75
  folders, files = tree.partition { |_name, children| children.any? }
40
76
 
41
77
  folders.sort.flat_map { |name, children| folder_rows(name, children, prefix, depth) } +
42
- files.sort.map { |name, _| Row.new(text: name, depth:, kind: :file, target: File.join(root, prefix, name)) }
78
+ files.sort.map { |name, _| file_row(name, File.join(base, prefix, name), depth) }
79
+ end
80
+
81
+ def file_row(name, path, depth)
82
+ Row.new(text: name, depth:, kind: :file, target: path, open: OpenView.new(view: OutlineView.new(path, workers: @workers)))
43
83
  end
44
84
 
45
85
  # A folder, and the tree inside it unless it's folded.
46
86
  def folder_rows(name, children, prefix, depth)
47
- folder = File.join(root, prefix, name)
48
- folded = @folded.include?(folder)
49
- row = Row.new(text: "#{folded ? '▸' : '▾'} #{name}/", depth:, kind: :folder, target: folder)
87
+ folder = File.join(base, prefix, name)
88
+ folded = !@expanded.include?(folder)
89
+ project = @projects.include?(folder)
90
+ row = Row.new(text: "#{folded ? '▸' : '▾'} #{"#{PROJECT} " if project}#{name}/", depth:,
91
+ kind: project ? :project : :folder, target: folder, fold: -> { toggle(folder) })
50
92
 
51
93
  [row, *(tree_rows(children, File.join(prefix, name), depth + 1) unless folded)]
52
94
  end