canopus 0.3.0 → 0.4.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 +8 -0
- data/README.md +8 -2
- data/docs/adr/008-component-domain-boundaries.md +26 -0
- data/docs/adr/009-decoration-overlay-path.md +26 -0
- data/docs/adr/010-overlay-aware-display-map.md +32 -0
- data/lib/canopus/command.rb +109 -0
- data/lib/canopus/controller.rb +30 -39
- data/lib/canopus/decoration.rb +132 -0
- data/lib/canopus/display_map/line_builder.rb +25 -5
- data/lib/canopus/display_map/overlay_map.rb +123 -0
- data/lib/canopus/display_map.rb +25 -4
- data/lib/canopus/lsp/client.rb +19 -11
- data/lib/canopus/panel.rb +104 -0
- data/lib/canopus/settings.rb +29 -5
- data/lib/canopus/tab_map.rb +8 -3
- data/lib/canopus/version.rb +1 -1
- data/lib/canopus/workspace/git_aware.rb +17 -1
- data/lib/canopus/workspace/session_persistable.rb +16 -6
- data/lib/canopus/workspace/view.rb +177 -51
- data/lib/canopus/workspace.rb +90 -23
- data/lib/canopus/wrap_map.rb +27 -1
- data/lib/canopus.rb +3 -0
- data/sig/canopus.rbs +7 -5
- data/sig/command.rbs +30 -0
- data/sig/controller.rbs +0 -2
- data/sig/decoration.rbs +26 -0
- data/sig/display_stages.rbs +28 -2
- data/sig/panel.rbs +35 -0
- data/sig/workspace_services.rbs +5 -2
- metadata +15 -5
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Canopus::DisplayMap::OverlayMap
|
|
4
|
+
Inline = Data.define(:item, :offset, :width, :cell_width, :height, :align)
|
|
5
|
+
Block = Data.define(:item, :row, :height, :row_span, :position)
|
|
6
|
+
|
|
7
|
+
attr_reader :line_height
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@inlines, @inline_values, @blocks, @line_height, @input = {}.freeze, [].freeze, {}.freeze, 1, nil
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def replace(items, rope:, font: nil, font_size: 14, line_height: 20)
|
|
14
|
+
raise ArgumentError, "decorations must be an array" unless items.is_a?(Array)
|
|
15
|
+
validate_size(font_size, "font size")
|
|
16
|
+
validate_size(line_height, "line height")
|
|
17
|
+
input = [items, font&.object_id, font_size, line_height]
|
|
18
|
+
return [] if @input == input
|
|
19
|
+
|
|
20
|
+
inlines, blocks = Hash.new { |hash, key| hash[key] = [] }, Hash.new { |hash, key| hash[key] = [] }
|
|
21
|
+
items.each do |item|
|
|
22
|
+
raise TypeError, "overlays must be decoration items" unless item.is_a?(Canopus::Decoration::Item)
|
|
23
|
+
next unless %i[inline block].include?(item.kind)
|
|
24
|
+
|
|
25
|
+
if item.kind == :inline
|
|
26
|
+
offset = item.range&.begin
|
|
27
|
+
raise ArgumentError, "inline decoration requires a range" unless offset.is_a?(Integer)
|
|
28
|
+
raise RangeError, "inline decoration is outside the buffer" unless offset.between?(0, rope.bytesize)
|
|
29
|
+
row = rope.point_at(offset).row
|
|
30
|
+
local = offset - rope.line_start(row)
|
|
31
|
+
unless Zaniah::Unicode.grapheme_boundary?(rope.line(row), local)
|
|
32
|
+
raise ArgumentError, "inline decoration splits a grapheme cluster"
|
|
33
|
+
end
|
|
34
|
+
style = item.style.is_a?(Hash) ? item.style : {}
|
|
35
|
+
align = style.fetch(:align, :after)
|
|
36
|
+
raise ArgumentError, "inline alignment must be before or after" unless %i[before after].include?(align)
|
|
37
|
+
text = item.content.is_a?(String) ? item.content : item.content.respond_to?(:text) ? item.content.text.to_s : item.content.to_s
|
|
38
|
+
text = text.encode(Encoding::UTF_8)
|
|
39
|
+
padding_left = numeric_style(style, :padding_left)
|
|
40
|
+
padding_right = numeric_style(style, :padding_right)
|
|
41
|
+
validate_size(padding_left, "inline left padding", allow_zero: true)
|
|
42
|
+
validate_size(padding_right, "inline right padding", allow_zero: true)
|
|
43
|
+
padding = padding_left + padding_right
|
|
44
|
+
width = numeric_style(style, :width, nil) || (font ? font.advance_width(text.codepoints, size: font_size) : Zaniah::Unicode.width(text) * font_size * 0.6)
|
|
45
|
+
width += padding
|
|
46
|
+
height = numeric_style(style, :height, line_height)
|
|
47
|
+
validate_size(width, "inline width")
|
|
48
|
+
validate_size(height, "inline height")
|
|
49
|
+
raise ArgumentError, "inline height must not exceed line height" if height > line_height
|
|
50
|
+
cells = numeric_style(style, :cells, Zaniah::Unicode.width(text))
|
|
51
|
+
validate_size(cells, "inline cell width", allow_zero: true)
|
|
52
|
+
inlines[row] << Inline.new(item, offset, width.to_f, cells.to_f, height.to_f, align)
|
|
53
|
+
else
|
|
54
|
+
row = item.row
|
|
55
|
+
raise ArgumentError, "block decoration requires a row" unless row.is_a?(Integer)
|
|
56
|
+
raise RangeError, "block decoration is outside the buffer" unless row.between?(0, rope.line_count - 1)
|
|
57
|
+
style = item.style.is_a?(Hash) ? item.style : {}
|
|
58
|
+
position = style.fetch(:position, :above)
|
|
59
|
+
raise ArgumentError, "block position must be above or below" unless %i[above below].include?(position)
|
|
60
|
+
height = numeric_style(style, :height, line_height)
|
|
61
|
+
validate_size(height, "block height")
|
|
62
|
+
blocks[row] << Block.new(item, row, height.to_f, (height.to_f / line_height).ceil, position)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
inlines.each_value { |values| values.sort_by! { |value| [value.offset, value.item.priority] }.freeze }
|
|
66
|
+
blocks.each_value { |values| values.sort_by! { |value| value.item.priority }.freeze }
|
|
67
|
+
inlines, blocks = inlines.to_h.freeze, blocks.to_h.freeze
|
|
68
|
+
inline_values = inlines.values.flatten.sort_by(&:offset).freeze
|
|
69
|
+
return [] if @inlines == inlines && @blocks == blocks && @line_height == line_height
|
|
70
|
+
|
|
71
|
+
affected = (@inlines.keys | @blocks.keys | inlines.keys | blocks.keys).select do |row|
|
|
72
|
+
@inlines.fetch(row, nil) != inlines.fetch(row, nil) ||
|
|
73
|
+
@blocks.fetch(row, nil) != blocks.fetch(row, nil) || @line_height != line_height
|
|
74
|
+
end
|
|
75
|
+
@inlines, @inline_values, @blocks, @line_height, @input = inlines, inline_values, blocks, line_height, input
|
|
76
|
+
affected.sort
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def transform(rope, row, text, offsets)
|
|
80
|
+
start = rope.line_start(row)
|
|
81
|
+
first, last = offsets.first + start, offsets.last + start
|
|
82
|
+
index = @inline_values.bsearch_index { |inline| inline.offset >= first }
|
|
83
|
+
return [] unless index
|
|
84
|
+
values = []
|
|
85
|
+
while (inline = @inline_values[index]) && inline.offset <= last
|
|
86
|
+
values << inline
|
|
87
|
+
index += 1
|
|
88
|
+
end
|
|
89
|
+
boundaries, byte = [0], 0
|
|
90
|
+
text.each_char { |character| boundaries << (byte += character.bytesize) }
|
|
91
|
+
values.filter_map do |inline|
|
|
92
|
+
local = inline.offset - start
|
|
93
|
+
column = offsets.bsearch_index { |offset| offset >= local } || offsets.length - 1
|
|
94
|
+
next unless offsets[column] == local
|
|
95
|
+
inline.with(offset: boundaries.fetch(column))
|
|
96
|
+
end.freeze
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def blocks(row, position) = @blocks.fetch(row, []).select { |block| block.position == position }
|
|
100
|
+
|
|
101
|
+
def snapshot
|
|
102
|
+
copy = self.class.allocate
|
|
103
|
+
copy.instance_variable_set(:@inlines, @inlines)
|
|
104
|
+
copy.instance_variable_set(:@inline_values, @inline_values)
|
|
105
|
+
copy.instance_variable_set(:@blocks, @blocks)
|
|
106
|
+
copy.instance_variable_set(:@line_height, @line_height)
|
|
107
|
+
copy.instance_variable_set(:@input, nil)
|
|
108
|
+
copy.freeze
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def numeric_style(style, key, default = 0)
|
|
114
|
+
value = style.fetch(key, default)
|
|
115
|
+
raise ArgumentError, "#{key} must be numeric" unless value.nil? || value.is_a?(Numeric)
|
|
116
|
+
value
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def validate_size(value, name, allow_zero: false)
|
|
120
|
+
valid = value.is_a?(Numeric) && value.real? && value.finite? && (allow_zero ? value >= 0 : value.positive?)
|
|
121
|
+
raise ArgumentError, "#{name} must be #{allow_zero ? 'nonnegative' : 'positive'} and finite" unless valid
|
|
122
|
+
end
|
|
123
|
+
end
|
data/lib/canopus/display_map.rb
CHANGED
|
@@ -10,14 +10,15 @@ module Canopus
|
|
|
10
10
|
class DisplayMap
|
|
11
11
|
BACKGROUND_THRESHOLD = 1 << 20
|
|
12
12
|
Row = Data.define(:text, :offsets, :kind, :metadata)
|
|
13
|
-
attr_reader :fold_map, :tab_map, :wrap_map, :block_map, :tree, :recomputed_lines
|
|
13
|
+
attr_reader :fold_map, :overlay_map, :tab_map, :wrap_map, :block_map, :tree, :recomputed_lines
|
|
14
14
|
attr_reader :layout_error
|
|
15
15
|
|
|
16
16
|
def initialize(buffer, tab_size: 4, wrap_width: nil, background_threshold: BACKGROUND_THRESHOLD)
|
|
17
17
|
raise ArgumentError, "background threshold must be nonnegative or nil" unless background_threshold.nil? || (background_threshold.is_a?(Integer) && background_threshold >= 0)
|
|
18
18
|
@background_threshold, @generation = background_threshold, 0
|
|
19
19
|
@buffer, @rope = buffer, buffer.rope
|
|
20
|
-
@fold_map, @tab_map, @wrap_map, @block_map = FoldMap.new,
|
|
20
|
+
@fold_map, @overlay_map, @tab_map, @wrap_map, @block_map = FoldMap.new, OverlayMap.new,
|
|
21
|
+
TabMap.new(tab_size: tab_size), WrapMap.new(width: wrap_width), BlockMap.new
|
|
21
22
|
rebuild
|
|
22
23
|
@subscription = buffer.on_edit { |patch| apply(patch) }
|
|
23
24
|
end
|
|
@@ -86,6 +87,17 @@ module Canopus
|
|
|
86
87
|
@tab_map = TabMap.new(tab_size: size)
|
|
87
88
|
rebuild(reuse: true)
|
|
88
89
|
end
|
|
90
|
+
def set_overlays(items, font: nil, font_size: 14, line_height: 20)
|
|
91
|
+
affected = @overlay_map.replace(items, rope: @rope, font: font, font_size: font_size, line_height: line_height)
|
|
92
|
+
return false if affected.empty?
|
|
93
|
+
|
|
94
|
+
@recomputed_lines = 0
|
|
95
|
+
affected.select { |row| row.between?(0, @rope.line_count - 1) }
|
|
96
|
+
.flat_map { |row| first, last = affected_lines(row, row); (first..last).to_a }.uniq.sort.each do |row|
|
|
97
|
+
replace_lines(row, row, row)
|
|
98
|
+
end
|
|
99
|
+
true
|
|
100
|
+
end
|
|
89
101
|
def fold(range)
|
|
90
102
|
raise Error, "folding is disabled for large read-only files" if @lazy
|
|
91
103
|
range = range.begin...(range.end + (range.exclude_end? ? 0 : 1))
|
|
@@ -166,6 +178,14 @@ module Canopus
|
|
|
166
178
|
local = offset - @rope.line_start(source)
|
|
167
179
|
lines = display_lines(source, local: local).rows
|
|
168
180
|
prefix = @tree.prefix_summary(source).display_rows
|
|
181
|
+
lines.each_with_index do |line, i|
|
|
182
|
+
next unless line.kind == :text && line.metadata&.any? do |placement|
|
|
183
|
+
placement.align == :before && placement.item.range.begin == offset
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
column = line.offsets.bsearch_index { |position| position >= local } || line.offsets.length - 1
|
|
187
|
+
return DisplayPoint.new(prefix + i, column)
|
|
188
|
+
end
|
|
169
189
|
lines.each_with_index do |line, i|
|
|
170
190
|
next unless line.kind == :text
|
|
171
191
|
next if local > line.offsets.last
|
|
@@ -261,7 +281,7 @@ module Canopus
|
|
|
261
281
|
@worker = nil
|
|
262
282
|
return
|
|
263
283
|
end
|
|
264
|
-
@builder = LineBuilder.new(@rope, @fold_map, @tab_map, @wrap_map, @block_map)
|
|
284
|
+
@builder = LineBuilder.new(@rope, @fold_map, @overlay_map, @tab_map, @wrap_map, @block_map)
|
|
265
285
|
@background = background_layout?(@rope)
|
|
266
286
|
unless reuse && @background && @tree && @tree.size == @rope.line_count
|
|
267
287
|
values = @background ? @builder.pending_lines(0, @rope.line_count) : Array.new(@rope.line_count) { |row| build_line(row) }
|
|
@@ -276,7 +296,7 @@ module Canopus
|
|
|
276
296
|
@pending_result = nil
|
|
277
297
|
@failed = false
|
|
278
298
|
@layout_error = nil
|
|
279
|
-
@builder = LineBuilder.new(@rope, @fold_map, @tab_map, @wrap_map, @block_map)
|
|
299
|
+
@builder = LineBuilder.new(@rope, @fold_map, @overlay_map, @tab_map, @wrap_map, @block_map)
|
|
280
300
|
replacement = @background ? @builder.pending_lines(first, new_last - first + 1) : Array.new(new_last - first + 1) { |i| build_line(first + i) }
|
|
281
301
|
if first < @computed_prefix
|
|
282
302
|
@computed_prefix = old_last + 1 >= @computed_prefix ? first : @computed_prefix + new_last - old_last
|
|
@@ -340,6 +360,7 @@ end
|
|
|
340
360
|
require_relative "display_map/summary"
|
|
341
361
|
require_relative "display_map/line_set"
|
|
342
362
|
require_relative "display_map/pending_line_set"
|
|
363
|
+
require_relative "display_map/overlay_map"
|
|
343
364
|
|
|
344
365
|
Canopus::DisplayMap::EMPTY_LINES = Canopus::DisplayMap::LineSet.new([].freeze)
|
|
345
366
|
Canopus::DisplayMap::UNWRAPPED_LINE = Canopus::DisplayMap::PendingLineSet.new(1)
|
data/lib/canopus/lsp/client.rb
CHANGED
|
@@ -46,6 +46,7 @@ module Canopus
|
|
|
46
46
|
raise Error, "missing semantic token legend" if semantic && (!semantic.is_a?(Hash) || !semantic["legend"].is_a?(Hash))
|
|
47
47
|
Protocol.semantic_tokens([], legend: semantic["legend"]) if semantic.is_a?(Hash)
|
|
48
48
|
notify("initialized", {})
|
|
49
|
+
reopen_documents if @reopening_documents
|
|
49
50
|
@state = :running
|
|
50
51
|
self
|
|
51
52
|
rescue StandardError => error
|
|
@@ -279,21 +280,28 @@ module Canopus
|
|
|
279
280
|
return if @restart_thread&.alive?
|
|
280
281
|
previous = @transport
|
|
281
282
|
@restart_thread = Thread.new do
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
283
|
+
@reopening_documents = true
|
|
284
|
+
begin
|
|
285
|
+
previous.close
|
|
286
|
+
until @closing || @restarts >= 3
|
|
287
|
+
@restarts += 1
|
|
288
|
+
sleep(0.2 * @restarts)
|
|
289
|
+
break if @closing
|
|
290
|
+
begin
|
|
291
|
+
start
|
|
292
|
+
break
|
|
293
|
+
rescue StandardError => failure
|
|
294
|
+
report_error(failure)
|
|
295
|
+
end
|
|
293
296
|
end
|
|
297
|
+
ensure
|
|
298
|
+
@reopening_documents = false
|
|
294
299
|
end
|
|
295
300
|
end
|
|
296
301
|
end
|
|
302
|
+
def reopen_documents
|
|
303
|
+
@documents.values.dup.each { |buffer, language, _| open_document(buffer, language_id: language) }
|
|
304
|
+
end
|
|
297
305
|
end
|
|
298
306
|
end
|
|
299
307
|
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Canopus
|
|
4
|
+
module Panel
|
|
5
|
+
Definition = Data.define(:id, :title, :icon, :dock, :build, :badge)
|
|
6
|
+
|
|
7
|
+
class Registry
|
|
8
|
+
DOCKS = %i[left right bottom].freeze
|
|
9
|
+
|
|
10
|
+
attr_reader :docks
|
|
11
|
+
|
|
12
|
+
def initialize(docks)
|
|
13
|
+
@docks = docks
|
|
14
|
+
@definitions, @states = {}, {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def register(definition, visible: true, size: nil)
|
|
18
|
+
definition = normalize(definition)
|
|
19
|
+
raise ArgumentError, "panel visibility must be true or false" unless [true, false].include?(visible)
|
|
20
|
+
size ||= @docks.fetch(definition.dock)[:size]
|
|
21
|
+
raise ArgumentError, "invalid panel size" unless size.is_a?(Numeric) && size.finite? && size.positive?
|
|
22
|
+
@definitions[definition.id] = definition
|
|
23
|
+
@states[definition.id] ||= {visible: visible, size: size}
|
|
24
|
+
definition
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def fetch(id) = @definitions.fetch(id.to_s)
|
|
28
|
+
def key?(id) = @definitions.key?(id.to_s)
|
|
29
|
+
def visible?(id)
|
|
30
|
+
definition = fetch(id)
|
|
31
|
+
!!@states.fetch(definition.id)[:visible]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def show(id)
|
|
35
|
+
definition = fetch(id)
|
|
36
|
+
state = @states.fetch(definition.id)
|
|
37
|
+
state[:visible] = true
|
|
38
|
+
dock = @docks.fetch(definition.dock)
|
|
39
|
+
dock[:size], dock[:visible] = state[:size], true
|
|
40
|
+
definition
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def hide(id)
|
|
44
|
+
definition = fetch(id)
|
|
45
|
+
state = @states.fetch(definition.id)
|
|
46
|
+
state[:size], state[:visible] = @docks.fetch(definition.dock)[:size], false
|
|
47
|
+
@docks.fetch(definition.dock)[:visible] = false if active(definition.dock).empty?
|
|
48
|
+
definition
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def toggle(id) = visible?(id) ? hide(id) : show(id)
|
|
52
|
+
|
|
53
|
+
def active(dock)
|
|
54
|
+
raise ArgumentError, "invalid dock side" unless DOCKS.include?(dock)
|
|
55
|
+
@definitions.each_value.select { |definition| definition.dock == dock && visible?(definition.id) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def badge(id, value)
|
|
59
|
+
definition = fetch(id).with(badge: value)
|
|
60
|
+
@definitions[definition.id] = definition
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def resize(dock, size)
|
|
64
|
+
raise ArgumentError, "invalid dock size" unless size.is_a?(Numeric) && size.finite? && size.positive?
|
|
65
|
+
@docks.fetch(dock)[:size] = size
|
|
66
|
+
active(dock).each { |definition| @states.fetch(definition.id)[:size] = size }
|
|
67
|
+
size
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def state
|
|
71
|
+
@states.to_h { |id, value| [id, {"visible" => value[:visible], "size" => value[:size]}] }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def restore(state, docks: nil)
|
|
75
|
+
raise ArgumentError, "panel state must be an object" unless state.is_a?(Hash) && state.length <= 1_000
|
|
76
|
+
restored = state.each_with_object({}) do |(id, value), result|
|
|
77
|
+
id = id.to_s
|
|
78
|
+
valid = value.is_a?(Hash) && [true, false].include?(value["visible"]) &&
|
|
79
|
+
value["size"].is_a?(Numeric) && value["size"].finite? && value["size"].positive?
|
|
80
|
+
raise ArgumentError, "invalid panel state: #{id}" if @definitions.key?(id) && !valid
|
|
81
|
+
next unless valid
|
|
82
|
+
result[id] = {visible: value["visible"], size: value["size"]}
|
|
83
|
+
end
|
|
84
|
+
@docks.replace(docks.transform_values(&:dup)) if docks
|
|
85
|
+
@states.merge!(restored)
|
|
86
|
+
self
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def normalize(definition)
|
|
92
|
+
raise ArgumentError, "panel definition required" unless definition.is_a?(Definition)
|
|
93
|
+
id, title, icon, dock, build, badge = definition.deconstruct
|
|
94
|
+
valid_id = (id.is_a?(String) || id.is_a?(Symbol)) && !id.to_s.empty? &&
|
|
95
|
+
!id.to_s.include?(":") && !id.to_s.match?(/[\x00-\x1f\x7f]/)
|
|
96
|
+
raise ArgumentError, "invalid panel id" unless valid_id
|
|
97
|
+
raise ArgumentError, "panel title must be a string" unless title.is_a?(String)
|
|
98
|
+
raise ArgumentError, "invalid dock side" unless DOCKS.include?(dock)
|
|
99
|
+
raise ArgumentError, "panel builder must be callable" unless build.respond_to?(:call)
|
|
100
|
+
Definition.new(id.to_s.dup.freeze, title.dup.freeze, icon, dock, build, badge)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
data/lib/canopus/settings.rb
CHANGED
|
@@ -10,7 +10,12 @@ module Canopus
|
|
|
10
10
|
"theme" => "Canopus Dark", "font_family" => nil, "icon_theme" => nil, "languages" => {}, "language_servers" => {},
|
|
11
11
|
"tabs" => {"activate_on_close" => "history", "close_on_middle_click" => true, "close_empty_pane" => true,
|
|
12
12
|
"reopen_history_limit" => 20, "confirm_on_close_dirty" => true}.freeze,
|
|
13
|
-
"dock" => {"
|
|
13
|
+
"dock" => {"left" => {"size" => 220, "visible" => true}.freeze,
|
|
14
|
+
"right" => {"size" => 260, "visible" => false}.freeze,
|
|
15
|
+
"bottom" => {"size" => 280, "visible" => false}.freeze,
|
|
16
|
+
"panels" => {"explorer" => {"size" => 220, "visible" => true}.freeze,
|
|
17
|
+
"search" => {"size" => 220, "visible" => false}.freeze,
|
|
18
|
+
"terminal" => {"size" => 280, "visible" => false}.freeze}.freeze}.freeze,
|
|
14
19
|
"terminal" => {"shell" => nil, "working_directory" => "project", "env" => {}.freeze, "scrollback_lines" => 10_000,
|
|
15
20
|
"font_size" => nil, "line_height" => 1.2, "copy_on_select" => false, "blinking" => "terminal_controlled", "cursor_shape" => "block",
|
|
16
21
|
"close_on_exit" => "clean", "confirm_close_running" => true, "confirm_multiline_paste" => true,
|
|
@@ -27,9 +32,17 @@ module Canopus
|
|
|
27
32
|
"bindings" => {"type" => "object", "maxProperties" => 1024, "additionalProperties" => {"type" => ["string", "null"]}}}}},
|
|
28
33
|
"theme" => {"type" => "string"}, "font_family" => {"type" => ["string", "null"]},
|
|
29
34
|
"icon_theme" => {"type" => ["string", "null"]},
|
|
30
|
-
"tabs" => {"type" => "object"}, "terminal" => {"type" => "object"},
|
|
35
|
+
"tabs" => {"type" => "object"}, "terminal" => {"type" => "object"},
|
|
36
|
+
"dock" => {"type" => "object", "properties" => {
|
|
37
|
+
"left" => {"$ref" => "#/$defs/dock"}, "right" => {"$ref" => "#/$defs/dock"},
|
|
38
|
+
"bottom" => {"$ref" => "#/$defs/dock"}, "panels" => {"type" => "object", "maxProperties" => 1000,
|
|
39
|
+
"additionalProperties" => {"$ref" => "#/$defs/panel"}}}},
|
|
31
40
|
"languages" => {"type" => "object", "additionalProperties" => {"$ref" => "#"}},
|
|
32
|
-
"language_servers" => {"type" => "object"}}
|
|
41
|
+
"language_servers" => {"type" => "object"}}, "$defs" => {
|
|
42
|
+
"dock" => {"type" => "object", "required" => %w[size visible], "properties" => {
|
|
43
|
+
"size" => {"type" => "number", "exclusiveMinimum" => 0}, "visible" => {"type" => "boolean"}}},
|
|
44
|
+
"panel" => {"type" => "object", "required" => %w[size visible], "properties" => {
|
|
45
|
+
"size" => {"type" => "number", "exclusiveMinimum" => 0}, "visible" => {"type" => "boolean"}}}}}.freeze
|
|
33
46
|
attr_reader :values, :errors, :layers
|
|
34
47
|
def self.schema = SCHEMA
|
|
35
48
|
def self.user_path
|
|
@@ -163,8 +176,19 @@ module Canopus
|
|
|
163
176
|
|
|
164
177
|
def validate_dock!
|
|
165
178
|
dock = @values["dock"]
|
|
166
|
-
|
|
167
|
-
|
|
179
|
+
raise Error, "dock must be an object" unless dock.is_a?(Hash)
|
|
180
|
+
%w[left right bottom].each { |side| validate_dock_state!(dock[side], "dock.#{side}") }
|
|
181
|
+
panels = dock["panels"]
|
|
182
|
+
raise Error, "dock.panels must be an object" unless panels.is_a?(Hash) && panels.length <= 1_000
|
|
183
|
+
panels.each do |id, state|
|
|
184
|
+
raise Error, "invalid panel id" unless id.is_a?(String) && id.bytesize.between?(1, 256)
|
|
185
|
+
validate_dock_state!(state, "dock.panels.#{id}")
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def validate_dock_state!(state, name)
|
|
190
|
+
valid_size = state.is_a?(Hash) && state["size"].is_a?(Numeric) && state["size"].finite? && state["size"].positive?
|
|
191
|
+
raise Error, "invalid #{name}" unless valid_size && [true, false].include?(state["visible"])
|
|
168
192
|
end
|
|
169
193
|
end
|
|
170
194
|
end
|
data/lib/canopus/tab_map.rb
CHANGED
|
@@ -7,8 +7,9 @@ module Canopus
|
|
|
7
7
|
raise ArgumentError, "tab size must be positive" unless tab_size.is_a?(Integer) && tab_size.positive?
|
|
8
8
|
@tab_size = tab_size
|
|
9
9
|
end
|
|
10
|
-
def transform(text, offsets, checkpoint: nil)
|
|
11
|
-
output, positions, column, i = +"", [offsets.first], 0, 0
|
|
10
|
+
def transform(text, offsets, checkpoint: nil, overlays: nil)
|
|
11
|
+
output, positions, column, i, input_byte = +"", [offsets.first], 0, 0, 0
|
|
12
|
+
boundaries = {0 => 0} if overlays
|
|
12
13
|
text.each_grapheme_cluster do |char|
|
|
13
14
|
checkpoint&.call if (i & 1023).zero?
|
|
14
15
|
if char == "\t"
|
|
@@ -23,8 +24,12 @@ module Canopus
|
|
|
23
24
|
column += char.ascii_only? ? char.length : Zaniah::Unicode.width(char)
|
|
24
25
|
end
|
|
25
26
|
i += char.length
|
|
27
|
+
input_byte += char.bytesize
|
|
28
|
+
boundaries[input_byte] = output.bytesize if boundaries
|
|
26
29
|
end
|
|
27
|
-
[output, positions]
|
|
30
|
+
return [output, positions] unless overlays
|
|
31
|
+
|
|
32
|
+
[output, positions, overlays.map { |overlay| overlay.with(offset: boundaries.fetch(overlay.offset)) }.freeze]
|
|
28
33
|
end
|
|
29
34
|
end
|
|
30
35
|
end
|
data/lib/canopus/version.rb
CHANGED
|
@@ -30,6 +30,7 @@ module Canopus
|
|
|
30
30
|
def invalidate_git
|
|
31
31
|
@git_generation = (@git_generation || 0) + 1
|
|
32
32
|
@git_status = @git_diff_cache = nil
|
|
33
|
+
@decorations.invalidate(:git)
|
|
33
34
|
@panes.each do |pane|
|
|
34
35
|
pane.editors.each do |current|
|
|
35
36
|
current.display_map.block_map.blocks.values.each { |block| current.display_map.remove_block(block.id) if block.kind == :git_diff }
|
|
@@ -73,7 +74,10 @@ module Canopus
|
|
|
73
74
|
diff = Porrima.diff(before, snapshot.to_s, context: 0)
|
|
74
75
|
diff.hunks
|
|
75
76
|
diff.marks
|
|
76
|
-
post
|
|
77
|
+
post do
|
|
78
|
+
(@git_diff_cache ||= {})[key] = diff
|
|
79
|
+
@decorations.invalidate(:git, buffer: buffer)
|
|
80
|
+
end unless @closed
|
|
77
81
|
rescue StandardError => error
|
|
78
82
|
post { @message = "Git diff: #{error.message}" } unless @closed
|
|
79
83
|
end
|
|
@@ -89,6 +93,18 @@ module Canopus
|
|
|
89
93
|
end
|
|
90
94
|
def git_hunks(buffer = editor.buffer, async: true) = git_diff(buffer, async: async)&.hunks || []
|
|
91
95
|
def git_gutter_marks(buffer = editor.buffer) = git_diff(buffer)&.marks || []
|
|
96
|
+
def git_decorations(buffer, rows)
|
|
97
|
+
git_gutter_marks(buffer).filter_map do |mark|
|
|
98
|
+
first = [mark.new_line, 1].max - 1
|
|
99
|
+
count = mark.kind == :removed ? 1 : mark.count
|
|
100
|
+
next if first + count <= rows.begin || first >= rows.end
|
|
101
|
+
|
|
102
|
+
color = mark.kind == :removed ? :error : mark.kind == :added ? "#80b987" : :accent
|
|
103
|
+
style = {color: color, rows: count}.freeze
|
|
104
|
+
click = ->(current, row) { toggle_git_hunk(current, row: row) }
|
|
105
|
+
Decoration::Item.new(:gutter, nil, first, "Toggle Git hunk", style, 0, :git, click)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
92
108
|
def show_git_diff
|
|
93
109
|
path = git_relative_path
|
|
94
110
|
before = Buffer.decode_bytes(git.blob(path).to_s).first
|
|
@@ -19,9 +19,9 @@ module Canopus
|
|
|
19
19
|
id
|
|
20
20
|
end
|
|
21
21
|
state = {version: 2, root: @root, layout: encode_layout(@layout), recent_files: @recent_files || [],
|
|
22
|
-
active_pane: @panes.index(@active_pane), docks: @docks,
|
|
22
|
+
active_pane: @panes.index(@active_pane), docks: @docks, panels: @panels.state,
|
|
23
23
|
terminals: @terminals.map { |current| {cwd: current.vt.cwd || (current.respond_to?(:initial_cwd) ? current.initial_cwd : @root), title: @terminal_names[current]} },
|
|
24
|
-
active_terminal: @active_terminal_index, terminal_visible:
|
|
24
|
+
active_terminal: @active_terminal_index, terminal_visible: terminal_visible,
|
|
25
25
|
panes: @panes.map do |pane|
|
|
26
26
|
{active: pane.active_index, tabs: pane.editors.map do |current|
|
|
27
27
|
{buffer_id: record.call(current.buffer), cursor: current.primary.head, pinned: pane.pinned.include?(current),
|
|
@@ -133,6 +133,14 @@ module Canopus
|
|
|
133
133
|
raise Error, "invalid session dock" unless value.is_a?(Hash) && [true, false].include?(value["visible"]) && value["size"].is_a?(Numeric) && value["size"].finite? && value["size"].positive?
|
|
134
134
|
restored_docks[side.to_sym] = {visible: value["visible"], size: value["size"].clamp(40, 4000)}
|
|
135
135
|
end
|
|
136
|
+
restored_panels = data.fetch("panels", {})
|
|
137
|
+
raise Error, "invalid session panels" unless restored_panels.is_a?(Hash) && restored_panels.length <= 1_000
|
|
138
|
+
restored_panels.each do |id, value|
|
|
139
|
+
next unless @panels.key?(id)
|
|
140
|
+
valid = value.is_a?(Hash) && [true, false].include?(value["visible"]) &&
|
|
141
|
+
value["size"].is_a?(Numeric) && value["size"].finite? && value["size"].positive?
|
|
142
|
+
raise Error, "invalid session panel" unless valid
|
|
143
|
+
end
|
|
136
144
|
active = data.fetch("active_pane", 0)
|
|
137
145
|
raise Error, "invalid active pane" unless active.is_a?(Integer) && active.between?(0, restored_panes.length - 1)
|
|
138
146
|
terminal_records = validate_session_terminals(data) if @settings["terminal"]["restore_on_startup"]
|
|
@@ -140,7 +148,8 @@ module Canopus
|
|
|
140
148
|
close_language_documents
|
|
141
149
|
@panes.each { |pane| pane.editors.each(&:dispose) }
|
|
142
150
|
@buffers.each_value(&:close)
|
|
143
|
-
@
|
|
151
|
+
@panels.restore(restored_panels, docks: restored_docks)
|
|
152
|
+
@panes, @buffers, @layout = restored_panes, restored_buffers, restored_layout
|
|
144
153
|
@active_pane = @panes[active]
|
|
145
154
|
@recent_files = Array(data["recent_files"]).select { |item| item.is_a?(String) && File.file?(item) }.first(100)
|
|
146
155
|
restore_terminals(data, terminal_records) if terminal_records
|
|
@@ -164,7 +173,7 @@ module Canopus
|
|
|
164
173
|
end
|
|
165
174
|
|
|
166
175
|
def restore_terminals(data, records)
|
|
167
|
-
old, old_index, old_visible = @terminals, @active_terminal_index,
|
|
176
|
+
old, old_index, old_visible = @terminals, @active_terminal_index, terminal_visible
|
|
168
177
|
@terminals = []
|
|
169
178
|
records.each do |record|
|
|
170
179
|
cwd = File.directory?(record["cwd"]) ? record["cwd"] : @root
|
|
@@ -172,12 +181,13 @@ module Canopus
|
|
|
172
181
|
rename_terminal(record["title"], created) if record["title"].is_a?(String)
|
|
173
182
|
end
|
|
174
183
|
@active_terminal_index = @terminals.empty? ? 0 : data.fetch("active_terminal", 0)
|
|
175
|
-
|
|
184
|
+
self.terminal_visible = !!data["terminal_visible"] && !@terminals.empty?
|
|
176
185
|
old.each { |current| current.close if current.respond_to?(:close) }
|
|
177
186
|
rescue StandardError
|
|
178
187
|
@terminals.each { |current| current.close if current.respond_to?(:close) }
|
|
179
188
|
@terminals = old
|
|
180
|
-
@active_terminal_index
|
|
189
|
+
@active_terminal_index = old_index
|
|
190
|
+
self.terminal_visible = old_visible
|
|
181
191
|
@message = "Session restored; terminals could not start"
|
|
182
192
|
end
|
|
183
193
|
end
|