canopus 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +33 -50
- data/README.md +32 -159
- data/docs/plugins.md +62 -0
- data/lib/canopus/decoration.rb +1 -1
- data/lib/canopus/plugins/api.rb +5 -1
- data/lib/canopus/plugins/host.rb +576 -0
- data/lib/canopus/plugins/isolated_runtime.rb +81 -8
- data/lib/canopus/plugins/local_runtime.rb +3 -2
- data/lib/canopus/plugins/vocabulary.rb +70 -0
- data/lib/canopus/plugins.rb +3 -0
- data/lib/canopus/provider.rb +1 -1
- data/lib/canopus/settings/schema.rb +9 -2
- data/lib/canopus/settings.rb +11 -0
- data/lib/canopus/version.rb +1 -1
- data/lib/canopus/workspace/file_change_aware.rb +1 -0
- data/lib/canopus/workspace/settings_aware.rb +1 -0
- data/lib/canopus/workspace/view.rb +9 -3
- data/lib/canopus/workspace.rb +44 -3
- data/sig/canopus.rbs +24 -1
- data/tools/check_dependencies.rb +3 -3
- metadata +20 -4
- data/docs/release-log.md +0 -17
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "net/http"
|
|
7
|
+
require "open3"
|
|
8
|
+
require "tempfile"
|
|
9
|
+
require "timeout"
|
|
10
|
+
require "uri"
|
|
11
|
+
|
|
12
|
+
module Canopus
|
|
13
|
+
module Plugins
|
|
14
|
+
# Canopus vocabulary on top of the generic out-of-process host.
|
|
15
|
+
class Host
|
|
16
|
+
API_VERSION = 2
|
|
17
|
+
|
|
18
|
+
attr_reader :runtime
|
|
19
|
+
|
|
20
|
+
def initialize(workspace, sandbox: true, limits: {})
|
|
21
|
+
require_gienah
|
|
22
|
+
@workspace = workspace
|
|
23
|
+
@sandbox = sandbox
|
|
24
|
+
@runtime = Gienah::Host.new(api_version: API_VERSION, sandbox: sandbox, limits: limits)
|
|
25
|
+
@surfaces = {}
|
|
26
|
+
@subscriptions = {}
|
|
27
|
+
@decorations = {}
|
|
28
|
+
@completion_sources = {}
|
|
29
|
+
@status_items = {}
|
|
30
|
+
@storage_lock = Mutex.new
|
|
31
|
+
@activation_threads = {}
|
|
32
|
+
@subscribed_instances = {}
|
|
33
|
+
@selection_subscriptions = {}
|
|
34
|
+
@selection_threads = {}
|
|
35
|
+
@event_lock = Mutex.new
|
|
36
|
+
register_buffer_api
|
|
37
|
+
register_workspace_api
|
|
38
|
+
register_ui_api
|
|
39
|
+
@runtime.on_contribution { |id, contributes| register_contributions(id, contributes) }
|
|
40
|
+
@workspace_event_subscription = @workspace.on_plugin_event { |method, payload| handle_workspace_event(method, payload) }
|
|
41
|
+
@runtime.on_error do |error, instance|
|
|
42
|
+
cleanup_instance(instance.id) if instance&.state == :failed
|
|
43
|
+
@workspace.message = error.message
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def discover(directories) = @runtime.discover(directories)
|
|
48
|
+
def add(manifest) = @runtime.add(manifest)
|
|
49
|
+
|
|
50
|
+
def activate(id, reason:)
|
|
51
|
+
raise PermissionDenied, "workspace is not trusted" unless @workspace.trust.trusted?
|
|
52
|
+
|
|
53
|
+
@runtime.activate(id, reason: reason)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def deactivate(id)
|
|
57
|
+
cleanup_instance(id)
|
|
58
|
+
@runtime.deactivate(id)
|
|
59
|
+
end
|
|
60
|
+
def instances = @runtime.instances
|
|
61
|
+
def shutdown
|
|
62
|
+
@activation_threads.values.each { |thread| thread.kill unless thread.equal?(Thread.current) }
|
|
63
|
+
@activation_threads.clear
|
|
64
|
+
@workspace_event_subscription&.detach
|
|
65
|
+
@selection_threads.each_value { |thread| thread.kill unless thread.equal?(Thread.current) }
|
|
66
|
+
@selection_threads.clear
|
|
67
|
+
@selection_subscriptions.each_value { |_editor, subscription| subscription.detach }
|
|
68
|
+
@selection_subscriptions.clear
|
|
69
|
+
@subscriptions.each_value(&:detach)
|
|
70
|
+
@subscriptions.clear
|
|
71
|
+
@subscribed_instances.clear
|
|
72
|
+
@runtime.shutdown
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def status_items
|
|
76
|
+
@status_items.dup
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def require_gienah
|
|
82
|
+
path = ENV["GIENAH_PATH"]
|
|
83
|
+
if path
|
|
84
|
+
require File.expand_path("lib/gienah", File.expand_path(path))
|
|
85
|
+
else
|
|
86
|
+
require "gienah"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def expose(name, capability: nil, &handler)
|
|
91
|
+
@runtime.expose(name, capability: capability) { |instance, params| handler.call(instance, normalize_params(params)) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def register_buffer_api
|
|
95
|
+
expose("buffer/text", capability: "buffer.read") do |_instance, params|
|
|
96
|
+
buffer = current_buffer
|
|
97
|
+
range = params["range"]
|
|
98
|
+
unless range
|
|
99
|
+
text = buffer.text
|
|
100
|
+
raise Error, "buffer text exceeds 1 MiB; request a range" if text.bytesize > Plugins::BUFFER_CONTEXT_LIMIT
|
|
101
|
+
next text
|
|
102
|
+
end
|
|
103
|
+
first = Integer(range.fetch("start"))
|
|
104
|
+
last = Integer(range.fetch("end"))
|
|
105
|
+
raise ArgumentError, "invalid buffer text range" unless first >= 0 && last >= first && last <= buffer.rope.bytesize
|
|
106
|
+
buffer.text.byteslice(first, last - first).to_s
|
|
107
|
+
end
|
|
108
|
+
expose("buffer/info", capability: "buffer.read") do |_instance, _params|
|
|
109
|
+
buffer = current_buffer
|
|
110
|
+
{"path" => buffer.path, "version" => buffer.version, "bytes" => buffer.rope.bytesize, "read_only" => buffer.read_only}
|
|
111
|
+
end
|
|
112
|
+
expose("buffer/selection", capability: "buffer.read") do |_instance, _params|
|
|
113
|
+
@workspace.editor&.selections.to_a.map { |selection| {"anchor" => selection.anchor, "head" => selection.head} }
|
|
114
|
+
end
|
|
115
|
+
expose("buffer/edit", capability: "buffer.edit") do |_instance, params|
|
|
116
|
+
buffer = current_buffer
|
|
117
|
+
expected = params["version"]
|
|
118
|
+
raise Error, "buffer version is required" unless expected.is_a?(Integer) && expected == buffer.version
|
|
119
|
+
changes = Array(params["changes"])
|
|
120
|
+
raise ArgumentError, "changes must be an Array" unless changes.all? { |change| change.is_a?(Hash) }
|
|
121
|
+
edits = changes.map do |change|
|
|
122
|
+
first = change.fetch("start")
|
|
123
|
+
last = change.fetch("end")
|
|
124
|
+
text = change.fetch("text")
|
|
125
|
+
unless first.is_a?(Integer) && last.is_a?(Integer) && first >= 0 && last >= first && last <= buffer.rope.bytesize && text.is_a?(String)
|
|
126
|
+
raise ArgumentError, "invalid buffer edit"
|
|
127
|
+
end
|
|
128
|
+
[first...last, text]
|
|
129
|
+
end
|
|
130
|
+
buffer.edit(edits, kind: :plugin)
|
|
131
|
+
{"version" => buffer.version}
|
|
132
|
+
end
|
|
133
|
+
expose("buffer/subscribe", capability: "buffer.read") do |instance, params|
|
|
134
|
+
buffer = current_buffer
|
|
135
|
+
requested_uri = params["uri"]
|
|
136
|
+
actual_uri = buffer.path && Sadr::Protocol.uri(buffer.path)
|
|
137
|
+
raise ArgumentError, "buffer URI does not match the active buffer" if requested_uri && requested_uri != actual_uri
|
|
138
|
+
key = [instance.id, buffer.object_id]
|
|
139
|
+
@subscribed_instances[instance.id] = instance
|
|
140
|
+
@subscriptions[key] ||= buffer.on_edit do
|
|
141
|
+
notify_buffer_event(instance, buffer, "buffer/didChange", buffer_change_params(buffer))
|
|
142
|
+
rescue StandardError
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
attach_selection_listener(@workspace.editor) if @workspace.editor&.buffer.equal?(buffer)
|
|
146
|
+
{"version" => buffer.version}
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def register_workspace_api
|
|
151
|
+
expose("workspace/root", capability: "workspace.read") { |_instance, _params| @workspace.root }
|
|
152
|
+
expose("workspace/files", capability: "workspace.read") { |_instance, _params| @workspace.files }
|
|
153
|
+
expose("workspace/open", capability: "ui.command") { |_instance, params| @workspace.open(params.fetch("path")).path }
|
|
154
|
+
expose("workspace/search", capability: "workspace.read") do |_instance, params|
|
|
155
|
+
query = String(params.fetch("query"))
|
|
156
|
+
raise ArgumentError, "search query must not be empty" if query.empty?
|
|
157
|
+
@workspace.files.filter_map do |path|
|
|
158
|
+
next unless File.file?(path)
|
|
159
|
+
next unless File.read(path, 1 << 20, encoding: "UTF-8").include?(query)
|
|
160
|
+
path
|
|
161
|
+
rescue ArgumentError, EncodingError
|
|
162
|
+
nil
|
|
163
|
+
end.first(1_000)
|
|
164
|
+
end
|
|
165
|
+
expose("workspace/notify") { |_instance, params| @workspace.notify(params.fetch("text")); nil }
|
|
166
|
+
expose("storage/get") do |instance, params|
|
|
167
|
+
storage_read(instance.id).fetch(String(params.fetch("key")), nil)
|
|
168
|
+
end
|
|
169
|
+
expose("storage/set") do |instance, params|
|
|
170
|
+
key = storage_key(params.fetch("key"))
|
|
171
|
+
value = params.fetch("value")
|
|
172
|
+
encoded = JSON.generate(value)
|
|
173
|
+
raise ArgumentError, "storage value exceeds 1 MiB" if encoded.bytesize > (1 << 20)
|
|
174
|
+
storage_update(instance.id) { |values| values[key] = value }
|
|
175
|
+
nil
|
|
176
|
+
end
|
|
177
|
+
expose("process/exec") do |instance, params|
|
|
178
|
+
raise PermissionDenied, "plugin requires process.exec" unless instance.granted?("process.exec")
|
|
179
|
+
command = params.fetch("command")
|
|
180
|
+
raise ArgumentError, "command must be a nonempty Array" unless command.is_a?(Array) && !command.empty? && command.all? { |part| part.is_a?(String) && !part.empty? && !part.include?("\0") }
|
|
181
|
+
timeout_ms = Integer(params.fetch("timeout_ms", 5_000))
|
|
182
|
+
raise ArgumentError, "timeout_ms must be between 1 and 30000" unless timeout_ms.between?(1, 30_000)
|
|
183
|
+
output, error, status = execute_process(command, instance, timeout_ms / 1_000.0)
|
|
184
|
+
{"stdout" => output.byteslice(0, 1 << 20).to_s, "stderr" => error.byteslice(0, 1 << 20).to_s,
|
|
185
|
+
"status" => status.exitstatus, "signaled" => status.signaled?}
|
|
186
|
+
end
|
|
187
|
+
expose("net/fetch") do |instance, params|
|
|
188
|
+
uri = URI(String(params.fetch("url")))
|
|
189
|
+
raise ArgumentError, "HTTP or HTTPS URL required" unless %w[http https].include?(uri.scheme) && uri.host
|
|
190
|
+
raise PermissionDenied, "plugin requires net:#{uri.host}" unless instance.granted?("net:#{uri.host}")
|
|
191
|
+
body = +""
|
|
192
|
+
status = nil
|
|
193
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 5) do |http|
|
|
194
|
+
http.request_get(uri.request_uri) do |response|
|
|
195
|
+
status = response.code.to_i
|
|
196
|
+
response.read_body do |chunk|
|
|
197
|
+
raise Error, "plugin HTTP response exceeds 1 MiB" if body.bytesize + chunk.bytesize > (1 << 20)
|
|
198
|
+
body << chunk
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
{"status" => status, "body" => body}
|
|
203
|
+
end
|
|
204
|
+
expose("ui/status", capability: "ui.statusbar") do |instance, params|
|
|
205
|
+
id = String(params.fetch("id"))
|
|
206
|
+
raise ArgumentError, "invalid status item id" unless id.match?(/\A[a-zA-Z0-9_-]{1,64}\z/)
|
|
207
|
+
key = [instance.id, id]
|
|
208
|
+
if params["remove"]
|
|
209
|
+
@status_items.delete(key)
|
|
210
|
+
@workspace.plugin_status_items.delete(key) if @workspace.respond_to?(:plugin_status_items)
|
|
211
|
+
else
|
|
212
|
+
text = String(params.fetch("text"))
|
|
213
|
+
raise ArgumentError, "status item text is too long" if text.bytesize > 1_024
|
|
214
|
+
@status_items[key] = {text: text, priority: Integer(params.fetch("priority", 0)), command: params["command"]}
|
|
215
|
+
@workspace.plugin_status_items[key] = @status_items[key] if @workspace.respond_to?(:plugin_status_items)
|
|
216
|
+
end
|
|
217
|
+
@workspace.window&.request_frame
|
|
218
|
+
nil
|
|
219
|
+
end
|
|
220
|
+
expose("ui/quick_pick", capability: "ui.command") { |_instance, params| queue_plugin_dialog(:quick_pick, params) }
|
|
221
|
+
expose("ui/input", capability: "ui.command") { |_instance, params| queue_plugin_dialog(:input, params) }
|
|
222
|
+
expose("ui/confirm", capability: "ui.command") { |_instance, params| queue_plugin_dialog(:confirm, params) }
|
|
223
|
+
expose("lsp/configure", capability: "process.exec") do |instance, params|
|
|
224
|
+
language = params.fetch("language")
|
|
225
|
+
command = params.fetch("command")
|
|
226
|
+
raise ArgumentError, "command must be a nonempty Array" unless command.is_a?(Array) && !command.empty? && command.all?(String)
|
|
227
|
+
@workspace.configure_plugin_language_server(instance.id, language, command)
|
|
228
|
+
end
|
|
229
|
+
expose("language/register", capability: "language.define") do |_instance, params|
|
|
230
|
+
@workspace.register_language(params.fetch("name"), extensions: Array(params.fetch("extensions")),
|
|
231
|
+
lexer: params.fetch("lexer", "plaintext"), comment: params.fetch("comment", "#"), servers: Array(params.fetch("servers", [])))
|
|
232
|
+
nil
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def register_ui_api
|
|
237
|
+
expose("ui/render", capability: "ui.panel") do |instance, params|
|
|
238
|
+
surface = surface_for(instance.id, params.fetch("panel"))
|
|
239
|
+
surface.replace(params.fetch("tree"))
|
|
240
|
+
@workspace.window&.request_frame
|
|
241
|
+
nil
|
|
242
|
+
end
|
|
243
|
+
expose("ui/patch", capability: "ui.panel") do |instance, params|
|
|
244
|
+
surface_for(instance.id, params.fetch("panel")).apply(params.fetch("patches"))
|
|
245
|
+
@workspace.window&.request_frame
|
|
246
|
+
nil
|
|
247
|
+
end
|
|
248
|
+
expose("decoration/publish", capability: "ui.decoration") do |instance, params|
|
|
249
|
+
source = decoration_source(instance.id, params.fetch("source"))
|
|
250
|
+
items = Array(params.fetch("items"))
|
|
251
|
+
@decorations[source] = items
|
|
252
|
+
@workspace.decorations.register(source) { |buffer, _rows, _context| decoration_items(items, source, buffer) }
|
|
253
|
+
@workspace.decorations.invalidate(source)
|
|
254
|
+
source.to_s
|
|
255
|
+
end
|
|
256
|
+
expose("decoration/clear", capability: "ui.decoration") do |instance, params|
|
|
257
|
+
source = decoration_source(instance.id, params.fetch("source"))
|
|
258
|
+
@decorations.delete(source)
|
|
259
|
+
@workspace.decorations.unregister(source)
|
|
260
|
+
nil
|
|
261
|
+
end
|
|
262
|
+
expose("completion/register", capability: "completion.provide") do |instance, params|
|
|
263
|
+
source = completion_source(instance.id, params.fetch("source"))
|
|
264
|
+
unless @completion_sources.key?(source)
|
|
265
|
+
@completion_sources[source] = true
|
|
266
|
+
@workspace.providers.register_completion(source, priority: Integer(params.fetch("priority", 0))) do |buffer, offset, context|
|
|
267
|
+
response = instance.call("completion/provide", {"offset" => offset, "version" => buffer.version, "context" => context}).await(timeout: 0.2)
|
|
268
|
+
Array(response).map { |item| completion_item(item, source) }
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
source.to_s
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def register_contributions(id, contributes)
|
|
276
|
+
Array(contributes["commands"]).each do |entry|
|
|
277
|
+
next unless entry.is_a?(Hash) && entry["id"] && entry["title"]
|
|
278
|
+
|
|
279
|
+
@workspace.register_action(entry["id"], description: entry["title"]) do
|
|
280
|
+
instance = activate(id, reason: "onCommand:#{entry['id']}")
|
|
281
|
+
instance&.call(entry["id"], {}).then { |_value, error| @workspace.message = error.message if error }
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
Array(contributes["panels"]).each do |entry|
|
|
285
|
+
next unless entry.is_a?(Hash) && entry["id"]
|
|
286
|
+
|
|
287
|
+
side = entry.fetch("dock", "right").to_sym
|
|
288
|
+
panel_id = entry["id"]
|
|
289
|
+
title = String(entry.fetch("title", panel_id))
|
|
290
|
+
@workspace.register_panel(panel_id, title: title, side: side, cache: false) { panel_element(id, panel_id) }
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def surface_for(plugin_id, panel_id)
|
|
295
|
+
key = [plugin_id.to_s, panel_id.to_s]
|
|
296
|
+
@surfaces[key] ||= Zaniah::Describe::Surface.new(
|
|
297
|
+
vocabulary: Vocabulary.build,
|
|
298
|
+
on_event: ->(event_id, payload) {
|
|
299
|
+
instance = @runtime.instances.find { |candidate| candidate.id == plugin_id }
|
|
300
|
+
instance&.notify("ui/event", {"panel" => panel_id, "id" => event_id, "payload" => payload})
|
|
301
|
+
}
|
|
302
|
+
)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def panel_element(plugin_id, panel_id)
|
|
306
|
+
instance = @runtime.instances.find { |candidate| candidate.id == plugin_id }
|
|
307
|
+
unless instance
|
|
308
|
+
activate_panel_async(plugin_id, panel_id)
|
|
309
|
+
return Zaniah::Div.new.flex_col.gap(1).children([Zaniah::UI::Badge.new(plugin_id.to_s), Zaniah::Text.new("Loading #{panel_id}…")])
|
|
310
|
+
end
|
|
311
|
+
surface = surface_for(plugin_id, panel_id)
|
|
312
|
+
element = surface.element || Zaniah::Text.new("Loading #{panel_id}…")
|
|
313
|
+
Zaniah::Div.new.flex_col.gap(1).children([Zaniah::UI::Badge.new(instance.manifest.name), element])
|
|
314
|
+
rescue StandardError => error
|
|
315
|
+
@workspace.message = error.message
|
|
316
|
+
Zaniah::Text.new("Plugin unavailable")
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def decoration_source(plugin_id, source)
|
|
320
|
+
value = String(source)
|
|
321
|
+
raise ArgumentError, "invalid decoration source" unless value.match?(/\A[a-zA-Z0-9_-]{1,64}\z/)
|
|
322
|
+
"plugin_#{plugin_id}_#{value}".to_sym
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def decoration_items(items, source, buffer)
|
|
326
|
+
items.map do |item|
|
|
327
|
+
raise ArgumentError, "decoration item must be an object" unless item.is_a?(Hash)
|
|
328
|
+
range = if item["range"]
|
|
329
|
+
first = Integer(item["range"].fetch("start"))
|
|
330
|
+
last = Integer(item["range"].fetch("end"))
|
|
331
|
+
raise ArgumentError, "decoration range is outside the buffer" unless first >= 0 && last >= first && last <= buffer.rope.bytesize
|
|
332
|
+
first...last
|
|
333
|
+
end
|
|
334
|
+
Decoration::Item.new(item.fetch("kind").to_sym, range, item["row"], item["content"], item["style"],
|
|
335
|
+
item.fetch("priority", 0), source, nil)
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def completion_source(plugin_id, source)
|
|
340
|
+
value = String(source)
|
|
341
|
+
raise ArgumentError, "invalid completion source" unless value.match?(/\A[a-zA-Z0-9_-]{1,64}\z/)
|
|
342
|
+
"plugin_#{plugin_id}_#{value}".to_sym
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def completion_item(item, source)
|
|
346
|
+
raise ArgumentError, "completion item must be an object" unless item.is_a?(Hash)
|
|
347
|
+
Provider::Completion.new(item.fetch("label"), item["insert_text"], item["kind"], item["detail"], item["documentation"],
|
|
348
|
+
item["sort_text"], item["filter_text"], [], source)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def handle_workspace_event(method, payload)
|
|
352
|
+
case method
|
|
353
|
+
when "buffer/didOpen", "buffer/didSave"
|
|
354
|
+
attach_selection_listener(@workspace.editor) if @workspace.editor&.buffer.equal?(payload)
|
|
355
|
+
notify_buffer_event(nil, payload, method, buffer_event_params(payload))
|
|
356
|
+
when "buffer/didClose"
|
|
357
|
+
notify_buffer_event(nil, payload, method, buffer_event_params(payload))
|
|
358
|
+
detach_buffer(payload)
|
|
359
|
+
when "workspace/didChangeFiles", "settings/didChange"
|
|
360
|
+
@subscribed_instances.values.dup.each do |instance|
|
|
361
|
+
instance.notify(method, payload)
|
|
362
|
+
rescue StandardError
|
|
363
|
+
nil
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
rescue StandardError => error
|
|
367
|
+
@workspace.message = error.message
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def buffer_event_params(buffer)
|
|
371
|
+
{"uri" => buffer.path && Sadr::Protocol.uri(buffer.path), "version" => buffer.version}
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def buffer_change_params(buffer)
|
|
375
|
+
transaction = buffer.history.last
|
|
376
|
+
changes = transaction.patch.edits.map do |edit|
|
|
377
|
+
range = Sadr::Protocol.range(buffer.rope, edit.old_range)
|
|
378
|
+
{"range" => {"start" => {"line" => range.start.line, "character" => range.start.character},
|
|
379
|
+
"end" => {"line" => range.end.line, "character" => range.end.character}}, "text" => edit.new_text}
|
|
380
|
+
end
|
|
381
|
+
buffer_event_params(buffer).merge("changes" => changes)
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def notify_buffer_event(instance, buffer, method, params)
|
|
385
|
+
targets = if instance
|
|
386
|
+
[instance]
|
|
387
|
+
else
|
|
388
|
+
@subscriptions.keys.filter_map { |id, object_id| @subscribed_instances[id] if object_id == buffer.object_id }
|
|
389
|
+
end
|
|
390
|
+
targets.uniq.each do |target|
|
|
391
|
+
target.notify(method, params)
|
|
392
|
+
rescue StandardError
|
|
393
|
+
nil
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def attach_selection_listener(editor)
|
|
398
|
+
return unless editor
|
|
399
|
+
key = editor.object_id
|
|
400
|
+
return if @selection_subscriptions.key?(key)
|
|
401
|
+
|
|
402
|
+
subscription = editor.on_selection { queue_selection_event(editor) }
|
|
403
|
+
@selection_subscriptions[key] = [editor, subscription]
|
|
404
|
+
rescue StandardError
|
|
405
|
+
nil
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def queue_selection_event(editor)
|
|
409
|
+
key = editor.object_id
|
|
410
|
+
@event_lock.synchronize do
|
|
411
|
+
return if @selection_threads[key]&.alive?
|
|
412
|
+
|
|
413
|
+
thread = Thread.new do
|
|
414
|
+
sleep 0.1
|
|
415
|
+
selections = editor.selections.map { |selection| {"anchor" => selection.anchor, "head" => selection.head} }
|
|
416
|
+
notify_buffer_event(nil, editor.buffer, "selection/didChange", buffer_event_params(editor.buffer).merge("selections" => selections))
|
|
417
|
+
ensure
|
|
418
|
+
@event_lock.synchronize { @selection_threads.delete(key) if @selection_threads[key].equal?(Thread.current) }
|
|
419
|
+
end
|
|
420
|
+
thread.report_on_exception = false
|
|
421
|
+
@selection_threads[key] = thread
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def detach_buffer(buffer)
|
|
426
|
+
@subscriptions.keys.select { |_id, object_id| object_id == buffer.object_id }.each do |key|
|
|
427
|
+
@subscriptions.delete(key)&.detach
|
|
428
|
+
end
|
|
429
|
+
@selection_subscriptions.keys.each do |key|
|
|
430
|
+
editor, subscription = @selection_subscriptions[key]
|
|
431
|
+
next unless editor.buffer.equal?(buffer)
|
|
432
|
+
|
|
433
|
+
subscription.detach
|
|
434
|
+
@selection_subscriptions.delete(key)
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def cleanup_instance(id)
|
|
439
|
+
@subscriptions.keys.select { |instance_id, _object_id| instance_id == id.to_s }.each do |key|
|
|
440
|
+
@subscriptions.delete(key)&.detach
|
|
441
|
+
end
|
|
442
|
+
@subscribed_instances.delete(id.to_s)
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def current_buffer
|
|
446
|
+
@workspace.editor&.buffer || raise(Error, "no active buffer")
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def normalize_params(params)
|
|
450
|
+
raise ArgumentError, "params must be an object" unless params.is_a?(Hash)
|
|
451
|
+
|
|
452
|
+
params.transform_keys(&:to_s)
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def activate_panel_async(plugin_id, panel_id)
|
|
456
|
+
key = [plugin_id.to_s, panel_id.to_s]
|
|
457
|
+
return if @activation_threads[key]&.alive?
|
|
458
|
+
|
|
459
|
+
@activation_threads[key] = Thread.new do
|
|
460
|
+
begin
|
|
461
|
+
instance = activate(plugin_id, reason: "onPanel:#{panel_id}")
|
|
462
|
+
instance&.notify("ui/activate", {"panel" => panel_id})
|
|
463
|
+
rescue StandardError => error
|
|
464
|
+
@workspace.message = error.message
|
|
465
|
+
ensure
|
|
466
|
+
@activation_threads.delete(key)
|
|
467
|
+
@workspace.window&.request_frame
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
@activation_threads[key].report_on_exception = false
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def queue_plugin_dialog(kind, params)
|
|
474
|
+
raise ArgumentError, "dialog parameters must be an object" unless params.is_a?(Hash)
|
|
475
|
+
@workspace.plugin_dialogs ||= [] if @workspace.respond_to?(:plugin_dialogs=)
|
|
476
|
+
@workspace.plugin_dialogs << {kind: kind, params: params.dup.freeze}.freeze if @workspace.respond_to?(:plugin_dialogs)
|
|
477
|
+
@workspace.window&.request_frame
|
|
478
|
+
nil
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def storage_path(plugin_id)
|
|
482
|
+
File.join(@workspace.root, ".canopus", "plugin-storage", "#{Digest::SHA256.hexdigest(plugin_id.to_s)}.json")
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def storage_key(value)
|
|
486
|
+
key = String(value)
|
|
487
|
+
raise ArgumentError, "storage key must be 1..128 bytes" unless key.bytesize.between?(1, 128) && !key.include?("\0")
|
|
488
|
+
key
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def storage_read(plugin_id)
|
|
492
|
+
path = storage_path(plugin_id)
|
|
493
|
+
value = @storage_lock.synchronize { File.file?(path) ? JSON.parse(File.read(path)) : {} }
|
|
494
|
+
raise Error, "plugin storage is not an object" unless value.is_a?(Hash)
|
|
495
|
+
value
|
|
496
|
+
rescue JSON::ParserError => error
|
|
497
|
+
raise Error, "invalid plugin storage: #{error.message}"
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def storage_update(plugin_id)
|
|
501
|
+
path = storage_path(plugin_id)
|
|
502
|
+
@storage_lock.synchronize do
|
|
503
|
+
values = File.file?(path) ? JSON.parse(File.read(path)) : {}
|
|
504
|
+
raise Error, "plugin storage is not an object" unless values.is_a?(Hash)
|
|
505
|
+
yield(values)
|
|
506
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
507
|
+
Tempfile.create([".storage-", ".json"], File.dirname(path), perm: 0o600) do |file|
|
|
508
|
+
file.write(JSON.generate(values))
|
|
509
|
+
file.flush
|
|
510
|
+
file.fsync
|
|
511
|
+
file.close
|
|
512
|
+
File.chmod(0o600, file.path)
|
|
513
|
+
File.rename(file.path, path)
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def execute_process(command, instance, timeout)
|
|
519
|
+
return Timeout.timeout(timeout) { Open3.capture3(*command, chdir: @workspace.root) } unless @sandbox
|
|
520
|
+
|
|
521
|
+
load_saiph
|
|
522
|
+
raise PermissionDenied, "plugin process sandbox is unavailable" unless Saiph.available?
|
|
523
|
+
|
|
524
|
+
output_reader, output_writer = IO.pipe
|
|
525
|
+
error_reader, error_writer = IO.pipe
|
|
526
|
+
pid = Saiph.spawn(command, policy: process_policy(instance), chdir: @workspace.root,
|
|
527
|
+
in: File::NULL, out: output_writer, err: error_writer)
|
|
528
|
+
output_writer.close
|
|
529
|
+
error_writer.close
|
|
530
|
+
output_thread = Thread.new { read_process_output(output_reader) }
|
|
531
|
+
error_thread = Thread.new { read_process_output(error_reader) }
|
|
532
|
+
status = Timeout.timeout(timeout) { Process.waitpid2(pid).last }
|
|
533
|
+
[output_thread.value, error_thread.value, status]
|
|
534
|
+
rescue Timeout::Error
|
|
535
|
+
begin
|
|
536
|
+
Process.kill("TERM", pid) if pid
|
|
537
|
+
rescue Errno::ESRCH
|
|
538
|
+
nil
|
|
539
|
+
end
|
|
540
|
+
begin
|
|
541
|
+
Process.wait(pid) if pid
|
|
542
|
+
rescue Errno::ECHILD
|
|
543
|
+
nil
|
|
544
|
+
end
|
|
545
|
+
raise
|
|
546
|
+
ensure
|
|
547
|
+
[output_writer, error_writer, output_reader, error_reader].each { |io| io&.close unless io&.closed? }
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def process_policy(instance)
|
|
551
|
+
load_saiph
|
|
552
|
+
reads = instance.granted?("workspace.read") ? [@workspace.root] : []
|
|
553
|
+
network = instance.manifest.capabilities.any? { |capability| capability.start_with?("net:") }
|
|
554
|
+
Saiph::Policy.new(reads, [], network, true, [])
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def read_process_output(io)
|
|
558
|
+
output = +""
|
|
559
|
+
loop do
|
|
560
|
+
chunk = io.readpartial(16 * 1024)
|
|
561
|
+
output << chunk if output.bytesize < (1 << 20)
|
|
562
|
+
end
|
|
563
|
+
rescue EOFError, IOError
|
|
564
|
+
output
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
def load_saiph
|
|
568
|
+
return if defined?(Saiph::Policy)
|
|
569
|
+
|
|
570
|
+
path = ENV["SAIPH_PATH"]
|
|
571
|
+
root = File.expand_path("../..", __dir__)
|
|
572
|
+
path ? require(File.expand_path("lib/saiph", File.expand_path(path, root))) : require("saiph")
|
|
573
|
+
end
|
|
574
|
+
end
|
|
575
|
+
end
|
|
576
|
+
end
|