canopus 0.1.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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +148 -0
- data/docs/adr/001-runtime-gem-components.md +24 -0
- data/docs/adr/002-bounded-diff-engine.md +22 -0
- data/docs/adr/003-background-language-analysis.md +22 -0
- data/docs/adr/004-persistent-display-map.md +23 -0
- data/docs/adr/005-windows-runtime.md +27 -0
- data/docs/adr/README.md +36 -0
- data/docs/distribution.md +27 -0
- data/docs/lsp.md +38 -0
- data/docs/performance.md +40 -0
- data/docs/snippets.md +32 -0
- data/docs/vim.md +24 -0
- data/docs/workspace_edits.md +32 -0
- data/examples/native_smoke.rb +25 -0
- data/examples/plugins/word_count.rb +11 -0
- data/exe/canopus +19 -0
- data/lib/canopus/block_map.rb +19 -0
- data/lib/canopus/buffer.rb +293 -0
- data/lib/canopus/cli.rb +171 -0
- data/lib/canopus/controller.rb +507 -0
- data/lib/canopus/data_compat.rb +25 -0
- data/lib/canopus/display_map/line_builder.rb +54 -0
- data/lib/canopus/display_map/line_set.rb +8 -0
- data/lib/canopus/display_map/pending_line_set.rb +9 -0
- data/lib/canopus/display_map/summary.rb +23 -0
- data/lib/canopus/display_map/worker.rb +89 -0
- data/lib/canopus/display_map.rb +350 -0
- data/lib/canopus/display_point.rb +5 -0
- data/lib/canopus/editor/snippet_expandable.rb +281 -0
- data/lib/canopus/editor.rb +445 -0
- data/lib/canopus/error.rb +5 -0
- data/lib/canopus/fold_map.rb +87 -0
- data/lib/canopus/git/blame.rb +50 -0
- data/lib/canopus/git/commit.rb +7 -0
- data/lib/canopus/git/corrupt_object.rb +7 -0
- data/lib/canopus/git/diff.rb +131 -0
- data/lib/canopus/git/index.rb +94 -0
- data/lib/canopus/git/object_database.rb +45 -0
- data/lib/canopus/git/pack.rb +186 -0
- data/lib/canopus/git/repository.rb +313 -0
- data/lib/canopus/git/status.rb +74 -0
- data/lib/canopus/git/tree_entry.rb +7 -0
- data/lib/canopus/git.rb +15 -0
- data/lib/canopus/icon_theme.rb +43 -0
- data/lib/canopus/language/background_analysis/job.rb +12 -0
- data/lib/canopus/language/background_analysis/scheduler.rb +70 -0
- data/lib/canopus/language/background_analysis.rb +280 -0
- data/lib/canopus/language/definition.rb +7 -0
- data/lib/canopus/language/document.rb +143 -0
- data/lib/canopus/language/symbol.rb +7 -0
- data/lib/canopus/language/syntax_worker.rb +91 -0
- data/lib/canopus/language.rb +42 -0
- data/lib/canopus/lazy_rope.rb +218 -0
- data/lib/canopus/lsp/client.rb +299 -0
- data/lib/canopus/lsp/error.rb +7 -0
- data/lib/canopus/lsp/future/subscription.rb +9 -0
- data/lib/canopus/lsp/future.rb +87 -0
- data/lib/canopus/lsp/protocol.rb +83 -0
- data/lib/canopus/lsp/server_error.rb +13 -0
- data/lib/canopus/lsp/timeout.rb +7 -0
- data/lib/canopus/lsp/transport.rb +123 -0
- data/lib/canopus/lsp.rb +19 -0
- data/lib/canopus/markdown.rb +69 -0
- data/lib/canopus/match_data_compat.rb +17 -0
- data/lib/canopus/multi_buffer.rb +266 -0
- data/lib/canopus/pane.rb +61 -0
- data/lib/canopus/patch/composite.rb +12 -0
- data/lib/canopus/patch/reload.rb +18 -0
- data/lib/canopus/patch.rb +27 -0
- data/lib/canopus/performance_recorder.rb +207 -0
- data/lib/canopus/plugins/api.rb +50 -0
- data/lib/canopus/plugins/isolated_runtime.rb +167 -0
- data/lib/canopus/plugins/local_runtime.rb +25 -0
- data/lib/canopus/plugins/permission_denied.rb +7 -0
- data/lib/canopus/plugins/registry.rb +30 -0
- data/lib/canopus/plugins.rb +11 -0
- data/lib/canopus/project/ignore_matcher.rb +104 -0
- data/lib/canopus/project/search.rb +164 -0
- data/lib/canopus/project/search_worker/cancelled.rb +3 -0
- data/lib/canopus/project/search_worker/runner.rb +9 -0
- data/lib/canopus/project/search_worker.rb +108 -0
- data/lib/canopus/project/tree.rb +48 -0
- data/lib/canopus/project/watcher.rb +59 -0
- data/lib/canopus/project.rb +127 -0
- data/lib/canopus/regexp_compat.rb +30 -0
- data/lib/canopus/save_conflict.rb +5 -0
- data/lib/canopus/selection.rb +11 -0
- data/lib/canopus/settings.rb +118 -0
- data/lib/canopus/snippet/transform.rb +209 -0
- data/lib/canopus/snippet.rb +183 -0
- data/lib/canopus/tab_map.rb +30 -0
- data/lib/canopus/terminal/cell.rb +7 -0
- data/lib/canopus/terminal/grid.rb +321 -0
- data/lib/canopus/terminal/pty.rb +103 -0
- data/lib/canopus/terminal/scrollback.rb +38 -0
- data/lib/canopus/terminal/vt.rb +398 -0
- data/lib/canopus/terminal.rb +13 -0
- data/lib/canopus/theme.rb +43 -0
- data/lib/canopus/version.rb +5 -0
- data/lib/canopus/vim/commandable.rb +148 -0
- data/lib/canopus/vim/motionable.rb +321 -0
- data/lib/canopus/vim/operator_capable.rb +377 -0
- data/lib/canopus/vim/text_object_selectable.rb +141 -0
- data/lib/canopus/vim.rb +426 -0
- data/lib/canopus/workspace/edit/executable.rb +139 -0
- data/lib/canopus/workspace/edit/node.rb +8 -0
- data/lib/canopus/workspace/edit/plan.rb +170 -0
- data/lib/canopus/workspace/edit/resource_preparable.rb +98 -0
- data/lib/canopus/workspace/edit.rb +6 -0
- data/lib/canopus/workspace/file_change_aware.rb +40 -0
- data/lib/canopus/workspace/file_previewable.rb +31 -0
- data/lib/canopus/workspace/git_aware.rb +140 -0
- data/lib/canopus/workspace/language_aware.rb +413 -0
- data/lib/canopus/workspace/language_server_configurable.rb +181 -0
- data/lib/canopus/workspace/project_searchable.rb +208 -0
- data/lib/canopus/workspace/project_tree_editable.rb +82 -0
- data/lib/canopus/workspace/session_persistable.rb +153 -0
- data/lib/canopus/workspace/settings_aware.rb +91 -0
- data/lib/canopus/workspace/view/terminal_presentable.rb +185 -0
- data/lib/canopus/workspace/view.rb +643 -0
- data/lib/canopus/workspace.rb +525 -0
- data/lib/canopus/wrap_map.rb +108 -0
- data/lib/canopus.rb +24 -0
- data/sig/canopus.rbs +375 -0
- data/sig/controller.rbs +55 -0
- data/sig/display_stages.rbs +49 -0
- data/sig/git.rbs +140 -0
- data/sig/lsp.rbs +99 -0
- data/sig/markdown.rbs +11 -0
- data/sig/performance_recorder.rbs +23 -0
- data/sig/search_services.rbs +8 -0
- data/sig/services.rbs +81 -0
- data/sig/snippet.rbs +42 -0
- data/sig/terminal.rbs +125 -0
- data/sig/vim.rbs +27 -0
- data/sig/workspace_edits.rbs +11 -0
- data/sig/workspace_services.rbs +117 -0
- data/tools/certify_snippet_regex.rb +35 -0
- data/tools/check_dependencies.rb +80 -0
- data/tools/native_check.rb +70 -0
- data/tools/package.rb +113 -0
- data/tools/package_test.rb +32 -0
- metadata +314 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "denebola"
|
|
4
|
+
require "zaniah"
|
|
5
|
+
|
|
6
|
+
module Canopus
|
|
7
|
+
module Terminal
|
|
8
|
+
class Grid
|
|
9
|
+
attr_reader :columns, :rows, :cells, :scrollback, :cursor_x, :cursor_y, :scroll_top, :scroll_bottom
|
|
10
|
+
attr_accessor :foreground, :background, :attributes, :hyperlink, :autowrap, :insert_mode, :origin_mode, :cursor_visible
|
|
11
|
+
|
|
12
|
+
def initialize(columns: 80, rows: 24, scrollback: 10_000)
|
|
13
|
+
validate_dimensions(columns, rows)
|
|
14
|
+
@columns, @rows = columns, rows
|
|
15
|
+
@scrollback = Scrollback.new(scrollback)
|
|
16
|
+
reset
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def reset
|
|
20
|
+
@foreground = @background = @hyperlink = nil
|
|
21
|
+
@attributes = {}.freeze
|
|
22
|
+
@autowrap = @cursor_visible = true
|
|
23
|
+
@insert_mode = @origin_mode = false
|
|
24
|
+
@cursor_x = @cursor_y = 0
|
|
25
|
+
@wrap_pending = false
|
|
26
|
+
@alternate = nil
|
|
27
|
+
@scroll_top, @scroll_bottom = 0, rows - 1
|
|
28
|
+
@cells = Array.new(rows) { blank_row }
|
|
29
|
+
@tabs = (8...columns).step(8).to_a
|
|
30
|
+
save_cursor
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def [](row, column = nil) = column ? cells[row]&.[](column) : cells[row]
|
|
34
|
+
def alternate? = !@alternate.nil?
|
|
35
|
+
def lines = cells.map { |row| row.map(&:text).join.rstrip }
|
|
36
|
+
def text = lines.join("\n")
|
|
37
|
+
|
|
38
|
+
def put(char)
|
|
39
|
+
width = self.class.width(char)
|
|
40
|
+
previous_x = @wrap_pending ? cursor_x : cursor_x - 1
|
|
41
|
+
if previous_x >= 0 && !char.ascii_only?
|
|
42
|
+
previous_x -= 1 if cells[cursor_y][previous_x].width.zero? && previous_x.positive?
|
|
43
|
+
previous = cells[cursor_y][previous_x]
|
|
44
|
+
joined = previous.text + char
|
|
45
|
+
if joined.match?(/\A\X\z/)
|
|
46
|
+
width = [self.class.width(joined), columns].min
|
|
47
|
+
if previous_x + width > columns && autowrap
|
|
48
|
+
cells[cursor_y][previous_x] = blank
|
|
49
|
+
carriage_return
|
|
50
|
+
linefeed
|
|
51
|
+
return put(joined)
|
|
52
|
+
end
|
|
53
|
+
width = [width, columns - previous_x].min
|
|
54
|
+
clear_wide(cursor_y, previous_x + 1) if width == 2 && previous.width < 2
|
|
55
|
+
cells[cursor_y][previous_x + 1] = blank if width < previous.width
|
|
56
|
+
previous.text, previous.width = joined, width
|
|
57
|
+
cells[cursor_y][previous_x + 1] = previous.dup.tap { |cell| cell.text = ""; cell.width = 0 } if width == 2
|
|
58
|
+
@cursor_x = [previous_x + width, columns - 1].min
|
|
59
|
+
@wrap_pending = previous_x + width >= columns && autowrap
|
|
60
|
+
return
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
return if width.zero?
|
|
64
|
+
width = 1 if width > columns
|
|
65
|
+
if @wrap_pending || (width == 2 && cursor_x == columns - 1)
|
|
66
|
+
if autowrap
|
|
67
|
+
@cursor_x = 0
|
|
68
|
+
linefeed
|
|
69
|
+
elsif width == 2
|
|
70
|
+
return
|
|
71
|
+
end
|
|
72
|
+
@wrap_pending = false
|
|
73
|
+
end
|
|
74
|
+
insert_characters(width) if insert_mode
|
|
75
|
+
clear_wide(cursor_y, cursor_x)
|
|
76
|
+
clear_wide(cursor_y, cursor_x + 1) if width == 2
|
|
77
|
+
cells[cursor_y][cursor_x] = Cell.new(text: char, width: width, foreground: foreground,
|
|
78
|
+
background: background, attributes: attributes, hyperlink: hyperlink)
|
|
79
|
+
cells[cursor_y][cursor_x + 1] = Cell.new(text: "", width: 0, foreground: foreground,
|
|
80
|
+
background: background, attributes: attributes, hyperlink: hyperlink) if width == 2
|
|
81
|
+
if cursor_x + width >= columns
|
|
82
|
+
@cursor_x = columns - 1
|
|
83
|
+
@wrap_pending = autowrap
|
|
84
|
+
else
|
|
85
|
+
@cursor_x += width
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.width(char)
|
|
90
|
+
return 1 if char.bytesize == 1 && char.ord.between?(32, 126)
|
|
91
|
+
Zaniah::Unicode.width(char)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def move(x: cursor_x, y: cursor_y, relative: false)
|
|
95
|
+
if relative
|
|
96
|
+
x += cursor_x
|
|
97
|
+
y += cursor_y
|
|
98
|
+
end
|
|
99
|
+
top, bottom = origin_mode ? [scroll_top, scroll_bottom] : [0, rows - 1]
|
|
100
|
+
@cursor_x = x.clamp(0, columns - 1)
|
|
101
|
+
@cursor_y = y.clamp(top, bottom)
|
|
102
|
+
@wrap_pending = false
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def position(row, column)
|
|
106
|
+
move(x: column - 1, y: row - 1 + (origin_mode ? scroll_top : 0))
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def carriage_return = move(x: 0)
|
|
110
|
+
def backspace = move(x: cursor_x - 1)
|
|
111
|
+
|
|
112
|
+
def linefeed
|
|
113
|
+
@wrap_pending = false
|
|
114
|
+
if cursor_y == scroll_bottom
|
|
115
|
+
scroll_up
|
|
116
|
+
elsif cursor_y < rows - 1
|
|
117
|
+
@cursor_y += 1
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def reverse_index
|
|
122
|
+
@wrap_pending = false
|
|
123
|
+
cursor_y == scroll_top ? scroll_down : @cursor_y = [0, cursor_y - 1].max
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def tab(count = 1, backward: false)
|
|
127
|
+
count.times do
|
|
128
|
+
stop = backward ? @tabs.reverse.find { |column| column < cursor_x } : @tabs.find { |column| column > cursor_x }
|
|
129
|
+
move(x: stop || (backward ? 0 : columns - 1))
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def tab_set = @tabs = (@tabs + [cursor_x]).uniq.sort
|
|
134
|
+
def tab_clear(all: false) = all ? @tabs.clear : @tabs.delete(cursor_x)
|
|
135
|
+
|
|
136
|
+
def margins(top = 1, bottom = rows)
|
|
137
|
+
return unless top >= 1 && top < bottom && bottom <= rows
|
|
138
|
+
@scroll_top, @scroll_bottom = top - 1, bottom - 1
|
|
139
|
+
position(1, 1)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def scroll_up(count = 1)
|
|
143
|
+
[count, scroll_bottom - scroll_top + 1].min.times do
|
|
144
|
+
removed = cells.delete_at(scroll_top)
|
|
145
|
+
scrollback.push(removed) if scroll_top.zero? && scroll_bottom == rows - 1 && !alternate?
|
|
146
|
+
cells.insert(scroll_bottom, blank_row)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def scroll_down(count = 1)
|
|
151
|
+
[count, scroll_bottom - scroll_top + 1].min.times do
|
|
152
|
+
cells.delete_at(scroll_bottom)
|
|
153
|
+
cells.insert(scroll_top, blank_row)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def erase_display(mode = 0)
|
|
158
|
+
case mode
|
|
159
|
+
when 0
|
|
160
|
+
erase_line(0)
|
|
161
|
+
((cursor_y + 1)...rows).each { |row| cells[row] = blank_row }
|
|
162
|
+
when 1
|
|
163
|
+
(0...cursor_y).each { |row| cells[row] = blank_row }
|
|
164
|
+
erase_line(1)
|
|
165
|
+
when 2 then @cells = Array.new(rows) { blank_row }
|
|
166
|
+
when 3 then scrollback.clear
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def erase_line(mode = 0)
|
|
171
|
+
first, last = case mode
|
|
172
|
+
when 0 then [cursor_x, columns - 1]
|
|
173
|
+
when 1 then [0, cursor_x]
|
|
174
|
+
when 2 then [0, columns - 1]
|
|
175
|
+
else return
|
|
176
|
+
end
|
|
177
|
+
(first..last).each { |column| clear_wide(cursor_y, column); cells[cursor_y][column] = blank }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def erase_characters(count = 1)
|
|
181
|
+
(cursor_x...[cursor_x + count, columns].min).each { |column| clear_wide(cursor_y, column); cells[cursor_y][column] = blank }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def insert_characters(count = 1)
|
|
185
|
+
clear_wide(cursor_y, cursor_x) if cells[cursor_y][cursor_x].width.zero?
|
|
186
|
+
cells[cursor_y].insert(cursor_x, *Array.new([count, columns - cursor_x].min) { blank })
|
|
187
|
+
cells[cursor_y] = cells[cursor_y].first(columns)
|
|
188
|
+
normalize_row(cursor_y)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def delete_characters(count = 1)
|
|
192
|
+
clear_wide(cursor_y, cursor_x)
|
|
193
|
+
count = [count, columns - cursor_x].min
|
|
194
|
+
cells[cursor_y].slice!(cursor_x, count)
|
|
195
|
+
cells[cursor_y].concat(Array.new(count) { blank })
|
|
196
|
+
normalize_row(cursor_y)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def insert_lines(count = 1)
|
|
200
|
+
return unless cursor_y.between?(scroll_top, scroll_bottom)
|
|
201
|
+
[count, scroll_bottom - cursor_y + 1].min.times { cells.delete_at(scroll_bottom); cells.insert(cursor_y, blank_row) }
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def delete_lines(count = 1)
|
|
205
|
+
return unless cursor_y.between?(scroll_top, scroll_bottom)
|
|
206
|
+
[count, scroll_bottom - cursor_y + 1].min.times { cells.delete_at(cursor_y); cells.insert(scroll_bottom, blank_row) }
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def save_cursor
|
|
210
|
+
@saved = [cursor_x, cursor_y, foreground, background, attributes, hyperlink, origin_mode, @wrap_pending]
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def restore_cursor
|
|
214
|
+
return unless @saved
|
|
215
|
+
@cursor_x, @cursor_y, @foreground, @background, @attributes, @hyperlink, @origin_mode, @wrap_pending = @saved
|
|
216
|
+
@cursor_x = cursor_x.clamp(0, columns - 1)
|
|
217
|
+
@cursor_y = cursor_y.clamp(0, rows - 1)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def alternate(enable, save: true)
|
|
221
|
+
if enable && !alternate?
|
|
222
|
+
save_cursor if save
|
|
223
|
+
@alternate = cells
|
|
224
|
+
@cells = Array.new(rows) { blank_row }
|
|
225
|
+
@cursor_x = @cursor_y = 0
|
|
226
|
+
@scroll_top, @scroll_bottom = 0, rows - 1
|
|
227
|
+
@wrap_pending = false
|
|
228
|
+
elsif !enable && alternate?
|
|
229
|
+
@cells, @alternate = @alternate, nil
|
|
230
|
+
@scroll_top, @scroll_bottom = 0, rows - 1
|
|
231
|
+
restore_cursor if save
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def resize(columns:, rows:)
|
|
236
|
+
validate_dimensions(columns, rows)
|
|
237
|
+
previous_columns = @columns
|
|
238
|
+
@columns, @rows = columns, rows
|
|
239
|
+
[cells, @alternate].compact.each do |screen|
|
|
240
|
+
while screen.length > rows
|
|
241
|
+
removed = screen.shift
|
|
242
|
+
scrollback.push(removed) unless alternate?
|
|
243
|
+
@cursor_y -= 1 if screen.equal?(cells)
|
|
244
|
+
end
|
|
245
|
+
screen << blank_row while screen.length < rows
|
|
246
|
+
screen.each do |row|
|
|
247
|
+
row.slice!(columns, row.length) if row.length > columns
|
|
248
|
+
row << blank while row.length < columns
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
cells.each_index { |index| normalize_row(index) }
|
|
252
|
+
@tabs = (@tabs.select { |column| column < columns } + (previous_columns...columns).select { |column| column.positive? && (column % 8).zero? }).uniq.sort
|
|
253
|
+
@scroll_top, @scroll_bottom = 0, rows - 1
|
|
254
|
+
move
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Coordinates are [column, row]; finish is exclusive, including scrollback
|
|
258
|
+
# when history: true. This is suitable for selection/copy without a renderer.
|
|
259
|
+
def selection(start, finish, history: false)
|
|
260
|
+
source = history ? scrollback.to_a + cells : cells
|
|
261
|
+
start, finish = finish, start if ([start[1], start[0]] <=> [finish[1], finish[0]]) == 1
|
|
262
|
+
(start[1]..finish[1]).map do |row|
|
|
263
|
+
next "" unless source[row]
|
|
264
|
+
first = row == start[1] ? start[0] : 0
|
|
265
|
+
last = row == finish[1] ? finish[0] : columns
|
|
266
|
+
source[row][first...last].to_a.map(&:text).join.rstrip
|
|
267
|
+
end.join("\n")
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def links(row)
|
|
271
|
+
line = cells.fetch(row).map(&:text).join
|
|
272
|
+
explicit = []
|
|
273
|
+
cells[row].each_with_index do |cell, column|
|
|
274
|
+
next unless cell.hyperlink
|
|
275
|
+
if explicit.last && explicit.last[:url] == cell.hyperlink && explicit.last[:end_column] == column
|
|
276
|
+
explicit.last[:end_column] = column + 1
|
|
277
|
+
else
|
|
278
|
+
explicit << {url: cell.hyperlink, column: column, end_column: column + 1}
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
line.to_enum(:scan, %r{https?://[^\s<>"']+}).each do
|
|
282
|
+
found = Regexp.last_match
|
|
283
|
+
column = line[0...found.begin(0)].each_char.sum { |char| self.class.width(char) }
|
|
284
|
+
explicit << {url: found[0], column: column, end_column: column + found[0].length}
|
|
285
|
+
end
|
|
286
|
+
explicit.uniq
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def file_paths(row, cwd: Dir.pwd)
|
|
290
|
+
line = cells.fetch(row).map(&:text).join
|
|
291
|
+
line.scan(%r{(?:\A|[\s("'])([^\s:"'()]+):(\d+)(?::(\d+))?}).filter_map do |name, number, column|
|
|
292
|
+
absolute = File.expand_path(name, cwd)
|
|
293
|
+
{path: absolute, line: number.to_i, column: column ? column.to_i : 1} if File.file?(absolute)
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
private
|
|
298
|
+
|
|
299
|
+
def validate_dimensions(columns, rows)
|
|
300
|
+
raise ArgumentError, "terminal dimensions must be positive integers" unless columns.is_a?(Integer) && rows.is_a?(Integer) && columns.positive? && rows.positive?
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def blank = Cell.new(text: " ", width: 1, foreground: foreground, background: background, attributes: {}.freeze)
|
|
304
|
+
def blank_row = Array.new(columns) { blank }
|
|
305
|
+
|
|
306
|
+
def clear_wide(row, column)
|
|
307
|
+
return unless column.between?(0, columns - 1)
|
|
308
|
+
cell = cells[row][column]
|
|
309
|
+
cells[row][column - 1] = blank if cell.width.zero? && column.positive?
|
|
310
|
+
cells[row][column + 1] = blank if cell.width == 2 && column + 1 < columns
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def normalize_row(row)
|
|
314
|
+
cells[row].each_with_index do |cell, column|
|
|
315
|
+
cells[row][column] = blank if (cell.width == 2 && (column == columns - 1 || cells[row][column + 1].width != 0)) ||
|
|
316
|
+
(cell.width.zero? && (column.zero? || cells[row][column - 1].width != 2))
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
require "shellwords"
|
|
5
|
+
|
|
6
|
+
module Canopus
|
|
7
|
+
module Terminal
|
|
8
|
+
class PTY
|
|
9
|
+
attr_reader :reader, :writer, :pid, :grid, :vt, :status
|
|
10
|
+
|
|
11
|
+
def initialize(command: ENV.fetch("SHELL", "/bin/sh"), cwd: Dir.pwd, columns: 80, rows: 24, env: {}, scrollback: 10_000)
|
|
12
|
+
@grid = Grid.new(columns: columns, rows: rows, scrollback: scrollback)
|
|
13
|
+
if RUBY_PLATFORM.match?(/mswin|mingw/)
|
|
14
|
+
require "zaniah/platform/windows/terminal"
|
|
15
|
+
command = nil if command == "/bin/sh"
|
|
16
|
+
@native = Zaniah::Platform::Windows::Terminal.new(command: command, cwd: cwd, columns: columns, rows: rows, env: env)
|
|
17
|
+
@pid = @native.pid
|
|
18
|
+
@vt = VT.new(grid) { |bytes| write(bytes) }
|
|
19
|
+
return
|
|
20
|
+
end
|
|
21
|
+
require "pty"
|
|
22
|
+
arguments = command.is_a?(Array) ? command : Shellwords.split(command)
|
|
23
|
+
raise ArgumentError, "terminal command required" if arguments.empty?
|
|
24
|
+
@reader, @writer, @pid = ::PTY.spawn({"TERM" => "xterm-256color", "COLORTERM" => "truecolor"}.merge(env), *arguments, chdir: cwd)
|
|
25
|
+
@reader.binmode
|
|
26
|
+
@writer.binmode
|
|
27
|
+
@writer.sync = true
|
|
28
|
+
@vt = VT.new(grid) { |bytes| write(bytes) }
|
|
29
|
+
resize(columns: columns, rows: rows)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# nil means EOF; an empty String means that no data was ready yet.
|
|
33
|
+
def read(timeout: 0, max_bytes: 65_536)
|
|
34
|
+
return nil if @eof
|
|
35
|
+
if @native
|
|
36
|
+
data = @native.read_available(limit: max_bytes)
|
|
37
|
+
data ? vt.feed(data) : @eof = true
|
|
38
|
+
return data
|
|
39
|
+
end
|
|
40
|
+
return "" unless IO.select([reader], nil, nil, timeout)
|
|
41
|
+
data = reader.read_nonblock(max_bytes, exception: false)
|
|
42
|
+
return "" if data == :wait_readable
|
|
43
|
+
if data.nil?
|
|
44
|
+
@eof = true
|
|
45
|
+
else
|
|
46
|
+
vt.feed(data)
|
|
47
|
+
end
|
|
48
|
+
data
|
|
49
|
+
rescue Errno::EIO, EOFError
|
|
50
|
+
@eof = true
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def write(bytes) = @native ? @native.write(bytes) : writer.write(bytes)
|
|
55
|
+
def paste(text) = write(vt.paste(text))
|
|
56
|
+
def key(name, **modifiers) = write(vt.key(name, **modifiers))
|
|
57
|
+
def mouse(**event) = write(vt.mouse(**event))
|
|
58
|
+
|
|
59
|
+
def resize(columns:, rows:)
|
|
60
|
+
grid.resize(columns: columns, rows: rows)
|
|
61
|
+
@native ? @native.resize(columns, rows) : reader.winsize = [rows, columns]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def signal(name = "INT")
|
|
65
|
+
return @native.write("\x03") if @native && name == "INT"
|
|
66
|
+
return @native.close if @native
|
|
67
|
+
Process.kill(name, -pid)
|
|
68
|
+
rescue Errno::ESRCH
|
|
69
|
+
false
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def alive?
|
|
73
|
+
return @native.alive? if @native
|
|
74
|
+
return false if @status
|
|
75
|
+
result = Process.waitpid2(pid, Process::WNOHANG)
|
|
76
|
+
@status = result.last if result
|
|
77
|
+
!result
|
|
78
|
+
rescue Errno::ECHILD
|
|
79
|
+
false
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def close
|
|
83
|
+
return @native.close if @native
|
|
84
|
+
signal("HUP") if alive?
|
|
85
|
+
reader.close unless reader.closed?
|
|
86
|
+
writer.close unless writer.closed?
|
|
87
|
+
# A shell may trap HUP: reap it after a short grace period.
|
|
88
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 0.5
|
|
89
|
+
while alive? && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
|
|
90
|
+
sleep 0.01
|
|
91
|
+
end
|
|
92
|
+
if alive?
|
|
93
|
+
signal("KILL")
|
|
94
|
+
_, @status = Process.waitpid2(pid)
|
|
95
|
+
end
|
|
96
|
+
@eof = true
|
|
97
|
+
self
|
|
98
|
+
rescue Errno::ECHILD
|
|
99
|
+
self
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "denebola"
|
|
4
|
+
|
|
5
|
+
module Canopus
|
|
6
|
+
module Terminal
|
|
7
|
+
class Scrollback
|
|
8
|
+
include Enumerable
|
|
9
|
+
Count = Struct.new(:lines) do
|
|
10
|
+
def self.zero = new(0)
|
|
11
|
+
def +(other) = self.class.new(lines + other.lines)
|
|
12
|
+
end
|
|
13
|
+
Row = Struct.new(:cells) do
|
|
14
|
+
def summary = Count.new(1)
|
|
15
|
+
end
|
|
16
|
+
attr_reader :tree, :limit
|
|
17
|
+
|
|
18
|
+
def initialize(limit)
|
|
19
|
+
raise ArgumentError, "scrollback limit must be nonnegative" unless limit.is_a?(Integer) && limit >= 0
|
|
20
|
+
@limit = limit
|
|
21
|
+
clear
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def clear = @tree = Denebola::Tree.new(summary: Count)
|
|
25
|
+
def length = tree.size
|
|
26
|
+
alias size length
|
|
27
|
+
def [](index) = tree[index]&.cells
|
|
28
|
+
def each(&block) = block ? tree.each { |row| block.call(row.cells) } : enum_for(__method__)
|
|
29
|
+
|
|
30
|
+
def push(cells)
|
|
31
|
+
return if limit.zero?
|
|
32
|
+
frozen = cells.map { |cell| cell.dup.tap { |copy| copy.text = copy.text.dup.freeze }.freeze }.freeze
|
|
33
|
+
@tree = tree.push(Row.new(frozen).freeze)
|
|
34
|
+
@tree = tree.slice(tree.size - limit, limit) if tree.size > limit
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|