canopus 0.4.0 → 0.5.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/CHANGELOG.md +55 -0
- data/README.md +191 -19
- data/assets/keymaps/emacs.jsonc +5 -0
- data/assets/keymaps/jetbrains.jsonc +5 -0
- data/assets/keymaps/sublime.jsonc +5 -0
- data/assets/keymaps/vscode.jsonc +5 -0
- data/docs/debugging.md +143 -0
- data/docs/distribution.md +40 -9
- data/docs/lsp.md +131 -0
- data/docs/performance.md +18 -0
- data/docs/providers.md +36 -0
- data/docs/release-log.md +12 -0
- data/docs/tasks.md +64 -0
- data/docs/testing.md +49 -0
- data/docs/vim.md +3 -0
- data/exe/canopus +4 -1
- data/lib/canopus/buffer.rb +291 -50
- data/lib/canopus/cli.rb +5 -2
- data/lib/canopus/command.rb +13 -4
- data/lib/canopus/controller.rb +183 -40
- data/lib/canopus/debug/breakpoints.rb +550 -0
- data/lib/canopus/debug/configuration.rb +291 -0
- data/lib/canopus/debug/console.rb +243 -0
- data/lib/canopus/debug/panel.rb +487 -0
- data/lib/canopus/debug/session.rb +354 -0
- data/lib/canopus/decoration.rb +4 -2
- data/lib/canopus/denebola_compat.rb +27 -0
- data/lib/canopus/diagnostics.rb +173 -0
- data/lib/canopus/display_map/overlay_map.rb +6 -2
- data/lib/canopus/editor/snippet_expandable.rb +3 -2
- data/lib/canopus/editor.rb +122 -31
- data/lib/canopus/git/state.rb +100 -0
- data/lib/canopus/language/background_analysis.rb +93 -14
- data/lib/canopus/language/document.rb +70 -34
- data/lib/canopus/language/symbol.rb +1 -0
- data/lib/canopus/language/syntax_worker.rb +61 -9
- data/lib/canopus/language.rb +3 -0
- data/lib/canopus/minimap.rb +201 -0
- data/lib/canopus/patch.rb +3 -1
- data/lib/canopus/plugins/api.rb +4 -2
- data/lib/canopus/plugins/isolated_runtime.rb +64 -7
- data/lib/canopus/plugins/registry.rb +114 -3
- data/lib/canopus/plugins.rb +8 -7
- data/lib/canopus/project.rb +15 -6
- data/lib/canopus/provider.rb +192 -0
- data/lib/canopus/regexp_compat.rb +36 -4
- data/lib/canopus/selection.rb +12 -0
- data/lib/canopus/settings/schema.rb +201 -0
- data/lib/canopus/settings.rb +328 -49
- data/lib/canopus/task/configuration.rb +356 -0
- data/lib/canopus/task/problem_matcher.rb +264 -0
- data/lib/canopus/task/runner.rb +314 -0
- data/lib/canopus/test_runner/bounded_ignore.rb +144 -0
- data/lib/canopus/test_runner/discovery.rb +97 -0
- data/lib/canopus/test_runner/execution.rb +194 -0
- data/lib/canopus/test_runner/minitest.rb +116 -0
- data/lib/canopus/test_runner/rspec.rb +103 -0
- data/lib/canopus/test_runner/safe_walker.rb +37 -0
- data/lib/canopus/test_runner.rb +54 -0
- data/lib/canopus/theme/import.rb +131 -0
- data/lib/canopus/theme.rb +28 -4
- data/lib/canopus/version.rb +1 -1
- data/lib/canopus/vim/motionable.rb +1 -1
- data/lib/canopus/vim.rb +10 -16
- data/lib/canopus/workspace/auto_savable.rb +83 -0
- data/lib/canopus/workspace/debug_aware.rb +600 -0
- data/lib/canopus/workspace/edit/plan.rb +2 -2
- data/lib/canopus/workspace/editor_configurable.rb +113 -0
- data/lib/canopus/workspace/file_change_aware.rb +11 -3
- data/lib/canopus/workspace/git_aware.rb +80 -34
- data/lib/canopus/workspace/git_blame.rb +175 -0
- data/lib/canopus/workspace/git_conflict_resolution.rb +410 -0
- data/lib/canopus/workspace/git_diff_view.rb +641 -0
- data/lib/canopus/workspace/git_history.rb +171 -0
- data/lib/canopus/workspace/git_remote.rb +267 -0
- data/lib/canopus/workspace/git_staging.rb +583 -0
- data/lib/canopus/workspace/hierarchy_aware.rb +477 -0
- data/lib/canopus/workspace/keymap_aware.rb +66 -0
- data/lib/canopus/workspace/language_aware.rb +3721 -135
- data/lib/canopus/workspace/language_server_configurable.rb +287 -71
- data/lib/canopus/workspace/persistent_undo.rb +169 -0
- data/lib/canopus/workspace/problems_aware.rb +131 -0
- data/lib/canopus/workspace/project_searchable.rb +88 -11
- data/lib/canopus/workspace/project_tree_editable.rb +3 -1
- data/lib/canopus/workspace/recoverable.rb +204 -0
- data/lib/canopus/workspace/session_persistable.rb +85 -13
- data/lib/canopus/workspace/settings_aware.rb +76 -2
- data/lib/canopus/workspace/task_aware.rb +194 -0
- data/lib/canopus/workspace/test_aware.rb +394 -0
- data/lib/canopus/workspace/trust.rb +73 -0
- data/lib/canopus/workspace/view/terminal_presentable.rb +298 -47
- data/lib/canopus/workspace/view.rb +399 -74
- data/lib/canopus/workspace.rb +688 -44
- data/lib/canopus.rb +20 -2
- data/sig/canopus.rbs +138 -31
- data/sig/controller.rbs +10 -3
- data/sig/debug.rbs +121 -0
- data/sig/decoration.rbs +12 -2
- data/sig/diagnostics.rbs +26 -0
- data/sig/minimap.rbs +20 -0
- data/sig/provider.rbs +29 -0
- data/sig/services.rbs +1 -16
- data/sig/task.rbs +92 -0
- data/sig/test_runner.rbs +109 -0
- data/sig/trust.rbs +17 -0
- data/sig/workspace_edits.rbs +2 -2
- data/sig/workspace_services.rbs +234 -2
- data/tools/check_dependencies.rb +21 -2
- data/tools/package.rb +172 -14
- data/tools/package_signature_test.rb +33 -0
- data/tools/package_test.rb +29 -0
- data/tools/sign_package.rb +123 -0
- data/tools/update.rb +189 -0
- data/tools/update_test.rb +71 -0
- metadata +206 -31
- data/lib/canopus/lsp/client.rb +0 -307
- data/lib/canopus/lsp/error.rb +0 -7
- data/lib/canopus/lsp/future/subscription.rb +0 -9
- data/lib/canopus/lsp/future.rb +0 -87
- data/lib/canopus/lsp/protocol.rb +0 -83
- data/lib/canopus/lsp/server_error.rb +0 -13
- data/lib/canopus/lsp/timeout.rb +0 -7
- data/lib/canopus/lsp/transport.rb +0 -123
- data/lib/canopus/lsp.rb +0 -19
- data/lib/canopus/project/search.rb +0 -164
- data/lib/canopus/project/search_worker/cancelled.rb +0 -3
- data/lib/canopus/project/search_worker/runner.rb +0 -9
- data/lib/canopus/project/search_worker.rb +0 -108
- data/lib/canopus/terminal/cell.rb +0 -7
- data/lib/canopus/terminal/grid.rb +0 -321
- data/lib/canopus/terminal/pty.rb +0 -178
- data/lib/canopus/terminal/scrollback.rb +0 -38
- data/lib/canopus/terminal/vt.rb +0 -398
- data/lib/canopus/terminal.rb +0 -13
- data/sig/lsp.rbs +0 -99
- data/sig/terminal.rbs +0 -130
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zaniah/ui"
|
|
4
|
+
|
|
5
|
+
module Canopus
|
|
6
|
+
module Workspace::ProblemsAware
|
|
7
|
+
PROBLEM_LABEL_LIMIT = 200
|
|
8
|
+
PROBLEM_FILTER_LIMIT = 4_096
|
|
9
|
+
|
|
10
|
+
def problems_tree
|
|
11
|
+
@problems_tree ||= Zaniah::UI::TreeView.new(problem_nodes, height: 320).on_select do |value, _event, _context|
|
|
12
|
+
select_problem(value)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def filter_problems(severity: nil, source: nil, text: nil)
|
|
17
|
+
severity = Diagnostics::SEVERITIES.fetch(severity, severity) if severity.is_a?(Symbol)
|
|
18
|
+
unless severity.nil? || severity.is_a?(Integer) && severity.between?(1, 4)
|
|
19
|
+
raise ArgumentError, "invalid problem severity"
|
|
20
|
+
end
|
|
21
|
+
unless source.nil? || Diagnostics::SOURCES.include?(source)
|
|
22
|
+
raise ArgumentError, "invalid problem source"
|
|
23
|
+
end
|
|
24
|
+
unless text.nil? || text.is_a?(String) && text.encoding == Encoding::UTF_8 && text.valid_encoding? &&
|
|
25
|
+
text.bytesize <= PROBLEM_FILTER_LIMIT && !text.include?("\0")
|
|
26
|
+
raise ArgumentError, "invalid problem text"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
@problem_filter = {severity: severity, source: source, text: text&.downcase}.freeze
|
|
30
|
+
refresh_problems
|
|
31
|
+
@problem_filter
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def show_problem_filter
|
|
35
|
+
self.palette = {kind: :problem_filter, query: +(@problem_filter_query || ""), index: 0, matches: []}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def apply_problem_filter(query)
|
|
41
|
+
unless query.is_a?(String) && query.encoding == Encoding::UTF_8 && query.valid_encoding? &&
|
|
42
|
+
query.bytesize <= PROBLEM_FILTER_LIMIT && !query.include?("\0")
|
|
43
|
+
raise ArgumentError, "invalid problem filter"
|
|
44
|
+
end
|
|
45
|
+
severity = source = nil
|
|
46
|
+
text = query.gsub(/(?:\A|\s+)(severity|source):(\S+)/i) do
|
|
47
|
+
key, value = Regexp.last_match(1).downcase, Regexp.last_match(2).downcase
|
|
48
|
+
if key == "severity"
|
|
49
|
+
severity = value == "all" ? nil : Diagnostics::SEVERITIES.find { |name, _| name.to_s == value }&.last
|
|
50
|
+
raise ArgumentError, "invalid problem severity" if value != "all" && !severity
|
|
51
|
+
else
|
|
52
|
+
source = value == "all" ? nil : Diagnostics::SOURCES.find { |name| name.to_s == value }
|
|
53
|
+
raise ArgumentError, "invalid problem source" if value != "all" && !source
|
|
54
|
+
end
|
|
55
|
+
" "
|
|
56
|
+
end.gsub(/\s+/, " ").strip
|
|
57
|
+
@problem_filter_query = query.dup.freeze
|
|
58
|
+
filter_problems(severity: severity, source: source, text: text)
|
|
59
|
+
@panels.show("problems")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def diagnostics_changed(uri)
|
|
63
|
+
uris = uri.is_a?(Array) ? uri : [uri]
|
|
64
|
+
targets = uris.each_with_object({}) do |value, result|
|
|
65
|
+
Sadr::Protocol.path(value)
|
|
66
|
+
result[value] = true
|
|
67
|
+
end
|
|
68
|
+
@buffers.each_value.uniq.each do |buffer|
|
|
69
|
+
invalidate_diagnostics(buffer) if buffer.path && targets.key?(Sadr::Protocol.uri(buffer.path))
|
|
70
|
+
end
|
|
71
|
+
refresh_problems
|
|
72
|
+
rescue Sadr::Error
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def refresh_problems
|
|
77
|
+
@problems_tree&.replace(problem_nodes)
|
|
78
|
+
count = @diagnostics.counts.values.sum
|
|
79
|
+
@panels.badge("problems", count.zero? ? nil : count) if @panels&.key?("problems")
|
|
80
|
+
@window&.request_frame
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def problem_nodes
|
|
85
|
+
filter = @problem_filter || {}
|
|
86
|
+
entries = @diagnostics.all(severity: filter[:severity], source: filter[:source])
|
|
87
|
+
if (text = filter[:text]) && !text.empty?
|
|
88
|
+
entries = entries.select { |entry| entry.diagnostic.fetch("message").downcase.include?(text) }
|
|
89
|
+
end
|
|
90
|
+
entries.group_by(&:uri).map do |uri, problems|
|
|
91
|
+
{id: [:problem_file, uri].freeze, label: problem_path(uri), value: nil,
|
|
92
|
+
children: problems.map do |entry|
|
|
93
|
+
diagnostic = entry.diagnostic
|
|
94
|
+
severity = Diagnostics::SEVERITIES.key(diagnostic.fetch("severity", 1))
|
|
95
|
+
line = diagnostic.dig("range", "start", "line") + 1
|
|
96
|
+
message = diagnostic.fetch("message").gsub(/\s+/, " ").strip
|
|
97
|
+
label = "#{severity} [#{entry.source}] #{line}: #{message}"
|
|
98
|
+
label = label.each_char.first(PROBLEM_LABEL_LIMIT - 1).join + "…" if label.length > PROBLEM_LABEL_LIMIT
|
|
99
|
+
{id: [:problem, entry.source, uri, diagnostic.object_id].freeze,
|
|
100
|
+
label: label.freeze, value: entry}.freeze
|
|
101
|
+
end.freeze}.freeze
|
|
102
|
+
end.freeze
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def problem_path(uri)
|
|
106
|
+
path = Sadr::Protocol.path(uri)
|
|
107
|
+
prefix = @root + File::SEPARATOR
|
|
108
|
+
label = path.start_with?(prefix) ? path.delete_prefix(prefix) : path
|
|
109
|
+
label.length > PROBLEM_LABEL_LIMIT ? label.each_char.first(PROBLEM_LABEL_LIMIT - 1).join + "…" : label
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def select_problem(entry)
|
|
113
|
+
return false unless entry.is_a?(Diagnostics::Entry)
|
|
114
|
+
|
|
115
|
+
path = Sadr::Protocol.path(entry.uri)
|
|
116
|
+
raise Error, "Problem target is not a file" unless File.file?(path)
|
|
117
|
+
target = open(path)
|
|
118
|
+
position = Sadr::Protocol.range_value(entry.diagnostic.fetch("range")).start
|
|
119
|
+
offset = Sadr::Protocol.offset(target.buffer.rope, position)
|
|
120
|
+
unless Sadr::Protocol.position(target.buffer.rope, offset) == position
|
|
121
|
+
raise Error, "Problem target position is no longer valid"
|
|
122
|
+
end
|
|
123
|
+
target.select(offset)
|
|
124
|
+
target.reveal_cursor
|
|
125
|
+
true
|
|
126
|
+
rescue Error, KeyError, RangeError, TypeError, SystemCallError, Sadr::Error => error
|
|
127
|
+
@message = "Cannot open problem: #{error.message}"
|
|
128
|
+
false
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -68,8 +68,7 @@ module Canopus
|
|
|
68
68
|
def prepare_project_search(pattern, generation, sources, settings:, paths: nil, async: true)
|
|
69
69
|
work = lambda do
|
|
70
70
|
prepared = nil
|
|
71
|
-
paths ||=
|
|
72
|
-
cancelled: -> { search_cancelled?(generation) }).map(&:path).uniq.freeze
|
|
71
|
+
paths ||= search_project_paths(pattern, generation)
|
|
73
72
|
next if search_cancelled?(generation)
|
|
74
73
|
prepared = build_search_preparation(pattern, generation, sources, paths, settings)
|
|
75
74
|
next unless prepared
|
|
@@ -95,6 +94,81 @@ module Canopus
|
|
|
95
94
|
async ? @search_job = Thread.new(&work) : work.call
|
|
96
95
|
end
|
|
97
96
|
|
|
97
|
+
def search_project_paths(pattern, generation)
|
|
98
|
+
project_search_matches(pattern, cancelled: -> { search_cancelled?(generation) }).map(&:path).uniq.freeze
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def workspace_symbol_fallback(query, generation, deadline)
|
|
102
|
+
matches = project_search_matches(query, ignore_case: true,
|
|
103
|
+
cancelled: -> { workspace_symbol_cancelled?(generation, deadline) })
|
|
104
|
+
cancelled = -> { workspace_symbol_cancelled?(generation, deadline) }
|
|
105
|
+
lines, paths, symbols = {}.compare_by_identity, {}, []
|
|
106
|
+
matches.each do |match|
|
|
107
|
+
break if cancelled.call
|
|
108
|
+
symbol = fallback_workspace_symbol(match, lines: lines, paths: paths, cancelled: cancelled)
|
|
109
|
+
symbols << symbol if symbol
|
|
110
|
+
end
|
|
111
|
+
symbols.freeze
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def project_search_matches(pattern, ignore_case: false, cancelled:)
|
|
115
|
+
Alkaid::Search.new(@root, pattern: pattern, ignore_case: ignore_case, ignore: @project.ignore_matcher, workers: 4,
|
|
116
|
+
max_file_size: 10 * 1024 * 1024, max_matches: 10_000, hidden: true, cancelled: cancelled).run
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def fallback_workspace_symbol(match, lines: {}.compare_by_identity, paths: {}, cancelled: -> { false })
|
|
120
|
+
absolute = if paths.key?(match.path)
|
|
121
|
+
paths[match.path]
|
|
122
|
+
else
|
|
123
|
+
paths[match.path] = File.realpath(File.join(@root, match.path))
|
|
124
|
+
end
|
|
125
|
+
prefix = @root.end_with?(File::SEPARATOR) ? @root : @root + File::SEPARATOR
|
|
126
|
+
return unless absolute.start_with?(prefix)
|
|
127
|
+
|
|
128
|
+
range = match.ranges.first
|
|
129
|
+
state = lines[match.line] ||= {valid: match.line.valid_encoding?, offset: 0, units: 0}
|
|
130
|
+
return unless state[:valid] && range && range.begin >= 0 && range.end <= match.line.bytesize &&
|
|
131
|
+
match.line_number.positive? && !cancelled.call
|
|
132
|
+
first = workspace_symbol_utf16_column(match.line, range.begin, state, cancelled)
|
|
133
|
+
last = workspace_symbol_utf16_column(match.line, range.end, state, cancelled)
|
|
134
|
+
return unless first && last
|
|
135
|
+
preview = fallback_workspace_symbol_preview(match.line, range)
|
|
136
|
+
return if preview.empty?
|
|
137
|
+
{"name" => preview.freeze, "kind" => 13, "containerName" => match.path.dup.freeze,
|
|
138
|
+
"location" => {"uri" => Sadr::Protocol.uri(absolute).freeze, "range" => {
|
|
139
|
+
"start" => {"line" => match.line_number - 1, "character" => first}.freeze,
|
|
140
|
+
"end" => {"line" => match.line_number - 1, "character" => last}.freeze
|
|
141
|
+
}.freeze}.freeze}.freeze
|
|
142
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP, EncodingError
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def workspace_symbol_utf16_column(line, target, state, cancelled)
|
|
147
|
+
offset, units = target < state[:offset] ? [0, 0] : state.values_at(:offset, :units)
|
|
148
|
+
update = offset == state[:offset]
|
|
149
|
+
while offset < target
|
|
150
|
+
return if cancelled.call
|
|
151
|
+
finish = [offset + Workspace::LanguageAware::WORKSPACE_SYMBOL_UTF16_CHUNK_BYTES, target].min
|
|
152
|
+
if finish < target
|
|
153
|
+
finish -= 1 while finish > offset && line.getbyte(finish)&.between?(0x80, 0xBF)
|
|
154
|
+
end
|
|
155
|
+
return if finish <= offset
|
|
156
|
+
units += line.byteslice(offset, finish - offset).encode(Encoding::UTF_16LE).bytesize / 2
|
|
157
|
+
offset = finish
|
|
158
|
+
end
|
|
159
|
+
state[:offset], state[:units] = offset, units if update
|
|
160
|
+
units
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def fallback_workspace_symbol_preview(line, range)
|
|
164
|
+
first = [range.begin - 96, 0].max
|
|
165
|
+
first += 1 while first < range.begin && line.getbyte(first)&.between?(0x80, 0xBF)
|
|
166
|
+
last = [first + Workspace::LanguageAware::WORKSPACE_SYMBOL_PREVIEW_BYTES, line.bytesize].min
|
|
167
|
+
last -= 1 while last > range.begin && line.getbyte(last)&.between?(0x80, 0xBF)
|
|
168
|
+
preview = line.byteslice(first, last - first).to_s.gsub(/\s+/, " ").strip
|
|
169
|
+
bounded_workspace_symbol_text(preview, Workspace::LanguageAware::WORKSPACE_SYMBOL_PREVIEW_BYTES)
|
|
170
|
+
end
|
|
171
|
+
|
|
98
172
|
def build_search_preparation(pattern, generation, sources, paths, settings)
|
|
99
173
|
prepared = SearchPreparation.new(sources, {}, nil, 0, settings)
|
|
100
174
|
excerpts = []
|
|
@@ -143,15 +217,17 @@ module Canopus
|
|
|
143
217
|
|
|
144
218
|
def search_excerpt_ranges(rope, pattern, limit, generation)
|
|
145
219
|
ranges, count = [], 0
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
220
|
+
Canopus.with_regexp_timeout(pattern) do
|
|
221
|
+
rope.to_s.to_enum(:scan, pattern).each do
|
|
222
|
+
break if search_cancelled?(generation)
|
|
223
|
+
match = Regexp.last_match
|
|
224
|
+
row, last_row = rope.point_at(match.bytebegin(0)).row, rope.point_at(match.byteend(0)).row
|
|
225
|
+
first = rope.line_start([row - 1, 0].max)
|
|
226
|
+
last = last_row + 2 < rope.line_count ? rope.line_start(last_row + 2) : rope.bytesize
|
|
227
|
+
ranges.last && first <= ranges.last.end ? ranges[-1] = ranges.last.begin...[ranges.last.end, last].max : ranges << (first...last)
|
|
228
|
+
count += 1
|
|
229
|
+
break if count >= limit
|
|
230
|
+
end
|
|
155
231
|
end
|
|
156
232
|
[ranges, count]
|
|
157
233
|
end
|
|
@@ -197,6 +273,7 @@ module Canopus
|
|
|
197
273
|
@buffers[projection.object_id] = projection
|
|
198
274
|
@active_pane.editors << prepared.editor
|
|
199
275
|
@active_pane.activate(@active_pane.editors.length - 1)
|
|
276
|
+
invalidate_hidden_selection_ranges
|
|
200
277
|
@message = "#{prepared.count} matches — edit excerpts, then Save to save source files"
|
|
201
278
|
@window&.request_frame
|
|
202
279
|
prepared = nil
|
|
@@ -45,7 +45,9 @@ module Canopus
|
|
|
45
45
|
close_language_documents(buffer)
|
|
46
46
|
rescue StandardError => error
|
|
47
47
|
@message = "Path moved; language server close failed: #{error.message}"
|
|
48
|
-
@opened_lsp_documents&.
|
|
48
|
+
@opened_lsp_documents&.keys&.each do |client, document|
|
|
49
|
+
forget_language_document(client, document) if document.equal?(buffer)
|
|
50
|
+
end
|
|
49
51
|
end
|
|
50
52
|
buffer.relocate(new_path)
|
|
51
53
|
@buffers[new_path] = buffer
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Canopus
|
|
6
|
+
module Workspace::Recoverable
|
|
7
|
+
MAX_DRAFT_BYTES = 10 << 20
|
|
8
|
+
MAX_SNAPSHOT_BYTES = 64 << 20
|
|
9
|
+
SNAPSHOT_OVERHEAD_BYTES = 4 << 20
|
|
10
|
+
JSON_ESCAPE_FACTOR = 6
|
|
11
|
+
|
|
12
|
+
def recovery_directory = File.join(@root, ".canopus", "recovery")
|
|
13
|
+
|
|
14
|
+
def recovery_candidates
|
|
15
|
+
return [] unless @settings["recovery"]["enabled"] && recovery_directory_safe?
|
|
16
|
+
ignored = @ignored_recovery_candidates || []
|
|
17
|
+
Dir.glob(File.join(recovery_directory, "*.json")).reject do |path|
|
|
18
|
+
path == @recovery_path || ignored.include?(path) || recovery_candidate_locked?(path) || !valid_recovery_candidate?(path)
|
|
19
|
+
end.sort_by { |path| -File.mtime(path).to_f }
|
|
20
|
+
rescue SystemCallError
|
|
21
|
+
[]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def offer_recovery
|
|
25
|
+
return false if @palette || !(candidate = recovery_candidates.first)
|
|
26
|
+
time = File.mtime(candidate).getlocal.strftime("%Y-%m-%d %H:%M:%S")
|
|
27
|
+
self.palette = {kind: :recovery, query: "Recover unsaved changes from #{time}?", index: 0,
|
|
28
|
+
matches: ["Restore", "Discard"], candidate: candidate}
|
|
29
|
+
true
|
|
30
|
+
rescue SystemCallError
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def resolve_recovery(choice)
|
|
35
|
+
prompt = @palette
|
|
36
|
+
return false unless prompt&.dig(:kind) == :recovery
|
|
37
|
+
candidate = prompt.fetch(:candidate)
|
|
38
|
+
self.palette = nil
|
|
39
|
+
unless !recovery_candidate_locked?(candidate) && valid_recovery_candidate?(candidate)
|
|
40
|
+
raise Error, "recovery snapshot is no longer safe to read"
|
|
41
|
+
end
|
|
42
|
+
if choice == :restore
|
|
43
|
+
open_paths = @panes.flat_map { |pane| pane.editors.filter_map { |current| current.buffer.path } }.uniq
|
|
44
|
+
restore_session(candidate)
|
|
45
|
+
open_paths.each { |path| open(path) }
|
|
46
|
+
discard_recovery(candidate) if !@settings["recovery"]["enabled"] || poll_recovery(force: true)
|
|
47
|
+
self.message = "Recovered unsaved changes"
|
|
48
|
+
else
|
|
49
|
+
raise Error, "recovery snapshot is no longer safe to discard" unless discard_recovery(candidate)
|
|
50
|
+
self.message = "Discarded recovered changes"
|
|
51
|
+
end
|
|
52
|
+
true
|
|
53
|
+
rescue StandardError => error
|
|
54
|
+
(@ignored_recovery_candidates ||= []) << candidate if candidate
|
|
55
|
+
notify("Recovery ignored: #{error.message}")
|
|
56
|
+
false
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def poll_recovery(now: Process.clock_gettime(Process::CLOCK_MONOTONIC), force: false)
|
|
60
|
+
recovery = @settings["recovery"]
|
|
61
|
+
return false if @closed
|
|
62
|
+
interval = recovery["interval"] / 1000.0
|
|
63
|
+
return false if !force && @last_recovery_poll && now - @last_recovery_poll < interval
|
|
64
|
+
@last_recovery_poll = now
|
|
65
|
+
(@recovery_mutex ||= Mutex.new).synchronize do
|
|
66
|
+
dirty = recovery_buffers.select(&:dirty?)
|
|
67
|
+
eligible = dirty.reject { |buffer| buffer.rope.bytesize > MAX_DRAFT_BYTES }
|
|
68
|
+
state = [recovery["enabled"], *dirty.map { |buffer| [buffer.object_id, buffer.version, buffer.path] }]
|
|
69
|
+
return false if !force && state == @recovery_state
|
|
70
|
+
if !recovery["enabled"] || eligible.empty?
|
|
71
|
+
remove_recovery_snapshot
|
|
72
|
+
notify("Crash recovery skipped #{dirty.length} buffer(s) larger than 10 MiB") if recovery["enabled"] && !dirty.empty?
|
|
73
|
+
@recovery_state = state
|
|
74
|
+
return false
|
|
75
|
+
end
|
|
76
|
+
if recovery_snapshot_too_large?(eligible)
|
|
77
|
+
remove_recovery_snapshot
|
|
78
|
+
notify("Crash recovery drafts exceeded the 64 MiB snapshot budget and were not retained")
|
|
79
|
+
@recovery_state = state
|
|
80
|
+
return false
|
|
81
|
+
end
|
|
82
|
+
ensure_recovery_storage
|
|
83
|
+
save_session(@recovery_path, max_draft_bytes: MAX_DRAFT_BYTES)
|
|
84
|
+
if File.size(@recovery_path) > MAX_SNAPSHOT_BYTES
|
|
85
|
+
remove_recovery_snapshot
|
|
86
|
+
notify("Crash recovery snapshot exceeded 64 MiB and was not retained")
|
|
87
|
+
@recovery_state = state
|
|
88
|
+
return false
|
|
89
|
+
end
|
|
90
|
+
notify("Crash recovery skipped #{dirty.length - eligible.length} buffer(s) larger than 10 MiB") if dirty.length != eligible.length
|
|
91
|
+
@recovery_state = state
|
|
92
|
+
true
|
|
93
|
+
end
|
|
94
|
+
rescue StandardError => error
|
|
95
|
+
notify("Crash recovery failed: #{error.message}")
|
|
96
|
+
false
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def close_recovery(clean: true)
|
|
100
|
+
(@recovery_mutex ||= Mutex.new).synchronize do
|
|
101
|
+
remove_recovery_snapshot if clean
|
|
102
|
+
@recovery_lock&.close
|
|
103
|
+
@recovery_lock = nil
|
|
104
|
+
remove_recovery_lock if clean
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def preserve_recovery! = @preserve_recovery = true
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def recovery_buffers
|
|
113
|
+
@buffers.values.flat_map { |buffer| buffer.is_a?(MultiBuffer) ? buffer.excerpts.map(&:buffer) : buffer }.uniq
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def ensure_recovery_storage
|
|
117
|
+
parent = File.dirname(recovery_directory)
|
|
118
|
+
raise Error, "recovery path is a symbolic link" if File.symlink?(parent) || File.symlink?(recovery_directory)
|
|
119
|
+
FileUtils.mkdir_p(parent)
|
|
120
|
+
begin
|
|
121
|
+
Dir.mkdir(recovery_directory, 0o700) unless File.directory?(recovery_directory)
|
|
122
|
+
rescue Errno::EEXIST
|
|
123
|
+
raise unless File.directory?(recovery_directory)
|
|
124
|
+
end
|
|
125
|
+
File.chmod(0o700, recovery_directory)
|
|
126
|
+
ignore = File.join(recovery_directory, ".gitignore")
|
|
127
|
+
begin
|
|
128
|
+
File.open(ignore, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |file| file.write("*\n") }
|
|
129
|
+
rescue Errno::EEXIST
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
initialize_recovery_lock
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def initialize_recovery_lock
|
|
136
|
+
token = @recovery_token ||= "#{Process.pid}-#{SecureRandom.hex(12)}"
|
|
137
|
+
@recovery_path ||= File.join(recovery_directory, "#{token}.json")
|
|
138
|
+
@recovery_lock_path ||= File.join(recovery_directory, "#{token}.lock")
|
|
139
|
+
return if @recovery_lock
|
|
140
|
+
raise Error, "recovery lock is a symbolic link" if File.symlink?(@recovery_lock_path)
|
|
141
|
+
flags = File::RDWR | File::CREAT
|
|
142
|
+
flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
|
|
143
|
+
@recovery_lock = File.open(@recovery_lock_path, flags, 0o600)
|
|
144
|
+
raise Error, "recovery lock is already held" unless @recovery_lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def valid_recovery_candidate?(path)
|
|
148
|
+
return false unless recovery_path_safe?(path) && File.file?(path) && File.size(path) <= MAX_SNAPSHOT_BYTES
|
|
149
|
+
data = JSON.parse(File.binread(path))
|
|
150
|
+
buffers = data["buffers"]
|
|
151
|
+
[1, 2].include?(data["version"]) && data["root"] == @root && buffers.is_a?(Hash) &&
|
|
152
|
+
buffers.length <= 10_000 && buffers.any? { |_, value| value.is_a?(Hash) && value["draft"].is_a?(String) }
|
|
153
|
+
rescue JSON::ParserError, JSON::NestingError, SystemCallError
|
|
154
|
+
false
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def recovery_candidate_locked?(path)
|
|
158
|
+
return true unless recovery_path_safe?(path)
|
|
159
|
+
lock_path = path.sub(/\.json\z/, ".lock")
|
|
160
|
+
return true if File.symlink?(lock_path)
|
|
161
|
+
return false unless File.file?(lock_path)
|
|
162
|
+
File.open(lock_path, File::RDWR) do |lock|
|
|
163
|
+
acquired = lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
164
|
+
lock.flock(File::LOCK_UN) if acquired
|
|
165
|
+
!acquired
|
|
166
|
+
end
|
|
167
|
+
rescue Errno::EACCES, Errno::EAGAIN
|
|
168
|
+
true
|
|
169
|
+
rescue SystemCallError
|
|
170
|
+
false
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def discard_recovery(path)
|
|
174
|
+
return false unless recovery_path_safe?(path) && !recovery_candidate_locked?(path)
|
|
175
|
+
lock_path = path.sub(/\.json\z/, ".lock")
|
|
176
|
+
return false if File.symlink?(lock_path)
|
|
177
|
+
File.delete(path) if File.file?(path)
|
|
178
|
+
File.delete(lock_path) if File.file?(lock_path)
|
|
179
|
+
true
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def recovery_snapshot_too_large?(buffers)
|
|
183
|
+
SNAPSHOT_OVERHEAD_BYTES + buffers.sum { |buffer| buffer.rope.bytesize * JSON_ESCAPE_FACTOR } > MAX_SNAPSHOT_BYTES
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def remove_recovery_snapshot
|
|
187
|
+
File.delete(@recovery_path) if @recovery_path && recovery_path_safe?(@recovery_path) && File.file?(@recovery_path)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def remove_recovery_lock
|
|
191
|
+
return unless @recovery_lock_path && recovery_directory_safe? && File.dirname(@recovery_lock_path) == recovery_directory
|
|
192
|
+
File.delete(@recovery_lock_path) if !File.symlink?(@recovery_lock_path) && File.file?(@recovery_lock_path)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def recovery_path_safe?(path)
|
|
196
|
+
recovery_directory_safe? && File.dirname(File.expand_path(path)) == recovery_directory && !File.symlink?(path)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def recovery_directory_safe?
|
|
200
|
+
parent = File.dirname(recovery_directory)
|
|
201
|
+
!File.symlink?(parent) && !File.symlink?(recovery_directory) && File.directory?(recovery_directory)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
@@ -2,36 +2,53 @@
|
|
|
2
2
|
|
|
3
3
|
module Canopus
|
|
4
4
|
module Workspace::SessionPersistable
|
|
5
|
-
def save_session(path)
|
|
5
|
+
def save_session(path, max_draft_bytes: nil)
|
|
6
6
|
records = {}
|
|
7
7
|
record = lambda do |buffer|
|
|
8
8
|
id = buffer.object_id.to_s
|
|
9
9
|
return id if records.key?(id)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
entry = if buffer.is_a?(MultiBuffer)
|
|
11
|
+
excerpts = buffer.excerpts.filter_map do |excerpt|
|
|
12
|
+
source = record.call(excerpt.buffer)
|
|
13
|
+
next unless source
|
|
14
|
+
{buffer: source, first: excerpt.buffer.resolve(excerpt.first),
|
|
13
15
|
last: excerpt.buffer.resolve(excerpt.last), title: excerpt.title}
|
|
14
|
-
end
|
|
16
|
+
end
|
|
17
|
+
next if excerpts.length != buffer.excerpts.length
|
|
18
|
+
{excerpts: excerpts}
|
|
15
19
|
else
|
|
20
|
+
next if max_draft_bytes && (buffer.dirty? || !buffer.path) && buffer.rope.bytesize > max_draft_bytes
|
|
16
21
|
{path: buffer.path, draft: buffer.dirty? || !buffer.path ? buffer.text : nil,
|
|
17
22
|
digest: buffer.disk_digest, encoding: buffer.encoding.name, bom: buffer.bom.unpack1("H*"), read_only: buffer.read_only}
|
|
18
23
|
end
|
|
24
|
+
records[id] = entry
|
|
19
25
|
id
|
|
20
26
|
end
|
|
21
27
|
state = {version: 2, root: @root, layout: encode_layout(@layout), recent_files: @recent_files || [],
|
|
22
28
|
active_pane: @panes.index(@active_pane), docks: @docks, panels: @panels.state,
|
|
23
|
-
terminals: @terminals.map
|
|
24
|
-
|
|
29
|
+
terminals: @terminals.map do |current|
|
|
30
|
+
cwd = current.respond_to?(:cwd) ? current.cwd : current.vt.cwd
|
|
31
|
+
{cwd: cwd || (current.respond_to?(:initial_cwd) ? current.initial_cwd : @root), title: @terminal_names[current],
|
|
32
|
+
profile: terminal_profile(current)}
|
|
33
|
+
end,
|
|
34
|
+
active_terminal: @active_terminal_index, terminal_layout: encode_terminal_layout(@terminal_layout),
|
|
35
|
+
terminal_visible: terminal_visible,
|
|
25
36
|
panes: @panes.map do |pane|
|
|
26
|
-
{active: pane.active_index, tabs: pane.editors.
|
|
27
|
-
|
|
37
|
+
{active: pane.active_index, tabs: pane.editors.filter_map do |current|
|
|
38
|
+
buffer_id = record.call(current.buffer)
|
|
39
|
+
next unless buffer_id
|
|
40
|
+
{buffer_id: buffer_id, cursor: current.primary.head, pinned: pane.pinned.include?(current),
|
|
28
41
|
selections: current.selections.map { |selection| {anchor: selection.anchor, head: selection.head} },
|
|
29
42
|
scroll_x: current.scroll_x, scroll_y: current.scroll_y}
|
|
30
43
|
end}
|
|
31
44
|
end, buffers: records}
|
|
32
|
-
state[:background_buffers] = @buffers.values.select(&:dirty?).
|
|
45
|
+
state[:background_buffers] = @buffers.values.select(&:dirty?).filter_map { |buffer| record.call(buffer) }
|
|
46
|
+
if max_draft_bytes && state[:panes][state[:active_pane]][:tabs].empty?
|
|
47
|
+
state[:active_pane] = state[:panes].index { |pane| !pane[:tabs].empty? } || state[:active_pane]
|
|
48
|
+
end
|
|
33
49
|
FileUtils.mkdir_p(File.dirname(path))
|
|
34
50
|
Tempfile.create([".session-", ".json"], File.dirname(path)) do |file|
|
|
51
|
+
file.chmod(0o600)
|
|
35
52
|
file.write(JSON.pretty_generate(state))
|
|
36
53
|
file.flush
|
|
37
54
|
file.fsync
|
|
@@ -69,6 +86,7 @@ module Canopus
|
|
|
69
86
|
elsif entry["path"] && File.file?(entry["path"])
|
|
70
87
|
buffer = Buffer.open(entry["path"])
|
|
71
88
|
end
|
|
89
|
+
load_persistent_undo(buffer) if buffer && buffer.path && !buffer.dirty?
|
|
72
90
|
loaded[id] = buffer
|
|
73
91
|
if buffer
|
|
74
92
|
key = buffer.path ? canonical_path(buffer.path) : buffer.object_id
|
|
@@ -146,10 +164,13 @@ module Canopus
|
|
|
146
164
|
terminal_records = validate_session_terminals(data) if @settings["terminal"]["restore_on_startup"]
|
|
147
165
|
clear_vim_states
|
|
148
166
|
close_language_documents
|
|
167
|
+
@buffers.each_value { |buffer| detach_breakpoints(buffer) }
|
|
149
168
|
@panes.each { |pane| pane.editors.each(&:dispose) }
|
|
150
169
|
@buffers.each_value(&:close)
|
|
151
170
|
@panels.restore(restored_panels, docks: restored_docks)
|
|
171
|
+
@panels.hide("hierarchy") if @panels.key?("hierarchy")
|
|
152
172
|
@panes, @buffers, @layout = restored_panes, restored_buffers, restored_layout
|
|
173
|
+
@buffers.each_value { |buffer| attach_breakpoints(buffer) unless buffer.is_a?(MultiBuffer) }
|
|
153
174
|
@active_pane = @panes[active]
|
|
154
175
|
@recent_files = Array(data["recent_files"]).select { |item| item.is_a?(String) && File.file?(item) }.first(100)
|
|
155
176
|
restore_terminals(data, terminal_records) if terminal_records
|
|
@@ -166,29 +187,80 @@ module Canopus
|
|
|
166
187
|
|
|
167
188
|
def validate_session_terminals(data)
|
|
168
189
|
records = data.fetch("terminals", [])
|
|
169
|
-
|
|
190
|
+
valid = records.is_a?(Array) && records.length <= 100 && records.all? do |item|
|
|
191
|
+
item.is_a?(Hash) && item["cwd"].is_a?(String) && (item["profile"].nil? || item["profile"].is_a?(String))
|
|
192
|
+
end
|
|
193
|
+
raise Error, "invalid session terminals" unless valid
|
|
170
194
|
active = data.fetch("active_terminal", 0)
|
|
171
195
|
raise Error, "invalid active terminal" unless records.empty? || active.is_a?(Integer) && active.between?(0, records.length - 1)
|
|
196
|
+
if data["terminal_layout"]
|
|
197
|
+
visible = []
|
|
198
|
+
validate_terminal_layout(data["terminal_layout"], records.length, visible)
|
|
199
|
+
raise Error, "active terminal missing from layout" unless visible.include?(active)
|
|
200
|
+
end
|
|
172
201
|
records
|
|
173
202
|
end
|
|
174
203
|
|
|
175
204
|
def restore_terminals(data, records)
|
|
176
|
-
old, old_index, old_visible = @terminals, @active_terminal_index, terminal_visible
|
|
205
|
+
old, old_index, old_visible, old_layout = @terminals, @active_terminal_index, terminal_visible, @terminal_layout
|
|
206
|
+
old_profiles = @terminal_profiles
|
|
177
207
|
@terminals = []
|
|
208
|
+
@terminal_profiles = {}.compare_by_identity
|
|
209
|
+
@terminal_layout = nil
|
|
178
210
|
records.each do |record|
|
|
179
211
|
cwd = File.directory?(record["cwd"]) ? record["cwd"] : @root
|
|
180
|
-
|
|
212
|
+
profile = record["profile"] if @settings["terminal"]["profiles"].key?(record["profile"])
|
|
213
|
+
created = new_terminal(cwd: cwd, profile: profile)
|
|
181
214
|
rename_terminal(record["title"], created) if record["title"].is_a?(String)
|
|
182
215
|
end
|
|
183
216
|
@active_terminal_index = @terminals.empty? ? 0 : data.fetch("active_terminal", 0)
|
|
217
|
+
@terminal_layout = if @terminals.empty?
|
|
218
|
+
nil
|
|
219
|
+
elsif data["terminal_layout"]
|
|
220
|
+
decode_terminal_layout(data["terminal_layout"], @terminals)
|
|
221
|
+
else
|
|
222
|
+
{terminal: terminal}
|
|
223
|
+
end
|
|
224
|
+
invalidate_terminal_layout_resize
|
|
184
225
|
self.terminal_visible = !!data["terminal_visible"] && !@terminals.empty?
|
|
185
226
|
old.each { |current| current.close if current.respond_to?(:close) }
|
|
186
227
|
rescue StandardError
|
|
187
228
|
@terminals.each { |current| current.close if current.respond_to?(:close) }
|
|
188
229
|
@terminals = old
|
|
189
230
|
@active_terminal_index = old_index
|
|
231
|
+
@terminal_profiles = old_profiles
|
|
232
|
+
@terminal_layout = old_layout
|
|
190
233
|
self.terminal_visible = old_visible
|
|
191
234
|
@message = "Session restored; terminals could not start"
|
|
192
235
|
end
|
|
236
|
+
|
|
237
|
+
def encode_terminal_layout(node)
|
|
238
|
+
return unless node
|
|
239
|
+
return {terminal: @terminals.index(node[:terminal])} if node[:terminal]
|
|
240
|
+
{direction: node[:direction], ratio: node.fetch(:ratio, 0.5),
|
|
241
|
+
children: node[:children].map { |child| encode_terminal_layout(child) }}
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def validate_terminal_layout(node, count, seen = [], depth = 0)
|
|
245
|
+
raise Error, "invalid terminal layout" unless node.is_a?(Hash) && depth < 100
|
|
246
|
+
if node.key?("terminal")
|
|
247
|
+
index = node["terminal"]
|
|
248
|
+
raise Error, "invalid terminal layout" unless index.is_a?(Integer) && index.between?(0, count - 1) && !seen.include?(index)
|
|
249
|
+
seen << index
|
|
250
|
+
return true
|
|
251
|
+
end
|
|
252
|
+
direction, ratio, children = node.values_at("direction", "ratio", "children")
|
|
253
|
+
valid = %w[horizontal vertical].include?(direction) && ratio.is_a?(Numeric) && ratio.finite? &&
|
|
254
|
+
ratio.between?(0.1, 0.9) && children.is_a?(Array) && children.length == 2
|
|
255
|
+
raise Error, "invalid terminal layout" unless valid
|
|
256
|
+
children.each { |child| validate_terminal_layout(child, count, seen, depth + 1) }
|
|
257
|
+
true
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def decode_terminal_layout(node, terminals)
|
|
261
|
+
return {terminal: terminals.fetch(node.fetch("terminal"))} if node.key?("terminal")
|
|
262
|
+
{direction: node.fetch("direction").to_sym, ratio: node.fetch("ratio"),
|
|
263
|
+
children: node.fetch("children").map { |child| decode_terminal_layout(child, terminals) }}
|
|
264
|
+
end
|
|
193
265
|
end
|
|
194
266
|
end
|