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.
@@ -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, @permissions, @timeout = workspace, permissions.map { |permission| permission.to_s == "process" ? "exec" : permission.to_s }.uniq, timeout
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) { Zaniah::Text.new(invoke(:panel, name).to_s) }
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.settings.merge!("language_servers" => manifest.fetch("servers"))
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
- buffer = @workspace.editor.buffer
121
- version = buffer.version
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
- context[:text] = buffer.text if @permissions.include?("read_buffer")
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
- @workspace.settings.merge!("language_servers" => {language => command})
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
@@ -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
@@ -9,7 +9,7 @@ module Canopus
9
9
  MAX_PROVIDERS = 64
10
10
  MAX_ITEMS = 10_000
11
11
  MAX_STRING = 65_536
12
- PROVIDER_TIMEOUT = 10
12
+ PROVIDER_TIMEOUT = 0.2
13
13
 
14
14
  def initialize
15
15
  @completion, @inline, @sequence = {}, {}, 0
@@ -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}}]}},
@@ -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!
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Canopus
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
@@ -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
- @panel_cache ||= {}
207
- @panel_cache.clear if @panel_cache.length > 50
208
- element = @panel_cache[key] ||= definition.build.call
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
@@ -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, name.to_s, nil, side, render, nil))
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?
@@ -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.5.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.5.2
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.5.2
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 |