raindown 0.4.0 → 0.5.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.
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'find'
4
+ require_relative 'nodes'
5
+ require_relative 'pages'
6
+
7
+ module Raindown
8
+ # Raindown projects are folders with a .raindown folder, and their Markdown files are watched for changes.
9
+ # A file belongs to the project of the nearest .raindown folder above it, so projects can sit inside each other.
10
+ module Projects
11
+ # Folders of dependencies, builds and tooling, which aren't searched for projects or watched, nor are hidden folders.
12
+ IGNORED = %w[node_modules vendor tmp pkg build dist coverage].freeze
13
+
14
+ module_function
15
+
16
+ # The roots of the projects in a directory: the project the directory is in, if any, and every project below it.
17
+ def roots(directory)
18
+ directory = File.expand_path(directory)
19
+ roots = [root(directory)].compact
20
+
21
+ Find.find(directory) do |path|
22
+ next unless File.directory?(path)
23
+
24
+ Find.prune if path != directory && ignored?(File.basename(path))
25
+ roots << path if File.directory?(File.join(path, Nodes::FOLDER))
26
+ end
27
+
28
+ roots.uniq
29
+ end
30
+
31
+ # The root of the project a file or directory is in, or nil when it isn't in one.
32
+ def root(path)
33
+ path = File.expand_path(path)
34
+ folder = Nodes.find_folder(File.directory?(path) ? path : File.dirname(path))
35
+ folder && File.dirname(folder)
36
+ end
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)
41
+ files = []
42
+
43
+ Find.find(root) do |path|
44
+ if File.directory?(path)
45
+ Find.prune if path != root && (ignored?(File.basename(path)) || File.directory?(File.join(path, Nodes::FOLDER)))
46
+ elsif markdown?(path, root:)
47
+ files << path
48
+ end
49
+ end
50
+
51
+ files.sort
52
+ end
53
+
54
+ # Whether a file is Markdown in its project, rather than in an ignored or hidden folder like node_modules.
55
+ def markdown?(path, root:)
56
+ return false unless Pages::EXTENSIONS.include?(File.extname(path).delete_prefix('.').downcase)
57
+
58
+ folders = File.dirname(File.expand_path(path)).delete_prefix(root).split('/').reject(&:empty?)
59
+ folders.none? { |folder| ignored?(folder) }
60
+ end
61
+
62
+ # Whether a file is one of a project's own nodes, which load when its watcher starts.
63
+ def node?(path, root:)
64
+ File.expand_path(path).start_with?(File.join(root, Nodes::FOLDER, '')) && %w[.rb .rbx].include?(File.extname(path))
65
+ end
66
+
67
+ def ignored?(folder)
68
+ folder.start_with?('.') || IGNORED.include?(folder)
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'project_worker'
4
+ require_relative 'projects'
5
+
6
+ module Raindown
7
+ # Watches every Raindown project in a directory, like ~/dev, and writes back to each Markdown file as it's saved.
8
+ #
9
+ # Each project's nodes load in a process of its own, forked once the shared code has loaded, so starting a project
10
+ # costs almost nothing and two projects can have nodes of the same name. One listener watches the directory and sends
11
+ # each changed file to its project's process. Without fork, as on Windows, the projects share this process.
12
+ class Supervisor
13
+ # Watching skips these folders as they hold dependencies and builds, which Projects ignores too.
14
+ IGNORE = %r{(?:\A|/)(?:#{Projects::IGNORED.join('|')})/}
15
+
16
+ attr_reader :directory, :workers
17
+
18
+ def initialize(directory:, bare: false, execute: false)
19
+ @directory = File.expand_path(directory)
20
+ @bare = bare
21
+ @execute = execute
22
+ @workers = {}
23
+ end
24
+
25
+ def start
26
+ require 'listen'
27
+ $stdout.sync = true
28
+ preload
29
+
30
+ Projects.roots(directory).each { worker(it) }
31
+ puts "Watching #{workers.size} #{workers.size == 1 ? 'project' : 'projects'} in #{directory} for changes..."
32
+
33
+ trap_interrupt
34
+ listener.start
35
+ sleep
36
+ ensure
37
+ stop
38
+ end
39
+
40
+ # Sends each changed file to its project, starting a worker for a new project, or restarting one when its nodes change.
41
+ def handle(paths)
42
+ paths.each do |path|
43
+ root = Projects.root(path) || next
44
+
45
+ if Projects.node?(path, root:)
46
+ restart(root)
47
+ elsif Projects.markdown?(path, root:)
48
+ worker(root).update(path)
49
+ end
50
+ end
51
+ end
52
+
53
+ def stop
54
+ workers.each_value(&:stop)
55
+ workers.clear
56
+ end
57
+
58
+ private
59
+
60
+ def listener
61
+ Listen.to(directory, ignore: IGNORE) { |modified, added, _removed| handle(modified + added) }
62
+ end
63
+
64
+ def worker(root)
65
+ workers[root] ||= begin
66
+ worker = ProjectWorker.new(root:, name: name(root), bare: @bare, execute: @execute)
67
+ fork? ? ForkedWorker.new(worker).tap(&:start) : worker.tap(&:start)
68
+ end
69
+ end
70
+
71
+ # Without fork the projects share this process, where nodes can't be unloaded.
72
+ def restart(root)
73
+ return warn("[#{name(root)}] Restart raindown to load the node changes") unless fork?
74
+
75
+ worker = workers.delete(root) || return
76
+ worker.stop
77
+ puts "[#{worker.name}] Reloading nodes"
78
+ worker(root)
79
+ end
80
+
81
+ # A project in the directory is named by its path from there, and the project the directory is in by its folder.
82
+ def name(root)
83
+ return File.basename(root) unless root.start_with?(File.join(directory, ''))
84
+
85
+ root.delete_prefix(File.join(directory, ''))
86
+ end
87
+
88
+ # Loaded before forking, so every project's process shares them instead of loading its own.
89
+ def preload
90
+ return unless fork?
91
+
92
+ require 'lownode'
93
+ require 'dirload'
94
+ end
95
+
96
+ def fork?
97
+ Process.respond_to?(:fork)
98
+ end
99
+
100
+ # Ctrl+C reaches every process in the terminal, so the workers stop with the watcher.
101
+ def trap_interrupt
102
+ trap('INT') { exit }
103
+ end
104
+ end
105
+
106
+ # A project's worker in a forked process, which reads the files to update from a pipe until the pipe closes.
107
+ # The process ends with exit! as it's forked mid-call, so exit would run the watcher's ensure and stop its siblings.
108
+ class ForkedWorker
109
+ def initialize(worker)
110
+ @worker = worker
111
+ end
112
+
113
+ def name
114
+ @worker.name
115
+ end
116
+
117
+ def start
118
+ @writer&.close
119
+ reader, @writer = IO.pipe
120
+ @pid = fork do
121
+ @writer.close
122
+ trap('INT') { exit!(0) }
123
+ @worker.start
124
+ while (path = reader.gets)
125
+ @worker.update(path.chomp)
126
+ end
127
+ exit!(0)
128
+ end
129
+ reader.close
130
+ end
131
+
132
+ # A worker that stopped, such as from a crash, starts again for the next file.
133
+ def update(path, restarted: false)
134
+ start unless alive?
135
+ @writer.puts(path)
136
+ @writer.flush
137
+ rescue Errno::EPIPE
138
+ raise if restarted
139
+
140
+ start
141
+ update(path, restarted: true)
142
+ end
143
+
144
+ def stop
145
+ @writer&.close
146
+ Process.kill('TERM', @pid) if alive?
147
+ Process.wait(@pid)
148
+ rescue Errno::ECHILD, Errno::ESRCH
149
+ nil
150
+ end
151
+
152
+ private
153
+
154
+ def alive?
155
+ @pid && Process.waitpid(@pid, Process::WNOHANG).nil?
156
+ rescue Errno::ECHILD
157
+ false
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../actions'
4
+
5
+ module Raindown
6
+ module TUI
7
+ # What a Navigator does to the open file: run an endpoint, open the editor, write back and read it again.
8
+ # Each sets the status, so the Screen shows how it went, and returns what the Screen should do next, if anything.
9
+ module Commands
10
+ private
11
+
12
+ def execute
13
+ directive = selected(:outline)&.target if @focus == :outline
14
+ return error('Select an endpoint to run') unless directive.is_a?(Document::Directive) && directive.endpoint?
15
+
16
+ [:wait, "Running the endpoint on line #{directive.line}...", -> { run_endpoint(directive) }]
17
+ end
18
+
19
+ def run_endpoint(directive)
20
+ changed = Actions.execute(path, directive)
21
+ @outline.read
22
+ info(changed ? "Ran the endpoint on line #{directive.line}" : 'The response is the same')
23
+ rescue StandardError => e
24
+ error(e.message)
25
+ end
26
+
27
+ # In the files pane, the selected file opens at its top, and in the outline, the open file at the selected line.
28
+ def edit
29
+ row = selected
30
+ show(row.target) if @focus == :files && row&.kind == :file
31
+ return error('Open a file to edit') unless path
32
+
33
+ line = @focus == :outline && row ? row.line : 1
34
+ [:run, Actions.edit_command(path, line:)]
35
+ end
36
+
37
+ def write_back
38
+ return error('Open a file to write back') unless path
39
+
40
+ changed = Actions.write_back(path)
41
+ @outline.read
42
+ info(changed ? "Updated #{relative(path)}" : "#{relative(path)} is up to date")
43
+ rescue StandardError => e
44
+ error(e.message)
45
+ end
46
+
47
+ def reload
48
+ @files.load
49
+ @outline&.read if path && File.exist?(path)
50
+ info('Reloaded')
51
+ end
52
+
53
+ # Reads the file again when it's changed, like when it's saved in an editor or a watcher writes back to it.
54
+ def refresh
55
+ @outline.read if @outline&.changed?
56
+ nil
57
+ end
58
+
59
+ def info(message)
60
+ @status = [:info, message]
61
+ nil
62
+ end
63
+
64
+ def error(message)
65
+ @status = [:error, message]
66
+ nil
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../projects'
4
+ require_relative 'row'
5
+
6
+ module Raindown
7
+ module TUI
8
+ # The files pane: a project's Markdown files as a tree, with folders before files, where folders fold.
9
+ class FileTree
10
+ attr_reader :root
11
+
12
+ def initialize(root:)
13
+ @root = root
14
+ @folded = Set.new
15
+ load
16
+ end
17
+
18
+ def load
19
+ @files = Projects.files(root)
20
+ end
21
+
22
+ def rows
23
+ tree = {}
24
+ @files.each { |path| relative(path).split('/').reduce(tree) { |node, part| node[part] ||= {} } }
25
+ tree_rows(tree, '', 0)
26
+ end
27
+
28
+ def toggle(folder)
29
+ @folded.delete?(folder) || @folded.add(folder)
30
+ end
31
+
32
+ def relative(path)
33
+ path.delete_prefix(File.join(root, ''))
34
+ end
35
+
36
+ private
37
+
38
+ def tree_rows(tree, prefix, depth)
39
+ folders, files = tree.partition { |_name, children| children.any? }
40
+
41
+ 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)) }
43
+ end
44
+
45
+ # A folder, and the tree inside it unless it's folded.
46
+ 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)
50
+
51
+ [row, *(tree_rows(children, File.join(prefix, name), depth + 1) unless folded)]
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'curses'
4
+ require 'unicode/display_width'
5
+
6
+ module Raindown
7
+ module TUI
8
+ # Turns curses' keys and clicks into a Navigator's events.
9
+ module Input
10
+ KEYS = {
11
+ Curses::KEY_UP => :up, 'k' => :up, Curses::KEY_DOWN => :down, 'j' => :down,
12
+ Curses::KEY_LEFT => :left, 'h' => :left, Curses::KEY_RIGHT => :right, 'l' => :right,
13
+ "\t" => :tab, Curses::KEY_BTAB => :tab, "\r" => :enter, "\n" => :enter, Curses::KEY_ENTER => :enter,
14
+ ' ' => :toggle, 'x' => :execute, 'e' => :edit, 'w' => :write_back, 'r' => :reload, 'q' => :quit
15
+ }.freeze
16
+
17
+ module_function
18
+
19
+ # A key from getch, which is a string for a printable character and otherwise a number.
20
+ def key_event(key)
21
+ key = key.chr if key.is_a?(Integer) && key < 256
22
+ KEYS[key]
23
+ end
24
+
25
+ # A scroll of the wheel moves the cursor, and a click sends the event of what's under it.
26
+ def mouse_event(event, zones)
27
+ wheel.find { |button, _| event.bstate.anybits?(button) }&.last || click(zones, column: event.x, row: event.y)
28
+ end
29
+
30
+ # Older ncurses has no fifth button, which is the wheel scrolling down.
31
+ def wheel
32
+ { Curses::BUTTON4_PRESSED => :up }.tap do |buttons|
33
+ buttons[Curses::BUTTON5_PRESSED] = :down if Curses.const_defined?(:BUTTON5_PRESSED)
34
+ end
35
+ end
36
+
37
+ # Where each of a Layout's segments with an event is on the screen, as [row, columns, event].
38
+ def zones(lines)
39
+ lines.each_with_index.flat_map do |segments, row|
40
+ column = 0
41
+ segments.filter_map do |segment|
42
+ width = Unicode::DisplayWidth.of(segment.text)
43
+ zone = [row, column...(column + width), segment.event] if segment.event
44
+ column += width
45
+ zone
46
+ end
47
+ end
48
+ end
49
+
50
+ def click(zones, column:, row:)
51
+ zones.find { |zone_row, columns, _event| zone_row == row && columns.cover?(column) }&.last
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'unicode/display_width'
4
+
5
+ module Raindown
6
+ module TUI
7
+ # The screen as lines of styled segments, worked out from a Navigator with no terminal, so the Screen only paints them.
8
+ # A title, the files pane beside the outline, a status line and a bar of buttons:
9
+ # raindown docs guides/setup.md
10
+ # ▾ guides/ │ # Setup
11
+ # setup.md │ toc: [+3]
12
+ # README.md │ ## Install
13
+ # Opened guides/setup.md
14
+ # Tab Pane Enter Open x Run e Edit w Write back r Reload q Quit
15
+ # Each line is exactly the width, in display columns. A segment with an event is clicked to send it.
16
+ class Layout
17
+ Segment = Data.define(:text, :style, :event)
18
+
19
+ BUTTONS = [['Tab', 'Pane', :tab], ['Enter', 'Open', :enter], ['x', 'Run', :execute], ['e', 'Edit', :edit],
20
+ ['w', 'Write back', :write_back], ['r', 'Reload', :reload], ['q', 'Quit', :quit]].freeze
21
+ FILES_WIDTH = (16..32)
22
+ INDENT = ' '
23
+ # The title, status and button lines.
24
+ CHROME_HEIGHT = 3
25
+
26
+ attr_reader :navigator, :width, :height
27
+
28
+ def initialize(navigator, width:, height:)
29
+ @navigator = navigator
30
+ @width = width
31
+ @height = height
32
+ end
33
+
34
+ def lines
35
+ return [status].first(height) if height < CHROME_HEIGHT + 1
36
+
37
+ [title, *panes, status, button_bar]
38
+ end
39
+
40
+ def files_width
41
+ (width / 3).clamp(FILES_WIDTH.begin, [FILES_WIDTH.end, width / 2].min)
42
+ end
43
+
44
+ private
45
+
46
+ def title
47
+ path = navigator.path ? navigator.relative(navigator.path) : 'No Markdown files'
48
+ [segment(" raindown #{File.basename(navigator.root)} #{path}", :title)]
49
+ end
50
+
51
+ def panes
52
+ pane_height = height - CHROME_HEIGHT
53
+ files = navigator.visible(:files, pane_height)
54
+ outline = navigator.visible(:outline, pane_height)
55
+
56
+ Array.new(pane_height) do |index|
57
+ [*cell(:files, *files, index, files_width), Segment.new(text: '│', style: :border, event: nil),
58
+ *cell(:outline, *outline, index, width - files_width - 1)]
59
+ end
60
+ end
61
+
62
+ # A pane's row as its text, then its buttons, or blank below its last row.
63
+ def cell(pane, rows, offset, index, cell_width)
64
+ row = rows[index]
65
+ return [segment('', :blank, width: cell_width)] unless row
66
+
67
+ index += offset
68
+ selected = navigator.cursor(pane) == index
69
+ buttons = row_buttons(row, pane:, index:, selected:, cell_width:)
70
+ text_width = cell_width - display_width(buttons.map(&:text).join)
71
+ text = " #{INDENT * row.depth}#{row.text}"
72
+
73
+ [segment(text, row_style(pane, row, selected), width: text_width, event: [:select, pane, index]), *buttons]
74
+ end
75
+
76
+ # The row's buttons, right of its text, unless they'd take more than half the pane.
77
+ def row_buttons(row, pane:, index:, selected:, cell_width:)
78
+ buttons = row.buttons.map do |label, event|
79
+ Segment.new(text: " #{label}", style: selected ? :selected_button : :button, event: [:press, pane, index, event])
80
+ end
81
+ display_width(buttons.map(&:text).join) > cell_width / 2 ? [] : buttons
82
+ end
83
+
84
+ def row_style(pane, row, selected)
85
+ return row.kind unless selected
86
+
87
+ navigator.focus == pane ? :selected : :cursor
88
+ end
89
+
90
+ def status
91
+ kind, message = navigator.status
92
+ [segment(" #{message}", kind)]
93
+ end
94
+
95
+ # As many buttons as fit, each clicked by its key or its label.
96
+ def button_bar
97
+ segments = []
98
+ used = 0
99
+
100
+ BUTTONS.each do |key, label, event|
101
+ pair = [Segment.new(text: " #{key} ", style: :key, event:), Segment.new(text: "#{label} ", style: :bar, event:)]
102
+ pair_width = display_width(pair.map(&:text).join)
103
+ break if used + pair_width > width
104
+
105
+ segments.concat(pair)
106
+ used += pair_width
107
+ end
108
+
109
+ [*segments, segment('', :bar, width: width - used)]
110
+ end
111
+
112
+ # Text cut or padded to the width, with a cut shown by an ellipsis.
113
+ def segment(text, style, width: self.width, event: nil)
114
+ Segment.new(text: fit(printable(text), width), style:, event:)
115
+ end
116
+
117
+ def fit(text, width)
118
+ return '' if width <= 0
119
+ return text + (' ' * (width - display_width(text))) if display_width(text) <= width
120
+
121
+ cut = +''
122
+ cut_width = 0
123
+ text.each_char do |char|
124
+ break if cut_width + display_width(char) > width - 1
125
+
126
+ cut << char
127
+ cut_width += display_width(char)
128
+ end
129
+ "#{cut}…#{' ' * (width - 1 - cut_width)}"
130
+ end
131
+
132
+ # Tabs and control characters, like in a directive's output, would move the terminal's cursor.
133
+ def printable(text)
134
+ text.gsub("\t", INDENT).gsub(/[[:cntrl:]]/, '')
135
+ end
136
+
137
+ def display_width(text)
138
+ Unicode::DisplayWidth.of(text)
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'file_tree'
4
+ require_relative 'commands'
5
+ require_relative 'outline'
6
+ require_relative 'pane'
7
+
8
+ module Raindown
9
+ module TUI
10
+ # What the terminal UI shows and does, in plain Ruby, with no terminal: a FileTree of the project's Markdown files,
11
+ # beside the open file's Outline. The Screen draws it and sends it events, like :down or
12
+ # [:press, :outline, 3, :execute], and handle returns what the Screen should do next:
13
+ # nil draw again
14
+ # :quit stop
15
+ # [:run, command] leave the screen to run a command, like the editor, then call ran
16
+ # [:wait, message, task] show the message while the task runs, like an endpoint's request
17
+ class Navigator
18
+ include Commands
19
+
20
+ # Keys of the events that move the cursor or the focus, and those that act on what's selected.
21
+ MOVES = { up: -1, down: 1 }.freeze
22
+ PANES = { tab: nil, left: :files, right: :outline }.freeze
23
+ ACTIONS = { execute: :execute, edit: :edit, write_back: :write_back, reload: :reload, tick: :refresh }.freeze
24
+
25
+ attr_reader :focus, :status
26
+
27
+ def initialize(root:, path: nil)
28
+ @files = FileTree.new(root:)
29
+ @focus = :files
30
+ @panes = { files: Pane.new { @files.rows }, outline: Pane.new { @outline ? @outline.rows : [] } }
31
+ @status = [:info, 'Tab switches panes, Enter opens and folds, q quits']
32
+ first = @files.rows.find { it.kind == :file }&.target
33
+ show(path || first) if path || first
34
+ end
35
+
36
+ def handle(event)
37
+ name, *args = event
38
+ return :quit if name == :quit
39
+ return move(MOVES[name]) if MOVES.key?(name)
40
+ return switch(PANES[name] || other_pane) if PANES.key?(name)
41
+ return select(*args) if name == :select
42
+ return press(*args) if name == :press
43
+ return activate(name) if %i[enter toggle].include?(name)
44
+
45
+ send(ACTIONS[name]) if ACTIONS.key?(name)
46
+ end
47
+
48
+ # After the editor closes, the file is written back, like a save while it's watched, and read again.
49
+ def ran
50
+ write_back
51
+ end
52
+
53
+ def root = @files.root
54
+ def path = @outline&.path
55
+ def relative(path) = @files.relative(path)
56
+
57
+ def rows(pane) = @panes[pane].rows
58
+ def cursor(pane) = @panes[pane].cursor
59
+ def visible(pane, height) = @panes[pane].visible(height)
60
+
61
+ private
62
+
63
+ def show(path)
64
+ @outline = Outline.new(File.expand_path(path))
65
+ @panes[:outline].reset
66
+ end
67
+
68
+ def other_pane
69
+ @focus == :files ? :outline : :files
70
+ end
71
+
72
+ def move(by)
73
+ @panes[@focus].move(by)
74
+ nil
75
+ end
76
+
77
+ def switch(pane)
78
+ @focus = pane
79
+ nil
80
+ end
81
+
82
+ def select(pane, index)
83
+ @focus = pane
84
+ @panes[pane].cursor = index
85
+ nil
86
+ end
87
+
88
+ def press(pane, index, event)
89
+ select(pane, index)
90
+ handle(event)
91
+ end
92
+
93
+ def selected(pane = @focus)
94
+ @panes[pane].selected
95
+ end
96
+
97
+ # Enter opens what's selected, or folds it, and Space only folds.
98
+ # A line of output, or a directive without any, opens in the editor.
99
+ def activate(name)
100
+ row = selected
101
+ return edit if name == :enter && editable?(row)
102
+
103
+ case row&.kind
104
+ when :folder then @files.toggle(row.target)
105
+ when :file then open_file(row.target) if name == :enter
106
+ when :heading then @outline.fold(row.target)
107
+ when :directive then @outline.expand(row.target) if row.target.output
108
+ end
109
+ nil
110
+ end
111
+
112
+ def editable?(row)
113
+ row&.kind == :output || (row&.kind == :directive && !row.target.output)
114
+ end
115
+
116
+ def open_file(path)
117
+ show(path)
118
+ @focus = :outline
119
+ info("Opened #{relative(path)}")
120
+ end
121
+ end
122
+ end
123
+ end