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
|
@@ -26,7 +26,7 @@ module Canopus
|
|
|
26
26
|
end
|
|
27
27
|
def text
|
|
28
28
|
permit!("read_buffer")
|
|
29
|
-
@context.fetch("text")
|
|
29
|
+
@context.fetch("text") { raise @context.fetch("text_error", "buffer text is not available") }
|
|
30
30
|
end
|
|
31
31
|
def files
|
|
32
32
|
permit!("read_project")
|
|
@@ -96,47 +96,73 @@ module Canopus
|
|
|
96
96
|
RUBY
|
|
97
97
|
|
|
98
98
|
def initialize(workspace, source, path, permissions, timeout: 2)
|
|
99
|
-
@workspace, @
|
|
99
|
+
@workspace, @plugin_id = workspace, path
|
|
100
|
+
@permissions, @timeout = permissions.map { |permission| permission.to_s == "process" ? "exec" : permission.to_s }.uniq, timeout
|
|
101
|
+
@panel_lock = Mutex.new
|
|
102
|
+
@panel_cache, @panel_refreshed, @panel_refreshing, @panel_threads = {}, {}, {}, {}
|
|
103
|
+
@closed = false
|
|
100
104
|
start_process
|
|
101
105
|
@input.sync = true
|
|
102
106
|
@output.binmode
|
|
103
107
|
@pending, @lock = +"".b, Mutex.new
|
|
104
108
|
@input.puts(JSON.generate(source: source, path: path, permissions: @permissions))
|
|
105
109
|
manifest = response
|
|
110
|
+
servers = manifest.fetch("servers")
|
|
111
|
+
if !servers.empty? && !@permissions.include?("exec")
|
|
112
|
+
raise Canopus::Plugins::PermissionDenied, "plugin requires process to configure language servers"
|
|
113
|
+
end
|
|
106
114
|
manifest.fetch("actions").each do |name, description|
|
|
107
115
|
workspace.register_action(name, description: description) { invoke(:action, name) }
|
|
108
116
|
end
|
|
109
117
|
manifest.fetch("panels").each do |name, side|
|
|
110
|
-
workspace.register_panel(name, side: side.to_sym) {
|
|
118
|
+
workspace.register_panel(name, side: side.to_sym, cache: false) { panel_element(name) }
|
|
111
119
|
end
|
|
120
|
+
@panel_names = manifest.fetch("panels").keys
|
|
112
121
|
manifest.fetch("languages").each { |name, options| workspace.register_language(name, **options.transform_keys(&:to_sym)) }
|
|
113
|
-
workspace.
|
|
122
|
+
servers.each { |language, command| workspace.configure_plugin_language_server(@plugin_id, language, command) }
|
|
114
123
|
rescue StandardError
|
|
115
124
|
close
|
|
116
125
|
raise
|
|
117
126
|
end
|
|
118
127
|
def invoke(kind, name)
|
|
119
128
|
@lock.synchronize do
|
|
120
|
-
|
|
121
|
-
|
|
129
|
+
raise Canopus::Error, "plugin runtime is closed" if @closed
|
|
130
|
+
buffer = @workspace.editor&.buffer
|
|
131
|
+
version = buffer&.version
|
|
122
132
|
context = {root: @workspace.root}
|
|
123
|
-
|
|
133
|
+
if @permissions.include?("read_buffer")
|
|
134
|
+
if buffer && buffer.rope.bytesize <= Plugins::BUFFER_CONTEXT_LIMIT
|
|
135
|
+
context[:text] = buffer.text
|
|
136
|
+
else
|
|
137
|
+
context[:text_error] = "plugin buffer text exceeds 1 MiB"
|
|
138
|
+
end
|
|
139
|
+
end
|
|
124
140
|
context[:files] = @workspace.files if @permissions.include?("read_project")
|
|
125
141
|
@input.puts(JSON.generate(kind: kind, name: name, context: context))
|
|
126
142
|
result = response
|
|
127
143
|
edits = result.fetch("edits")
|
|
128
144
|
unless edits.empty?
|
|
129
145
|
raise Canopus::Plugins::PermissionDenied, "plugin requires edit_buffer" unless @permissions.include?("edit_buffer")
|
|
130
|
-
raise Canopus::Error, "buffer changed while plugin was running" unless buffer.version == version
|
|
146
|
+
raise Canopus::Error, "buffer changed while plugin was running" unless buffer && buffer.version == version
|
|
131
147
|
buffer.edit(edits.map { |first, last, text| [first...last, text] }, kind: :plugin)
|
|
132
148
|
end
|
|
133
149
|
@workspace.message = result.fetch("messages").last.to_s unless result.fetch("messages").empty?
|
|
150
|
+
refresh_panels if kind == :action
|
|
134
151
|
result["result"]
|
|
135
152
|
end
|
|
136
153
|
rescue IOError, Errno::EPIPE, EOFError => error
|
|
137
154
|
raise Canopus::Error, "plugin process ended: #{error.message}"
|
|
138
155
|
end
|
|
139
156
|
def close
|
|
157
|
+
threads = @panel_lock.synchronize do
|
|
158
|
+
@closed = true
|
|
159
|
+
@panel_threads.keys
|
|
160
|
+
end
|
|
161
|
+
threads.each do |thread|
|
|
162
|
+
next if thread.equal?(Thread.current)
|
|
163
|
+
thread.kill
|
|
164
|
+
thread.join(0.2)
|
|
165
|
+
end
|
|
140
166
|
@input&.close unless @input&.closed?
|
|
141
167
|
if @process && !@process.join(0.2)
|
|
142
168
|
Process.kill("KILL", @pid) rescue Errno::ESRCH
|
|
@@ -145,6 +171,53 @@ module Canopus
|
|
|
145
171
|
@output&.close unless @output&.closed?
|
|
146
172
|
end
|
|
147
173
|
private
|
|
174
|
+
def panel_element(name)
|
|
175
|
+
request_panel_refresh(name)
|
|
176
|
+
Zaniah::Text.new(@panel_lock.synchronize { @panel_cache.fetch(name, "") })
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def request_panel_refresh(name)
|
|
180
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
181
|
+
@panel_lock.synchronize do
|
|
182
|
+
return if @closed || @panel_refreshing[name]
|
|
183
|
+
return if @panel_refreshed[name] && now - @panel_refreshed[name] < 0.25
|
|
184
|
+
|
|
185
|
+
@panel_refreshing[name] = true
|
|
186
|
+
end
|
|
187
|
+
thread = Thread.new do
|
|
188
|
+
@panel_lock.synchronize do
|
|
189
|
+
if @closed
|
|
190
|
+
@panel_refreshing.delete(name)
|
|
191
|
+
next
|
|
192
|
+
end
|
|
193
|
+
@panel_threads[Thread.current] = true
|
|
194
|
+
end
|
|
195
|
+
begin
|
|
196
|
+
result = invoke(:panel, name)
|
|
197
|
+
@panel_lock.synchronize do
|
|
198
|
+
@panel_cache[name] = result.to_s
|
|
199
|
+
@panel_refreshed[name] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
200
|
+
end
|
|
201
|
+
@workspace.window&.request_frame
|
|
202
|
+
rescue StandardError => error
|
|
203
|
+
@workspace.message = error.message unless @closed
|
|
204
|
+
ensure
|
|
205
|
+
@panel_lock.synchronize do
|
|
206
|
+
@panel_refreshing.delete(name)
|
|
207
|
+
@panel_threads.delete(Thread.current)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
thread
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def refresh_panels
|
|
215
|
+
@panel_names&.each do |name|
|
|
216
|
+
visible = %i[left right bottom].any? { |side| @workspace.panels.active(side).any? { |definition| definition.id == name } }
|
|
217
|
+
request_panel_refresh(name) if visible
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
148
221
|
def start_process
|
|
149
222
|
command = [RbConfig.ruby, "-e", WORKER]
|
|
150
223
|
mode = @workspace.settings["plugins"].fetch("sandbox", "auto")
|
|
@@ -4,7 +4,7 @@ module Canopus
|
|
|
4
4
|
module Plugins
|
|
5
5
|
class LocalRuntime
|
|
6
6
|
def initialize(workspace, source, path, permissions)
|
|
7
|
-
@workspace, @api = workspace, Canopus::Plugins::API.new(workspace, permissions)
|
|
7
|
+
@workspace, @plugin_id, @api = workspace, path, Canopus::Plugins::API.new(workspace, permissions)
|
|
8
8
|
instance_eval(source, path, 1)
|
|
9
9
|
end
|
|
10
10
|
def register_action(name, description: name, &block)
|
|
@@ -17,7 +17,8 @@ module Canopus
|
|
|
17
17
|
end
|
|
18
18
|
def register_language(name, **options) = @workspace.register_language(name, **options)
|
|
19
19
|
def configure_lsp(language, command)
|
|
20
|
-
@
|
|
20
|
+
@api.permit!("process")
|
|
21
|
+
@workspace.configure_plugin_language_server(@plugin_id, language, command)
|
|
21
22
|
end
|
|
22
23
|
def close; end
|
|
23
24
|
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Canopus
|
|
4
|
+
module Plugins
|
|
5
|
+
module Vocabulary
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def build
|
|
9
|
+
require "zaniah/ui"
|
|
10
|
+
Zaniah::Describe::Vocabulary.build do
|
|
11
|
+
node :column, props: {gap: :integer}, children: :many do |props, children|
|
|
12
|
+
Zaniah::Div.new.flex_col.style(gap: props.fetch(:gap, 0)).children(children)
|
|
13
|
+
end
|
|
14
|
+
node :row, props: {gap: :integer}, children: :many do |props, children|
|
|
15
|
+
Zaniah::Div.new.flex_row.style(gap: props.fetch(:gap, 0)).children(children)
|
|
16
|
+
end
|
|
17
|
+
node :text, props: {value: :string}, children: :none do |props|
|
|
18
|
+
Zaniah::Text.new(props.fetch(:value))
|
|
19
|
+
end
|
|
20
|
+
node :button, props: {label: :string, on_click: :handler}, children: :none do |props|
|
|
21
|
+
Zaniah::UI::Button.new(props.fetch(:label)).on_click { |event, _context| props.fetch(:on_click).call(event) }
|
|
22
|
+
end
|
|
23
|
+
node :list, props: {items: :array}, children: :none do |props|
|
|
24
|
+
Zaniah::Div.new.flex_col.children(Array(props.fetch(:items)).map { |item| Zaniah::Text.new(String(item)) })
|
|
25
|
+
end
|
|
26
|
+
node :spacer, props: {size: :number}, children: :none do |props|
|
|
27
|
+
Zaniah::UI::Spacer.new(props[:size])
|
|
28
|
+
end
|
|
29
|
+
node :scroll, props: {axis: %i[vertical horizontal both]}, children: :one do |props, children|
|
|
30
|
+
axis = props.fetch(:axis, :vertical)
|
|
31
|
+
Zaniah::ScrollView.new(axis: axis == :both ? :both : axis).child(children.first)
|
|
32
|
+
end
|
|
33
|
+
node :divider, props: {axis: %i[horizontal vertical]}, children: :none do |props|
|
|
34
|
+
Zaniah::UI::Divider.new(axis: props.fetch(:axis, :horizontal))
|
|
35
|
+
end
|
|
36
|
+
node :icon, props: {name: %i[check close search menu info warning], label: :string}, children: :none do |props|
|
|
37
|
+
Zaniah::UI::Icon.new(props.fetch(:name), label: props[:label])
|
|
38
|
+
end
|
|
39
|
+
node :badge, props: {value: :string, variant: %i[neutral accent success warning danger]}, children: :none do |props|
|
|
40
|
+
Zaniah::UI::Badge.new(props.fetch(:value), variant: props.fetch(:variant, :neutral))
|
|
41
|
+
end
|
|
42
|
+
node :progress, props: {value: :number, min: :number, max: :number, label: :string}, children: :none do |props|
|
|
43
|
+
Zaniah::UI::ProgressBar.new(value: props[:value], min: props.fetch(:min, 0), max: props.fetch(:max, 100), label: props[:label])
|
|
44
|
+
end
|
|
45
|
+
node :code, props: {value: :string}, children: :none do |props|
|
|
46
|
+
Zaniah::Text.new(props.fetch(:value)).style(font_family: :monospace)
|
|
47
|
+
end
|
|
48
|
+
node :checkbox, props: {label: :string, value: :boolean, on_change: :handler}, children: :none do |props|
|
|
49
|
+
Zaniah::UI::Checkbox.new(props.fetch(:label), value: props.fetch(:value, false))
|
|
50
|
+
.on_change { |value, _event, _context| props.fetch(:on_change).call(value) }
|
|
51
|
+
end
|
|
52
|
+
node :text_field, props: {value: :string, placeholder: :string, on_change: :handler}, children: :none do |props|
|
|
53
|
+
Zaniah::UI::TextField.new(props.fetch(:value, ""), placeholder: props[:placeholder])
|
|
54
|
+
.on_change { |value, _context| props.fetch(:on_change).call(value) }
|
|
55
|
+
end
|
|
56
|
+
node :select, props: {items: :array, value: :any, label: :string, on_change: :handler}, children: :none do |props|
|
|
57
|
+
Zaniah::UI::Select.new(props.fetch(:items), value: props[:value], label: props.fetch(:label, "Select"))
|
|
58
|
+
.on_change { |value, _event, _context| props.fetch(:on_change).call(value) }
|
|
59
|
+
end
|
|
60
|
+
node :tree, props: {items: :array}, children: :none do |props|
|
|
61
|
+
Zaniah::UI::TreeView.new(Array(props.fetch(:items)))
|
|
62
|
+
end
|
|
63
|
+
node :section, props: {title: :string}, children: :many do |props, children|
|
|
64
|
+
Zaniah::Div.new.flex_col.gap(2).children([Zaniah::UI::Label.new(props.fetch(:title)), *children])
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
data/lib/canopus/plugins.rb
CHANGED
|
@@ -2,11 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
module Canopus
|
|
4
4
|
module Plugins
|
|
5
|
+
BUFFER_CONTEXT_LIMIT = 1 << 20
|
|
5
6
|
PERMISSIONS = %w[read_buffer edit_buffer read_project write_project exec process network].freeze
|
|
6
7
|
autoload :PermissionDenied, "canopus/plugins/permission_denied"
|
|
7
8
|
autoload :API, "canopus/plugins/api"
|
|
8
9
|
autoload :LocalRuntime, "canopus/plugins/local_runtime"
|
|
9
10
|
autoload :IsolatedRuntime, "canopus/plugins/isolated_runtime"
|
|
11
|
+
autoload :Host, "canopus/plugins/host"
|
|
12
|
+
autoload :Vocabulary, "canopus/plugins/vocabulary"
|
|
10
13
|
autoload :Registry, "canopus/plugins/registry"
|
|
11
14
|
end
|
|
12
15
|
end
|
data/lib/canopus/provider.rb
CHANGED
|
@@ -25,7 +25,8 @@ module Canopus
|
|
|
25
25
|
"persistent_undo" => {"enabled" => true, "max_entries" => 1_000, "expire_days" => 30},
|
|
26
26
|
"format_on_save" => false, "code_actions_on_save" => [], "format_on_save_timeout" => 2_000,
|
|
27
27
|
"git" => {"inline_blame" => "off", "autofetch" => false, "autofetch_interval" => 180},
|
|
28
|
-
"plugins" => {"sandbox" => "auto"
|
|
28
|
+
"plugins" => {"enabled" => true, "sandbox" => "auto", "directories" => [], "disabled" => [],
|
|
29
|
+
"limits" => {"memory_mb" => 256, "request_timeout_ms" => 2_000}, "settings" => {}},
|
|
29
30
|
"recovery" => {"enabled" => true, "interval" => 5_000},
|
|
30
31
|
"dock" => {"left" => {"size" => 220, "visible" => true}, "right" => {"size" => 260, "visible" => false},
|
|
31
32
|
"bottom" => {"size" => 280, "visible" => false},
|
|
@@ -104,7 +105,13 @@ module Canopus
|
|
|
104
105
|
"git" => {"type" => "object", "additionalProperties" => false, "required" => %w[inline_blame autofetch autofetch_interval], "properties" => {
|
|
105
106
|
"inline_blame" => {"type" => "string", "enum" => %w[off cursor all]}, "autofetch" => {"type" => "boolean"}, "autofetch_interval" => {"type" => "integer", "minimum" => 10, "maximum" => 86_400}}},
|
|
106
107
|
"plugins" => {"type" => "object", "additionalProperties" => false, "required" => ["sandbox"], "properties" => {
|
|
107
|
-
"sandbox" => {"type" => "string", "enum" => %w[off auto required]}
|
|
108
|
+
"enabled" => {"type" => "boolean"}, "sandbox" => {"type" => "string", "enum" => %w[off auto required]},
|
|
109
|
+
"directories" => {"type" => "array", "maxItems" => 64, "items" => {"type" => "string", "minLength" => 1}},
|
|
110
|
+
"disabled" => {"type" => "array", "maxItems" => 256, "uniqueItems" => true, "items" => {"type" => "string", "minLength" => 1, "maxLength" => 128, "pattern" => "^[A-Za-z0-9_.-]+$"}},
|
|
111
|
+
"limits" => {"type" => "object", "additionalProperties" => false, "properties" => {
|
|
112
|
+
"memory_mb" => {"type" => "integer", "minimum" => 1, "maximum" => 4096},
|
|
113
|
+
"request_timeout_ms" => {"type" => "integer", "minimum" => 1, "maximum" => 60_000}}},
|
|
114
|
+
"settings" => {"type" => "object"}}},
|
|
108
115
|
"recovery" => {"type" => "object", "additionalProperties" => false, "required" => %w[enabled interval], "properties" => {"enabled" => {"type" => "boolean"}, "interval" => {"type" => "integer", "minimum" => 100, "maximum" => 3_600_000}}},
|
|
109
116
|
"dock" => {"type" => "object", "properties" => {"left" => {"$ref" => "#/$defs/dock"}, "right" => {"$ref" => "#/$defs/dock"}, "bottom" => {"$ref" => "#/$defs/dock"}, "panels" => {"type" => "object", "maxProperties" => 1000, "additionalProperties" => {"$ref" => "#/$defs/panel"}}}},
|
|
110
117
|
"languages" => {"type" => "object", "additionalProperties" => {"allOf" => [{"$ref" => "#"}, {"properties" => {"languages" => false, "debug_adapters" => false, "recovery" => false, "auto_save" => false, "auto_save_delay" => false, "persistent_undo" => false}}]}},
|
data/lib/canopus/settings.rb
CHANGED
|
@@ -293,7 +293,18 @@ module Canopus
|
|
|
293
293
|
def validate_plugins!
|
|
294
294
|
plugins = @values["plugins"]
|
|
295
295
|
raise Error, "plugins must be an object" unless plugins.is_a?(Hash)
|
|
296
|
+
raise Error, "plugins.enabled must be true or false" unless [true, false].include?(plugins["enabled"])
|
|
296
297
|
raise Error, "invalid plugins.sandbox" unless %w[off auto required].include?(plugins["sandbox"])
|
|
298
|
+
directories = plugins["directories"]
|
|
299
|
+
raise Error, "plugins.directories must be an array" unless directories.is_a?(Array) && directories.length <= 64 && directories.all? { |path| path.is_a?(String) && !path.empty? }
|
|
300
|
+
disabled = plugins["disabled"]
|
|
301
|
+
raise Error, "plugins.disabled must be an array" unless disabled.is_a?(Array) && disabled.length <= 256 && disabled.all? { |name| name.is_a?(String) && name.match?(/\A[A-Za-z0-9_.-]{1,128}\z/) }
|
|
302
|
+
limits = plugins["limits"]
|
|
303
|
+
raise Error, "plugins.limits must be an object" unless limits.is_a?(Hash)
|
|
304
|
+
memory, timeout = limits.values_at("memory_mb", "request_timeout_ms")
|
|
305
|
+
raise Error, "invalid plugins.limits.memory_mb" unless memory.is_a?(Integer) && memory.between?(1, 4_096)
|
|
306
|
+
raise Error, "invalid plugins.limits.request_timeout_ms" unless timeout.is_a?(Integer) && timeout.between?(1, 60_000)
|
|
307
|
+
raise Error, "plugins.settings must be an object" unless plugins["settings"].is_a?(Hash)
|
|
297
308
|
end
|
|
298
309
|
|
|
299
310
|
def validate_diagnostics!
|
data/lib/canopus/version.rb
CHANGED
|
@@ -24,6 +24,7 @@ module Canopus
|
|
|
24
24
|
events = @watcher.poll
|
|
25
25
|
invalidate_git unless events.empty?
|
|
26
26
|
refresh_files unless events.empty?
|
|
27
|
+
notify_plugin_event("workspace/didChangeFiles", events.map { |event| {"type" => event.type.to_s, "path" => event.path} }) unless events.empty?
|
|
27
28
|
if events.any? { |event| File.basename(event.path) == ".editorconfig" }
|
|
28
29
|
invalidate_editorconfig
|
|
29
30
|
apply_settings
|
|
@@ -99,6 +99,7 @@ module Canopus
|
|
|
99
99
|
apply_settings
|
|
100
100
|
errors = @settings.paths.filter_map { |path| Settings.file_diagnostics(path)&.last }.flatten
|
|
101
101
|
@message = errors.empty? ? "Settings reloaded" : "Settings unchanged: #{errors.first.message}"
|
|
102
|
+
notify_plugin_event("settings/didChange", {"paths" => @settings.paths})
|
|
102
103
|
rescue StandardError => error
|
|
103
104
|
@settings = old if old
|
|
104
105
|
publish_settings_diagnostics(@settings.paths) if @settings
|
|
@@ -203,9 +203,13 @@ module Canopus
|
|
|
203
203
|
paint_element(badge, Zaniah::Bounds.new(area.right - 40, area.y + 5, 36, 22))
|
|
204
204
|
end
|
|
205
205
|
key = [definition.id, @workspace.editor&.buffer&.object_id, @workspace.editor&.buffer&.version, @theme.object_id]
|
|
206
|
-
@
|
|
207
|
-
|
|
208
|
-
|
|
206
|
+
if @workspace.uncached_panel?(definition.id)
|
|
207
|
+
element = definition.build.call
|
|
208
|
+
else
|
|
209
|
+
@panel_cache ||= {}
|
|
210
|
+
@panel_cache.clear if @panel_cache.length > 50
|
|
211
|
+
element = @panel_cache[key] ||= definition.build.call
|
|
212
|
+
end
|
|
209
213
|
element = Zaniah::Text.new(ui_text(element), color: @theme[:foreground]) unless element.is_a?(Zaniah::Element)
|
|
210
214
|
panel = Zaniah::Div.new.w(area.width).h([area.height - 34, 0].max)
|
|
211
215
|
.test_id("syrma:panel:#{instrumentation_component(definition.id)}").child(element)
|
|
@@ -913,6 +917,8 @@ module Canopus
|
|
|
913
917
|
point = editor.buffer.rope.point_at(editor.primary.head)
|
|
914
918
|
mode = @workspace.settings["vim_mode"] ? @workspace.vim.mode.to_s : "edit"
|
|
915
919
|
left = "#{mode} #{@workspace.message}"
|
|
920
|
+
plugin_status = @workspace.plugin_status_items.values.sort_by { |item| -item.fetch(:priority, 0) }.map { |item| item[:text] }.join(" ")
|
|
921
|
+
left = "#{left} #{plugin_status}" unless plugin_status.empty?
|
|
916
922
|
branch = @workspace.git&.branch
|
|
917
923
|
left = "#{branch} #{left}" if branch
|
|
918
924
|
left = ":#{@workspace.vim.command_line}" if @workspace.settings["vim_mode"] && @workspace.vim.command_line
|
data/lib/canopus/workspace.rb
CHANGED
|
@@ -36,7 +36,8 @@ module Canopus
|
|
|
36
36
|
attr_reader :panes, :active_pane, :buffers, :actions, :commands, :settings, :theme, :project, :clients, :root, :docks, :panels, :decorations, :providers, :minimap, :diagnostics, :breakpoints, :trust
|
|
37
37
|
attr_reader :terminals, :active_terminal_index, :terminal_layout
|
|
38
38
|
attr_accessor :window, :terminal_composition, :selected_project_path, :performance
|
|
39
|
-
attr_reader :message, :palette
|
|
39
|
+
attr_reader :message, :palette, :plugin_dialogs, :plugin_status_items
|
|
40
|
+
attr_writer :plugin_dialogs
|
|
40
41
|
|
|
41
42
|
def initialize(root: Dir.pwd, settings: nil)
|
|
42
43
|
@root = File.realpath(root)
|
|
@@ -50,6 +51,9 @@ module Canopus
|
|
|
50
51
|
@actions = @commands = Command::Registry.new
|
|
51
52
|
@clients, @vim_states = {}, {}
|
|
52
53
|
@message = ""
|
|
54
|
+
@plugin_dialogs = []
|
|
55
|
+
@plugin_status_items = {}
|
|
56
|
+
@plugin_event_listeners = []
|
|
53
57
|
@project = Project.new(@root) if defined?(Project)
|
|
54
58
|
dock = @settings["dock"]
|
|
55
59
|
@docks = %i[left right bottom].to_h do |side|
|
|
@@ -152,6 +156,14 @@ module Canopus
|
|
|
152
156
|
@palette
|
|
153
157
|
end
|
|
154
158
|
def plugins = @plugins ||= Plugins::Registry.new(self)
|
|
159
|
+
def plugin_host = @plugin_host ||= Plugins::Host.new(self)
|
|
160
|
+
def discover_plugin_manifests(directories = nil)
|
|
161
|
+
configured = @settings["plugins"] || {}
|
|
162
|
+
return [] if configured["enabled"] == false
|
|
163
|
+
directories ||= [File.join(ENV["XDG_CONFIG_HOME"] || File.expand_path("~/.config"), "canopus", "plugins"),
|
|
164
|
+
File.join(@root, ".canopus", "plugins"), *Array(configured["directories"])]
|
|
165
|
+
plugin_host.discover(directories)
|
|
166
|
+
end
|
|
155
167
|
def files
|
|
156
168
|
return @files if @files
|
|
157
169
|
@project_entries = @project ? @project.files(include_directories: true).to_a : []
|
|
@@ -162,6 +174,20 @@ module Canopus
|
|
|
162
174
|
notify(@message) unless @message.empty?
|
|
163
175
|
@message
|
|
164
176
|
end
|
|
177
|
+
def on_plugin_event(&listener)
|
|
178
|
+
raise ArgumentError, "plugin event listener required" unless listener
|
|
179
|
+
|
|
180
|
+
@plugin_event_listeners << listener
|
|
181
|
+
Zaniah::Subscription.new { @plugin_event_listeners.delete(listener) }
|
|
182
|
+
end
|
|
183
|
+
def notify_plugin_event(method, payload = nil)
|
|
184
|
+
@plugin_event_listeners.dup.each do |listener|
|
|
185
|
+
listener.call(method, payload)
|
|
186
|
+
rescue StandardError => error
|
|
187
|
+
@message = error.message
|
|
188
|
+
end
|
|
189
|
+
nil
|
|
190
|
+
end
|
|
165
191
|
def notify(text, now: Process.clock_gettime(Process::CLOCK_MONOTONIC))
|
|
166
192
|
@notifications ||= []
|
|
167
193
|
@notification_id = (@notification_id || 0) + 1
|
|
@@ -210,6 +236,7 @@ module Canopus
|
|
|
210
236
|
apply_editor_settings(opened)
|
|
211
237
|
@project_tree&.reveal(absolute.delete_prefix(@root + File::SEPARATOR))
|
|
212
238
|
invalidate_hidden_selection_ranges
|
|
239
|
+
notify_plugin_event("buffer/didOpen", opened.buffer)
|
|
213
240
|
@window&.request_frame
|
|
214
241
|
opened
|
|
215
242
|
end
|
|
@@ -286,6 +313,7 @@ module Canopus
|
|
|
286
313
|
self.message = "File saved; language server notification failed: #{error.message}"
|
|
287
314
|
end
|
|
288
315
|
end
|
|
316
|
+
notify_plugin_event("buffer/didSave", buffer)
|
|
289
317
|
result
|
|
290
318
|
end
|
|
291
319
|
|
|
@@ -323,6 +351,7 @@ module Canopus
|
|
|
323
351
|
opened = @active_pane.open(buffer)
|
|
324
352
|
apply_editor_settings(opened)
|
|
325
353
|
invalidate_hidden_selection_ranges
|
|
354
|
+
notify_plugin_event("buffer/didOpen", opened.buffer)
|
|
326
355
|
opened
|
|
327
356
|
end
|
|
328
357
|
def focus(pane)
|
|
@@ -363,6 +392,7 @@ module Canopus
|
|
|
363
392
|
raise Error, "buffer has unsaved changes" if current.buffer.dirty? && !discard
|
|
364
393
|
pane = @panes.find { |item| item.editors.include?(current) }
|
|
365
394
|
raise Error, "editor is not in workspace" unless pane
|
|
395
|
+
closes_buffer = buffer_refs(current.buffer).length == 1
|
|
366
396
|
closed = if current.buffer.path && !current.buffer.dirty?
|
|
367
397
|
ClosedTab.new(current.buffer.path, current.selections.map { |selection| [selection.anchor, selection.head] },
|
|
368
398
|
current.scroll_x, current.scroll_y, pane.object_id, pane.editors.index(current))
|
|
@@ -382,6 +412,7 @@ module Canopus
|
|
|
382
412
|
pane.close(current, discard: discard, activate: @settings["tabs"]["activate_on_close"].to_sym)
|
|
383
413
|
release_buffer(current.buffer, discard: discard)
|
|
384
414
|
sources.each { |source| release_buffer(source, discard: discard) }
|
|
415
|
+
notify_plugin_event("buffer/didClose", current.buffer) if closes_buffer
|
|
385
416
|
remember_closed_tab(closed) if closed
|
|
386
417
|
close_empty_pane(pane) if pane.editors.empty?
|
|
387
418
|
end
|
|
@@ -674,11 +705,20 @@ module Canopus
|
|
|
674
705
|
dock = @docks.fetch(side)
|
|
675
706
|
dock[:visible] = !dock[:visible]
|
|
676
707
|
end
|
|
677
|
-
def register_panel(name, side: :left, &render)
|
|
678
|
-
definition = @panels.register(Panel::Definition.new(name,
|
|
708
|
+
def register_panel(name, title: name.to_s, side: :left, cache: true, &render)
|
|
709
|
+
definition = @panels.register(Panel::Definition.new(name, title.to_s, nil, side, render, nil))
|
|
710
|
+
(@uncached_panels ||= {})[definition.id] = true unless cache
|
|
679
711
|
register_action("panel.#{name}") { @panels.show(definition.id) }
|
|
680
712
|
definition
|
|
681
713
|
end
|
|
714
|
+
def uncached_panel?(id) = !!@uncached_panels&.key?(id.to_s)
|
|
715
|
+
def configure_plugin_language_server(plugin, language, command)
|
|
716
|
+
language = String(language)
|
|
717
|
+
raise ArgumentError, "language server language must not be empty" if language.empty?
|
|
718
|
+
@settings.merge!("language_servers" => {language => command})
|
|
719
|
+
@message = "Plugin #{File.basename(plugin.to_s)} configured language server #{language}: #{JSON.generate(command)}"
|
|
720
|
+
command
|
|
721
|
+
end
|
|
682
722
|
def register_action(name, description: name, category: name.to_s.split(".", 2).first, condition: "", keybinding: Command::DEFAULT_KEYBINDINGS[name.to_s], &block)
|
|
683
723
|
@commands.register(Command::Definition.new(name.to_s, description, category, condition, block, keybinding))
|
|
684
724
|
end
|
|
@@ -1115,6 +1155,7 @@ module Canopus
|
|
|
1115
1155
|
@palette&.dig(:response)&.fulfill({"applied" => false, "failureReason" => "Workspace closed"})
|
|
1116
1156
|
self.palette = nil
|
|
1117
1157
|
@plugins&.close
|
|
1158
|
+
@plugin_host&.shutdown
|
|
1118
1159
|
failure = nil
|
|
1119
1160
|
cleanup = lambda do |&operation|
|
|
1120
1161
|
operation.call
|
data/sig/canopus.rbs
CHANGED
|
@@ -366,6 +366,8 @@ module Canopus
|
|
|
366
366
|
attr_reader minimap: Minimap
|
|
367
367
|
attr_accessor message: String
|
|
368
368
|
def notify: (String text, ?now: Numeric) -> Integer
|
|
369
|
+
def on_plugin_event: () { (String, untyped) -> void } -> Zaniah::Subscription
|
|
370
|
+
def notify_plugin_event: (String method, ?untyped payload) -> nil
|
|
369
371
|
def notifications: () -> Array[{id: Integer, text: String, expires: Numeric}]
|
|
370
372
|
def dismiss_notification: (Integer id) -> void
|
|
371
373
|
def expire_notifications: (?Numeric now) -> bool
|
|
@@ -409,8 +411,10 @@ module Canopus
|
|
|
409
411
|
def resize_terminal: (Integer columns, Integer rows, ?final: bool, ?now: Numeric, ?terminal: Tarazed::PTY?) -> void
|
|
410
412
|
def flush_terminal_resize: () -> void
|
|
411
413
|
def toggle_dock: (Symbol side) -> bool
|
|
412
|
-
def register_panel: (String name, ?side: Panel::dock) { () -> (Zaniah::Element | String) } -> Panel::Definition
|
|
414
|
+
def register_panel: (String name, ?title: String, ?side: Panel::dock, ?cache: bool) { () -> (Zaniah::Element | String) } -> Panel::Definition
|
|
413
415
|
def register_action: (String name, ?description: String, ?category: String, ?condition: String, ?keybinding: Command::keybinding) { (*untyped) -> untyped } -> Command::Definition
|
|
416
|
+
def uncached_panel?: (String id) -> bool
|
|
417
|
+
def configure_plugin_language_server: (String plugin, String language, untyped command) -> untyped
|
|
414
418
|
def definition_for: (String? path) -> Language::Definition
|
|
415
419
|
def register_language: (String name, extensions: Array[String], ?lexer: String, ?comment: String, ?indent_open: String, ?indent_close: String, ?servers: Array[Array[String]]) -> untyped
|
|
416
420
|
def command_context: (?terminal: bool, ?task_output: bool) -> Command::context
|
|
@@ -481,11 +485,30 @@ module Canopus
|
|
|
481
485
|
def drain: () -> void
|
|
482
486
|
def close: () -> void
|
|
483
487
|
def plugins: () -> Plugins::Registry
|
|
488
|
+
def plugin_host: () -> Plugins::Host
|
|
489
|
+
def discover_plugin_manifests: (?Array[String] directories) -> Array[untyped]
|
|
490
|
+
attr_reader plugin_dialogs: Array[Hash[Symbol, untyped]]
|
|
491
|
+
attr_writer plugin_dialogs: Array[Hash[Symbol, untyped]]
|
|
492
|
+
attr_reader plugin_status_items: Hash[untyped, Hash[Symbol, untyped]]
|
|
484
493
|
end
|
|
485
494
|
|
|
486
495
|
module Plugins
|
|
487
496
|
class PermissionDenied < Error
|
|
488
497
|
end
|
|
498
|
+
class Host
|
|
499
|
+
attr_reader runtime: untyped
|
|
500
|
+
def initialize: (Workspace workspace, ?sandbox: bool, ?limits: Hash[Symbol, Numeric]) -> void
|
|
501
|
+
def discover: (Array[String] directories) -> Array[untyped]
|
|
502
|
+
def add: (untyped manifest) -> untyped
|
|
503
|
+
def activate: (String id, reason: String) -> untyped?
|
|
504
|
+
def deactivate: (String id) -> untyped
|
|
505
|
+
def instances: () -> Array[untyped]
|
|
506
|
+
def shutdown: () -> untyped
|
|
507
|
+
def status_items: () -> Hash[untyped, untyped]
|
|
508
|
+
end
|
|
509
|
+
module Vocabulary
|
|
510
|
+
def self.build: () -> untyped
|
|
511
|
+
end
|
|
489
512
|
class API
|
|
490
513
|
def text: () -> String
|
|
491
514
|
def replace: (Range[Integer] range, String text) -> patch?
|
data/tools/check_dependencies.rb
CHANGED
|
@@ -13,7 +13,7 @@ smoke = ARGV.first && File.expand_path(ARGV.fetch(0))
|
|
|
13
13
|
abort "Usage: ruby tools/check_dependencies.rb [smoke.rb]" if ARGV.length > 1 || (smoke && !File.file?(smoke))
|
|
14
14
|
specification = Gem::Specification.load(File.join(root, "#{entry}.gemspec"))
|
|
15
15
|
dependencies = specification.runtime_dependencies.map(&:name).sort
|
|
16
|
-
expected = %w[alhena alkaid antares denebola editorconfig kochab megrez menkar porrima prism rexml rouge sadr saiph spica tarazed thuban timeout unicode-display_width zaniah]
|
|
16
|
+
expected = %w[alhena alkaid antares denebola editorconfig gienah kochab megrez menkar porrima prism rexml rouge sadr saiph spica tarazed thuban timeout unicode-display_width zaniah]
|
|
17
17
|
abort "unexpected runtime gem dependencies" unless dependencies == expected
|
|
18
18
|
abort "native extension declaration" unless specification.extensions.empty?
|
|
19
19
|
abort "packaged native binary" if specification.files.any? { |path| path.match?(/\.(?:so|bundle|dll|dylib|a|o)\z/i) }
|
|
@@ -32,10 +32,10 @@ Dir.mktmpdir("#{entry}-install-") do |directory|
|
|
|
32
32
|
gem_command = File.join(RbConfig::CONFIG.fetch("bindir"), "gem")
|
|
33
33
|
environment = ENV.each_key.grep(/\ABUNDLE/).to_h { |key| [key, nil] }.merge(
|
|
34
34
|
"GEM_HOME" => installation, "GEM_PATH" => ([installation] + Gem.path).join(File::PATH_SEPARATOR),
|
|
35
|
-
"RUBYLIB" => nil, "RUBYOPT" => nil, "ALKAID_PATH" => nil, "ANTARES_PATH" => nil,
|
|
35
|
+
"RUBYLIB" => nil, "RUBYOPT" => nil, "GIENAH_PATH" => nil, "ZANIAH_PATH" => nil, "ALKAID_PATH" => nil, "ANTARES_PATH" => nil,
|
|
36
36
|
"MEGREZ_PATH" => nil, "MENKAR_PATH" => nil, "SADR_PATH" => nil, "SAIPH_PATH" => nil, "TARAZED_PATH" => nil
|
|
37
37
|
)
|
|
38
|
-
{"ALKAID_PATH" => "alkaid", "ANTARES_PATH" => "antares", "MEGREZ_PATH" => "megrez", "MENKAR_PATH" => "menkar", "SADR_PATH" => "sadr", "SAIPH_PATH" => "saiph",
|
|
38
|
+
{"GIENAH_PATH" => "gienah", "ZANIAH_PATH" => "zaniah", "ALKAID_PATH" => "alkaid", "ANTARES_PATH" => "antares", "MEGREZ_PATH" => "megrez", "MENKAR_PATH" => "menkar", "SADR_PATH" => "sadr", "SAIPH_PATH" => "saiph",
|
|
39
39
|
"TARAZED_PATH" => "tarazed"}.each do |variable, name|
|
|
40
40
|
next unless ENV[variable]
|
|
41
41
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: canopus
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yudai Takada
|
|
@@ -79,6 +79,20 @@ dependencies:
|
|
|
79
79
|
- - "~>"
|
|
80
80
|
- !ruby/object:Gem::Version
|
|
81
81
|
version: 0.2.2
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: gienah
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: 0.1.0
|
|
89
|
+
type: :runtime
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - "~>"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: 0.1.0
|
|
82
96
|
- !ruby/object:Gem::Dependency
|
|
83
97
|
name: kochab
|
|
84
98
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -293,14 +307,14 @@ dependencies:
|
|
|
293
307
|
requirements:
|
|
294
308
|
- - "~>"
|
|
295
309
|
- !ruby/object:Gem::Version
|
|
296
|
-
version: 0.
|
|
310
|
+
version: 0.6.0
|
|
297
311
|
type: :runtime
|
|
298
312
|
prerelease: false
|
|
299
313
|
version_requirements: !ruby/object:Gem::Requirement
|
|
300
314
|
requirements:
|
|
301
315
|
- - "~>"
|
|
302
316
|
- !ruby/object:Gem::Version
|
|
303
|
-
version: 0.
|
|
317
|
+
version: 0.6.0
|
|
304
318
|
email:
|
|
305
319
|
- t.yudai92@gmail.com
|
|
306
320
|
executables:
|
|
@@ -333,8 +347,8 @@ files:
|
|
|
333
347
|
- docs/distribution.md
|
|
334
348
|
- docs/lsp.md
|
|
335
349
|
- docs/performance.md
|
|
350
|
+
- docs/plugins.md
|
|
336
351
|
- docs/providers.md
|
|
337
|
-
- docs/release-log.md
|
|
338
352
|
- docs/snippets.md
|
|
339
353
|
- docs/tasks.md
|
|
340
354
|
- docs/testing.md
|
|
@@ -393,10 +407,12 @@ files:
|
|
|
393
407
|
- lib/canopus/performance_recorder.rb
|
|
394
408
|
- lib/canopus/plugins.rb
|
|
395
409
|
- lib/canopus/plugins/api.rb
|
|
410
|
+
- lib/canopus/plugins/host.rb
|
|
396
411
|
- lib/canopus/plugins/isolated_runtime.rb
|
|
397
412
|
- lib/canopus/plugins/local_runtime.rb
|
|
398
413
|
- lib/canopus/plugins/permission_denied.rb
|
|
399
414
|
- lib/canopus/plugins/registry.rb
|
|
415
|
+
- lib/canopus/plugins/vocabulary.rb
|
|
400
416
|
- lib/canopus/project.rb
|
|
401
417
|
- lib/canopus/project/tree.rb
|
|
402
418
|
- lib/canopus/project/watcher.rb
|
data/docs/release-log.md
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
# Dependency Release Log
|
|
2
|
-
|
|
3
|
-
| Date | Repository | Version | Canopus follow-up | Reason |
|
|
4
|
-
| --- | --- | --- | --- | --- |
|
|
5
|
-
| 2026-09-18 | kochab | 0.2.0 | 2026-09-18 | Settings schema metadata and safe layer merge API |
|
|
6
|
-
| 2026-09-18 | tarazed | 0.2.4 | 2026-09-18 | Explicit command-history clearing for terminal consumers |
|
|
7
|
-
| 2026-09-18 | tarazed | 0.2.3 | 2026-09-18 | Shell executable detection for argument-bearing and Windows commands |
|
|
8
|
-
| 2026-09-17 | thuban | 0.6.0 | 2026-09-17 | Transactional merge-conflict snapshots and resolution |
|
|
9
|
-
| 2026-09-17 | thuban | 0.5.0 | 2026-09-17 | Atomic partial staging and managed Git transfers |
|
|
10
|
-
| 2026-09-17 | thuban | 0.4.1 | 2026-09-17 | Lazy remote provider loading for headless startup |
|
|
11
|
-
| 2026-09-17 | tarazed | 0.2.2 | 2026-09-17 | Drain final Windows ConPTY output after process exit |
|
|
12
|
-
| 2026-09-16 | megrez | 0.1.1 | 2026-09-16 | Debug Adapter Protocol session lifecycle |
|
|
13
|
-
| 2026-09-16 | antares | 0.2.1 | 2026-09-16 | CRLF-safe selection range columns |
|
|
14
|
-
| 2026-09-16 | denebola | 0.2.1 | 2026-09-16 | Deterministic same-offset edit and anchor mapping |
|
|
15
|
-
| 2026-09-15 | thuban | 0.4.0 | 2026-09-17 | Git write, commit, history, stash, and remote APIs |
|
|
16
|
-
| 2026-09-15 | porrima | 0.2.0 | 2026-09-17 | Structured three-way merge conflict resolution |
|
|
17
|
-
| 2026-09-14 | thuban | 0.2.0 | 2026-09-14 | Public `IgnoreMatcher` API |
|