canopus 0.1.0 → 0.2.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.
@@ -11,8 +11,10 @@ require_relative "project/tree"
11
11
 
12
12
  module Canopus
13
13
  class Workspace
14
+ ClosedTab = Data.define(:path, :selections, :scroll_x, :scroll_y, :pane_id, :index)
14
15
  attr_reader :panes, :active_pane, :buffers, :actions, :settings, :theme, :project, :clients, :root, :docks, :panels
15
- attr_accessor :window, :show_project, :terminal, :terminal_visible, :selected_project_path, :performance
16
+ attr_reader :terminals, :active_terminal_index
17
+ attr_accessor :window, :show_project, :terminal_visible, :terminal_composition, :selected_project_path, :performance
16
18
  attr_reader :message, :palette
17
19
 
18
20
  def initialize(root: Dir.pwd, settings: nil)
@@ -25,11 +27,25 @@ module Canopus
25
27
  @actions, @clients, @vim_states = Zaniah::Input::ActionRegistry.new, {}, {}
26
28
  @show_project, @message = true, ""
27
29
  @project = Project.new(@root) if defined?(Project)
28
- @docks = {left: {visible: true, size: 220}, right: {visible: false, size: 260}, bottom: {visible: false, size: 220}}
29
- @panels, @languages = {}, {}
30
+ bottom = @settings["dock"]["bottom"]
31
+ @docks = {left: {visible: true, size: 220}, right: {visible: false, size: 260},
32
+ bottom: {visible: bottom["visible"], size: bottom["size"]}}
33
+ @panels, @languages, @terminals = {}, {}, []
34
+ @active_terminal_index = 0
35
+ @closed_tabs, @terminal_names = [], {}
30
36
  register_actions
31
37
  end
32
38
  def editor = @active_pane.active
39
+ def terminal = @terminals[@active_terminal_index]
40
+ def terminal=(value)
41
+ @terminals.each { |item| item.close if !item.equal?(value) && item.respond_to?(:close) }
42
+ @terminal_names.clear
43
+ @terminal_sizes&.clear
44
+ @terminal_resize_times&.clear
45
+ @terminals = value ? [value] : []
46
+ @active_terminal_index = 0
47
+ value
48
+ end
33
49
  def palette=(value)
34
50
  previous, @palette = @palette, @closed ? nil : value
35
51
  response = previous&.dig(:response)
@@ -158,7 +174,6 @@ module Canopus
158
174
  raise Error, "pane is not in workspace" unless @panes.include?(pane)
159
175
  @vim_states[editor]&.deactivate unless pane.equal?(@active_pane)
160
176
  @active_pane = pane
161
- new_buffer unless editor
162
177
  @window&.request_frame
163
178
  end
164
179
  def activate_tab(pane, tab)
@@ -192,16 +207,78 @@ module Canopus
192
207
  raise Error, "buffer has unsaved changes" if current.buffer.dirty? && !discard
193
208
  pane = @panes.find { |item| item.editors.include?(current) }
194
209
  raise Error, "editor is not in workspace" unless pane
210
+ closed = if current.buffer.path && !current.buffer.dirty?
211
+ ClosedTab.new(current.buffer.path, current.selections.map { |selection| [selection.anchor, selection.head] },
212
+ current.scroll_x, current.scroll_y, pane.object_id, pane.editors.index(current))
213
+ end
214
+ sources = current.buffer.is_a?(MultiBuffer) ? current.buffer.excerpts.map(&:buffer).uniq : []
195
215
  @vim_states.delete(current)&.dispose
196
- pane.close(current, discard: discard)
197
- remaining = @panes.flat_map(&:editors).any? { |item| item.buffer.equal?(current.buffer) }
198
- remaining ||= @buffers.values.grep(MultiBuffer).any? { |multi| multi.excerpts.any? { |excerpt| excerpt.buffer.equal?(current.buffer) } }
199
- unless remaining
200
- close_language_documents(current.buffer)
201
- @buffers.delete_if { |_, buffer| buffer.equal?(current.buffer) }
202
- current.buffer.close
216
+ pane.close(current, discard: discard, activate: @settings["tabs"]["activate_on_close"].to_sym)
217
+ release_buffer(current.buffer, discard: discard)
218
+ sources.each { |source| release_buffer(source, discard: discard) }
219
+ remember_closed_tab(closed) if closed
220
+ close_empty_pane(pane) if pane.editors.empty?
221
+ end
222
+
223
+ def request_close(editors = [editor])
224
+ pending = editors.compact.uniq.select { |current| @panes.any? { |pane| pane.editors.include?(current) } }
225
+ while (current = pending.shift)
226
+ if current.buffer.dirty? && @settings["tabs"]["confirm_on_close_dirty"]
227
+ self.palette = {kind: :confirm_tab_close, query: "Save changes to #{File.basename(current.buffer.path || 'Untitled')}?",
228
+ index: 0, matches: ["Save", "Don't Save", "Cancel"], editor: current, remaining: pending,
229
+ refs: buffer_refs(current.buffer)}
230
+ return false
231
+ end
232
+ close_editor(current, discard: current.buffer.dirty?)
203
233
  end
204
- new_buffer unless editor
234
+ true
235
+ end
236
+
237
+ def resolve_tab_close(choice)
238
+ prompt = @palette
239
+ return unless prompt&.dig(:kind) == :confirm_tab_close
240
+ current = prompt[:editor]
241
+ self.palette = nil
242
+ return false if choice == :cancel
243
+ unless buffer_refs(current.buffer) == prompt[:refs]
244
+ self.message = "Close cancelled because the document is now open elsewhere"
245
+ return false
246
+ end
247
+ if choice == :save && !current.buffer.path
248
+ self.palette = {kind: :save_as, query: +"", index: 0, matches: [], after_save: {editor: current, remaining: prompt[:remaining]}}
249
+ return false
250
+ end
251
+ save_buffer(current.buffer) if choice == :save
252
+ close_editor(current, discard: choice == :discard)
253
+ request_close(prompt[:remaining])
254
+ end
255
+
256
+ def reopen_closed
257
+ while (closed = @closed_tabs.pop)
258
+ next unless File.file?(closed.path)
259
+ pane = @panes.find { |item| item.object_id == closed.pane_id } || @active_pane
260
+ focus(pane)
261
+ current = open(closed.path)
262
+ pane.editors.delete(current)
263
+ pane.editors.insert(closed.index.clamp(0, pane.editors.length), current)
264
+ pane.active_index = pane.editors.index(current)
265
+ closed.selections.each_with_index do |(anchor, head), index|
266
+ current.select(anchor.clamp(0, current.buffer.rope.bytesize), head.clamp(0, current.buffer.rope.bytesize), add: index.positive?)
267
+ end
268
+ current.scroll(dx: closed.scroll_x, dy: closed.scroll_y)
269
+ return current
270
+ end
271
+ nil
272
+ end
273
+
274
+ def buffer_refs(path_or_buffer)
275
+ buffer = path_or_buffer.is_a?(Buffer) ? path_or_buffer : @buffers[canonical_path(path_or_buffer)]
276
+ return [] unless buffer
277
+ refs = @panes.flat_map { |pane| pane.editors.filter_map { |current| [:editor, current.object_id] if current.buffer.equal?(buffer) } }
278
+ @buffers.values.grep(MultiBuffer).each do |multi|
279
+ refs << [:multi_buffer, multi.object_id] if multi.excerpts.any? { |excerpt| excerpt.buffer.equal?(buffer) }
280
+ end
281
+ refs.sort_by { |kind, id| [kind.to_s, id] }
205
282
  end
206
283
  def clear_vim_states
207
284
  @vim_states.each_value(&:dispose)
@@ -221,6 +298,135 @@ module Canopus
221
298
  to.pin(editor) if pinned
222
299
  focus(to)
223
300
  end
301
+
302
+ def new_terminal(cwd: terminal_working_directory)
303
+ options = @settings["terminal"]
304
+ created = Terminal::PTY.new(command: options["shell"] || ENV.fetch("SHELL", "/bin/sh"), cwd: cwd,
305
+ columns: 100, rows: 12, env: options["env"], scrollback: options["scrollback_lines"],
306
+ queue_limit_bytes: options["queue_limit_bytes"])
307
+ @terminals << created
308
+ @active_terminal_index = @terminals.length - 1
309
+ @terminal_visible = true
310
+ resize_terminal(*@terminal_dimensions, final: true) if @terminal_dimensions
311
+ @window&.request_frame
312
+ created
313
+ end
314
+
315
+ def activate_terminal(index)
316
+ raise IndexError, "terminal tab outside panel" unless index.is_a?(Integer) && index.between?(0, @terminals.length - 1)
317
+ @active_terminal_index = index
318
+ resize_terminal(*@terminal_dimensions, final: true) if @terminal_dimensions
319
+ @window&.request_frame
320
+ terminal
321
+ end
322
+
323
+ def move_terminal(from, to)
324
+ raise IndexError, "terminal tab outside panel" unless from.is_a?(Integer) && from.between?(0, @terminals.length - 1) && to.is_a?(Integer) && to.between?(0, @terminals.length - 1)
325
+ current = @terminals.delete_at(from)
326
+ @terminals.insert(to, current)
327
+ @active_terminal_index = to
328
+ current
329
+ end
330
+
331
+ def request_terminal_close(index = @active_terminal_index)
332
+ current = @terminals[index]
333
+ return unless current
334
+ if @settings["terminal"]["confirm_close_running"] && current.respond_to?(:busy?) && current.busy?
335
+ self.palette = {kind: :confirm_terminal_close, query: "A process is still running in this terminal.", index: 1,
336
+ matches: ["Close terminal", "Cancel"], terminal: current}
337
+ return false
338
+ end
339
+ close_terminal(index)
340
+ end
341
+
342
+ def close_terminal(index = @active_terminal_index)
343
+ active = terminal
344
+ current = @terminals.delete_at(index)
345
+ return unless current
346
+ @terminal_names.delete(current)
347
+ @terminal_sizes&.delete(current)
348
+ @terminal_resize_times&.delete(current)
349
+ current.close if current.respond_to?(:close)
350
+ @active_terminal_index = if current.equal?(active)
351
+ [index, @terminals.length - 1].min.clamp(0, @terminals.length)
352
+ else
353
+ @terminals.index(active) || 0
354
+ end
355
+ @terminal_visible = false if @terminals.empty? && @settings["terminal"]["hide_when_empty"]
356
+ @window&.request_frame
357
+ current
358
+ end
359
+
360
+ def restart_terminal(index = @active_terminal_index)
361
+ current = @terminals[index]
362
+ return unless current
363
+ cwd = current.vt.cwd || (current.respond_to?(:initial_cwd) ? current.initial_cwd : @root)
364
+ name = @terminal_names[current]
365
+ close_terminal(index)
366
+ replacement = new_terminal(cwd: File.directory?(cwd) ? cwd : @root)
367
+ move_terminal(@terminals.length - 1, index) if index < @terminals.length - 1
368
+ rename_terminal(name, replacement) if name
369
+ replacement
370
+ end
371
+
372
+ def terminal_title(current = terminal)
373
+ return "Terminal" unless current
374
+ title = @terminal_names[current] || current.vt.title.to_s.then { |value| value.empty? ? nil : value } ||
375
+ current.vt.cwd&.then { |cwd| File.basename(cwd).empty? ? cwd : File.basename(cwd) } ||
376
+ (current.respond_to?(:foreground_process_name) ? current.foreground_process_name : nil) ||
377
+ (current.respond_to?(:command_name) ? current.command_name : nil) || "shell"
378
+ title.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace).scrub.slice(0, 200)
379
+ end
380
+
381
+ def rename_terminal(name, current = terminal)
382
+ return unless current
383
+ value = name.to_s.strip
384
+ value.empty? ? @terminal_names.delete(current) : @terminal_names[current] = value.slice(0, 200)
385
+ end
386
+
387
+ def drain_terminals(now: Process.clock_gettime(Process::CLOCK_MONOTONIC))
388
+ return false if @terminals.empty?
389
+ remaining = @settings["terminal"]["max_bytes_per_frame"]
390
+ deadline = now + 0.004
391
+ changed = false
392
+ order = @terminals.rotate(@terminal_poll_index.to_i % @terminals.length)
393
+ @terminal_poll_index = @terminal_poll_index.to_i + 1
394
+ order.each do |current|
395
+ break if remaining <= 0 || Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
396
+ method = current.method(:read)
397
+ keywords = method.parameters.any? { |kind, _| [:key, :keyreq, :keyrest].include?(kind) }
398
+ data = keywords ? current.read(max_bytes: remaining, max_seconds: [deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max) : current.read
399
+ changed ||= !!(data && !data.empty?)
400
+ remaining -= data.bytesize if data
401
+ end
402
+ reap_exited_terminals
403
+ @window&.request_frame if changed || @terminals.any? { |current| current.respond_to?(:pending?) && current.pending? }
404
+ changed
405
+ end
406
+
407
+ def resize_terminal(columns, rows, final: false, now: Process.clock_gettime(Process::CLOCK_MONOTONIC))
408
+ minimum = @settings["terminal"]
409
+ dimensions = [[columns.to_i, minimum["min_cols"]].max, [rows.to_i, minimum["min_rows"]].max]
410
+ @terminal_dimensions = dimensions
411
+ return if @terminals.empty?
412
+ final ||= @terminal_resize_final
413
+ @terminal_resize_final = false
414
+ wait = minimum["resize_debounce_ms"] / 1000.0
415
+ @terminals.each do |current|
416
+ previous = (@terminal_resize_times ||= {})[current]
417
+ next if !final && previous && now - previous < wait
418
+ next if (@terminal_sizes ||= {})[current] == dimensions
419
+ current.resize(columns: dimensions.first, rows: dimensions.last)
420
+ @terminal_sizes[current] = dimensions
421
+ @terminal_resize_times[current] = now
422
+ end
423
+ end
424
+
425
+ def flush_terminal_resize
426
+ resize_terminal(*@terminal_dimensions, final: true) if @terminal_dimensions
427
+ @terminal_resize_final = true
428
+ end
429
+
224
430
  def toggle_dock(side)
225
431
  dock = @docks.fetch(side)
226
432
  dock[:visible] = !dock[:visible]
@@ -297,6 +503,7 @@ module Canopus
297
503
  end
298
504
  def palette_accept
299
505
  search_state = @palette.slice(:search_options, :selection, :editor, :version)
506
+ after_save = @palette[:after_save]
300
507
  if @palette[:kind] == :snippet_choices
301
508
  current = @palette
302
509
  self.palette = nil
@@ -338,7 +545,12 @@ module Canopus
338
545
  expression, options = search_query(pattern, search_state)
339
546
  @message = "#{replace_in_buffer(expression, query, **options)} replacements (unsaved)"
340
547
  elsif kind == :save_as
341
- save_buffer(path: query)
548
+ current = after_save&.dig(:editor)
549
+ save_buffer(current&.buffer || editor.buffer, path: query)
550
+ if current
551
+ close_editor(current)
552
+ request_close(after_save[:remaining])
553
+ end
342
554
  elsif kind == :rename
343
555
  language_request(:rename, name: query)
344
556
  elsif kind == :workspace_symbols
@@ -394,7 +606,11 @@ module Canopus
394
606
  @plugins&.close
395
607
  @watcher&.close
396
608
  stop_language_servers
397
- @terminal&.close
609
+ terminal_closers = @terminals.filter_map do |current|
610
+ Thread.new { current.close } if current.respond_to?(:close)
611
+ end
612
+ terminal_closers.each(&:join)
613
+ @terminals.clear
398
614
  clear_vim_states
399
615
  @panes.each { |pane| pane.editors.each(&:dispose) }
400
616
  @buffers.each_value(&:close)
@@ -404,7 +620,15 @@ module Canopus
404
620
  def register_actions
405
621
  register_action("file.new") { new_buffer }
406
622
  register_action("file.save") { editor.buffer.path || editor.buffer.is_a?(MultiBuffer) ? save_buffer : palette_open(:save_as) }
407
- register_action("file.close") { close_editor }
623
+ register_action("file.close") { request_close }
624
+ register_action("tab.close") { request_close }
625
+ register_action("tab.close_others") { request_close(@active_pane.editors.reject { |current| current.equal?(editor) }) }
626
+ register_action("tab.close_right") { request_close(@active_pane.editors.drop(@active_pane.active_index + 1)) }
627
+ register_action("tab.close_saved") { request_close(@active_pane.editors.reject { |current| current.buffer.dirty? }) }
628
+ register_action("tab.close_all") { request_close(@active_pane.editors.dup) }
629
+ register_action("tab.reopen_closed") { reopen_closed }
630
+ register_action("pane.close") { request_close(@active_pane.editors.dup) }
631
+ register_action("debug.buffer_refs") { self.message = buffer_refs(editor.buffer).map { |kind, id| "#{kind}:#{id}" }.join(", ") }
408
632
  register_action("file.find") { palette_open(:files) }
409
633
  register_action("project.new_file") { project_prompt(:create_file) }
410
634
  register_action("project.new_folder") { project_prompt(:create_folder) }
@@ -458,9 +682,17 @@ module Canopus
458
682
  clear_vim_states unless @settings["vim_mode"]
459
683
  end
460
684
  register_action("view.terminal") do
461
- @terminal ||= Terminal::PTY.new(cwd: @root, columns: 100, rows: 12)
462
- @terminal_visible = !@terminal_visible
685
+ @terminals.empty? ? new_terminal : @terminal_visible = !@terminal_visible
463
686
  end
687
+ register_action("terminal.toggle") { call("view.terminal") }
688
+ register_action("terminal.new") { new_terminal }
689
+ register_action("terminal.close") { request_terminal_close }
690
+ register_action("terminal.restart") { restart_terminal }
691
+ register_action("terminal.next") { activate_terminal((@active_terminal_index + 1) % @terminals.length) unless @terminals.empty? }
692
+ register_action("terminal.prev") { activate_terminal((@active_terminal_index - 1) % @terminals.length) unless @terminals.empty? }
693
+ 9.times { |index| register_action("terminal.select_#{index + 1}") { activate_terminal(index) if index < @terminals.length } }
694
+ register_action("terminal.rename") { self.palette = {kind: :terminal_rename, query: +"", index: 0, matches: []} if terminal }
695
+ register_action("terminal.clear") { terminal&.grid&.reset }
464
696
  end
465
697
  def encode_layout(node)
466
698
  node[:pane] ? {pane: @panes.index(node[:pane])} : {direction: node[:direction], ratio: node.fetch(:ratio, 0.5), children: node[:children].map { |child| encode_layout(child) }}
@@ -502,6 +734,60 @@ module Canopus
502
734
  else open(command.delete_prefix("e ")) if command.start_with?("e ")
503
735
  end
504
736
  end
737
+
738
+ def remember_closed_tab(closed)
739
+ limit = @settings["tabs"]["reopen_history_limit"]
740
+ return if limit.zero?
741
+ @closed_tabs << closed
742
+ @closed_tabs.shift while @closed_tabs.length > limit
743
+ end
744
+
745
+ def release_buffer(buffer, discard: false)
746
+ return unless buffer_refs(buffer).empty?
747
+ return if buffer.dirty? && !discard
748
+ close_language_documents(buffer)
749
+ @buffers.delete_if { |_, current| current.equal?(buffer) }
750
+ buffer.close
751
+ end
752
+
753
+ def close_empty_pane(pane)
754
+ return unless @settings["tabs"]["close_empty_pane"] && @panes.length > 1
755
+ sibling = nil
756
+ collapse = lambda do |node|
757
+ return nil if node[:pane].equal?(pane)
758
+ return node if node[:pane]
759
+ children = node[:children].map { |child| collapse.call(child) }
760
+ sibling ||= layout_pane(children.compact.first) if children.any?(&:nil?)
761
+ children.compact!
762
+ children.length == 1 ? children.first : node.merge(children: children)
763
+ end
764
+ @layout = collapse.call(@layout)
765
+ @panes.delete(pane)
766
+ @active_pane = sibling || @panes.first if @active_pane.equal?(pane)
767
+ end
768
+
769
+ def layout_pane(node) = node&.dig(:pane) || layout_pane(node&.dig(:children)&.first)
770
+
771
+ def terminal_working_directory
772
+ value = @settings["terminal"]["working_directory"]
773
+ path = case value
774
+ when "project" then @root
775
+ when "current_file" then editor&.buffer&.path ? File.dirname(editor.buffer.path) : @root
776
+ when "home" then Dir.home
777
+ else File.expand_path(value, @root)
778
+ end
779
+ raise Error, "terminal working directory does not exist" unless File.directory?(path)
780
+ path
781
+ end
782
+
783
+ def reap_exited_terminals
784
+ policy = @settings["terminal"]["close_on_exit"]
785
+ @terminals.dup.each do |current|
786
+ next unless current.respond_to?(:alive?) && !current.alive?
787
+ clean = !current.respond_to?(:status) || current.status&.success?
788
+ close_terminal(@terminals.index(current)) if policy == "always" || (policy == "clean" && clean)
789
+ end
790
+ end
505
791
  end
506
792
  end
507
793
 
data/lib/canopus.rb CHANGED
@@ -9,6 +9,8 @@ require_relative "canopus/save_conflict"
9
9
 
10
10
  require "denebola"
11
11
  require "zaniah"
12
+ require "porrima"
13
+ require "thuban"
12
14
  require_relative "canopus/patch"
13
15
  require_relative "canopus/buffer"
14
16
  require_relative "canopus/multi_buffer"
@@ -17,7 +19,6 @@ require_relative "canopus/editor"
17
19
  require_relative "canopus/language"
18
20
  require "spica"
19
21
  require_relative "canopus/project"
20
- require_relative "canopus/git"
21
22
  require_relative "canopus/terminal"
22
23
  require_relative "canopus/workspace"
23
24
  require_relative "canopus/plugins"
data/sig/canopus.rbs CHANGED
@@ -292,12 +292,21 @@ module Canopus
292
292
  def open: (Buffer buffer) -> Editor
293
293
  def activate: (Integer index) -> void
294
294
  def pin: (?Editor editor) -> untyped
295
- def close: (?Editor editor, ?discard: bool) -> void
295
+ def close: (?Editor editor, ?discard: bool, ?activate: Symbol) -> void
296
296
  def detach: (Editor editor) -> void
297
297
  def back: () -> untyped
298
298
  def forward: () -> untyped
299
299
  end
300
300
  class Workspace
301
+ class ClosedTab < Data
302
+ attr_reader path: String
303
+ attr_reader selections: Array[[Integer, Integer]]
304
+ attr_reader scroll_x: Numeric
305
+ attr_reader scroll_y: Numeric
306
+ attr_reader pane_id: Integer
307
+ attr_reader index: Integer
308
+ def self.new: (String path, Array[[Integer, Integer]] selections, Numeric scroll_x, Numeric scroll_y, Integer pane_id, Integer index) -> instance
309
+ end
301
310
  def initialize: (?root: String, ?settings: Settings?) -> void
302
311
  attr_reader panes: Array[Pane]
303
312
  attr_reader active_pane: Pane
@@ -313,6 +322,9 @@ module Canopus
313
322
  def expire_notifications: (?Numeric now) -> bool
314
323
  attr_accessor show_project: bool
315
324
  attr_accessor terminal_visible: bool?
325
+ attr_accessor terminal_composition: Zaniah::Input::Composition?
326
+ attr_reader terminals: Array[Terminal::PTY]
327
+ attr_reader active_terminal_index: Integer
316
328
  attr_accessor performance: PerformanceRecorder?
317
329
  def editor: () -> Editor?
318
330
  def files: () -> Array[String]
@@ -326,8 +338,23 @@ module Canopus
326
338
  def activate_tab: (Pane pane, Editor tab) -> void
327
339
  def split: (?Symbol direction) -> Pane
328
340
  def close_editor: (?Editor current, ?discard: bool) -> void
341
+ def request_close: (?Array[Editor?] editors) -> bool
342
+ def resolve_tab_close: (:save | :discard | :cancel choice) -> bool?
343
+ def reopen_closed: () -> Editor?
344
+ def buffer_refs: (String | Buffer path_or_buffer) -> Array[[Symbol, Integer]]
329
345
  def clear_vim_states: () -> void
330
346
  def move_tab: (from: Pane, to: Pane, ?editor: Editor, ?index: Integer?) -> void
347
+ def new_terminal: (?cwd: String) -> Terminal::PTY
348
+ def activate_terminal: (Integer index) -> Terminal::PTY
349
+ def move_terminal: (Integer from, Integer to) -> Terminal::PTY
350
+ def request_terminal_close: (?Integer index) -> (Terminal::PTY | false | nil)
351
+ def close_terminal: (?Integer index) -> Terminal::PTY?
352
+ def restart_terminal: (?Integer index) -> Terminal::PTY?
353
+ def terminal_title: (?Terminal::PTY? current) -> String
354
+ def rename_terminal: (String name, ?Terminal::PTY? current) -> String?
355
+ def drain_terminals: (?now: Numeric) -> bool
356
+ def resize_terminal: (Integer columns, Integer rows, ?final: bool, ?now: Numeric) -> void
357
+ def flush_terminal_resize: () -> void
331
358
  def toggle_dock: (Symbol side) -> bool
332
359
  def register_panel: (String name, ?side: Symbol) { () -> (Zaniah::Element | String) } -> untyped
333
360
  def register_action: (String name, ?description: String) { (*untyped) -> untyped } -> untyped
data/sig/controller.rbs CHANGED
@@ -1,6 +1,7 @@
1
1
  module Canopus
2
2
  class Controller
3
3
  SHORTCUTS: Hash[String, String]
4
+ TERMINAL_SHORTCUTS: Hash[String, String]
4
5
  attr_reader workspace: Workspace
5
6
  attr_reader view: Workspace::View
6
7
  attr_reader window: Zaniah::Platform::Headless::Window
data/sig/terminal.rbs CHANGED
@@ -110,8 +110,11 @@ module Canopus
110
110
  attr_reader grid: Grid
111
111
  attr_reader vt: VT
112
112
  attr_reader status: Process::Status?
113
- def initialize: (?command: String | Array[String], ?cwd: String, ?columns: Integer, ?rows: Integer, ?env: Hash[String, String?], ?scrollback: Integer) -> void
114
- def read: (?timeout: Numeric?, ?max_bytes: Integer) -> String?
113
+ attr_reader initial_cwd: String
114
+ attr_reader command_name: String
115
+ def initialize: (?command: String | Array[String], ?cwd: String, ?columns: Integer, ?rows: Integer, ?env: Hash[String, String?], ?scrollback: Integer, ?queue_limit_bytes: Integer) -> void
116
+ def read: (?timeout: Numeric?, ?max_bytes: Integer, ?max_seconds: Numeric?) -> String?
117
+ def pending?: () -> bool
115
118
  def write: (String bytes) -> Integer
116
119
  def paste: (String text) -> Integer
117
120
  def key: (String | Symbol name, ?shift: bool, ?alt: bool, ?control: bool) -> Integer
@@ -119,6 +122,8 @@ module Canopus
119
122
  def resize: (columns: Integer, rows: Integer) -> void
120
123
  def signal: (?String | Integer name) -> void
121
124
  def alive?: () -> bool
125
+ def busy?: () -> bool
126
+ def foreground_process_name: (?now: Numeric) -> String?
122
127
  def close: () -> void
123
128
  end
124
129
  end
@@ -60,12 +60,14 @@ module Canopus
60
60
  def accept_language_result: (palette_state palette, Integer index) -> void
61
61
  def show_diagnostics: () -> void
62
62
  def show_outline: () -> void
63
- def git: () -> Git::Repository?
63
+ def git: () -> Thuban::Repository?
64
64
  def git_status: () -> Hash[String, String]
65
65
  def invalidate_git: () -> nil
66
66
  def poll_git_changes: (?now: Numeric) -> void
67
67
  def git_relative_path: (?Buffer buffer) -> String
68
- def git_hunks: (?Buffer buffer, ?async: bool) -> Array[Git::Diff::Hunk]
68
+ def git_diff: (?Buffer buffer, ?async: bool) -> Porrima::Diff?
69
+ def git_hunks: (?Buffer buffer, ?async: bool) -> Array[Porrima::Hunk]
70
+ def git_gutter_marks: (?Buffer buffer) -> Array[Porrima::Mark]
69
71
  def show_git_diff: () -> Language::Document
70
72
  def toggle_git_hunk: (?Editor current, ?row: Integer?) -> bool
71
73
  def show_git_blame: () -> String
@@ -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 antares denebola kochab prism rouge spica unicode-display_width zaniah]
16
+ expected = %w[alhena antares denebola kochab porrima prism rouge spica thuban 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) }
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: canopus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yudai Takada
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-09-12 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: alhena
@@ -65,6 +66,20 @@ dependencies:
65
66
  - - "~>"
66
67
  - !ruby/object:Gem::Version
67
68
  version: 0.1.0
69
+ - !ruby/object:Gem::Dependency
70
+ name: porrima
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: 0.1.0
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: 0.1.0
68
83
  - !ruby/object:Gem::Dependency
69
84
  name: prism
70
85
  requirement: !ruby/object:Gem::Requirement
@@ -107,6 +122,20 @@ dependencies:
107
122
  - - "~>"
108
123
  - !ruby/object:Gem::Version
109
124
  version: 0.1.0
125
+ - !ruby/object:Gem::Dependency
126
+ name: thuban
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: 0.1.0
132
+ type: :runtime
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: 0.1.0
110
139
  - !ruby/object:Gem::Dependency
111
140
  name: unicode-display_width
112
141
  requirement: !ruby/object:Gem::Requirement
@@ -135,6 +164,7 @@ dependencies:
135
164
  - - "~>"
136
165
  - !ruby/object:Gem::Version
137
166
  version: 0.1.0
167
+ description:
138
168
  email:
139
169
  - t.yudai92@gmail.com
140
170
  executables:
@@ -145,11 +175,16 @@ files:
145
175
  - CHANGELOG.md
146
176
  - LICENSE.txt
147
177
  - README.md
178
+ - assets/canopus-editor.png
179
+ - assets/logo.png
180
+ - docs/adr/000-template.md
148
181
  - docs/adr/001-runtime-gem-components.md
149
182
  - docs/adr/002-bounded-diff-engine.md
150
183
  - docs/adr/003-background-language-analysis.md
151
184
  - docs/adr/004-persistent-display-map.md
152
185
  - docs/adr/005-windows-runtime.md
186
+ - docs/adr/006-tab-close-lifetime.md
187
+ - docs/adr/007-terminal-key-routing.md
153
188
  - docs/adr/README.md
154
189
  - docs/distribution.md
155
190
  - docs/lsp.md
@@ -177,17 +212,6 @@ files:
177
212
  - lib/canopus/editor/snippet_expandable.rb
178
213
  - lib/canopus/error.rb
179
214
  - lib/canopus/fold_map.rb
180
- - lib/canopus/git.rb
181
- - lib/canopus/git/blame.rb
182
- - lib/canopus/git/commit.rb
183
- - lib/canopus/git/corrupt_object.rb
184
- - lib/canopus/git/diff.rb
185
- - lib/canopus/git/index.rb
186
- - lib/canopus/git/object_database.rb
187
- - lib/canopus/git/pack.rb
188
- - lib/canopus/git/repository.rb
189
- - lib/canopus/git/status.rb
190
- - lib/canopus/git/tree_entry.rb
191
215
  - lib/canopus/icon_theme.rb
192
216
  - lib/canopus/language.rb
193
217
  - lib/canopus/language/background_analysis.rb
@@ -270,7 +294,6 @@ files:
270
294
  - sig/canopus.rbs
271
295
  - sig/controller.rbs
272
296
  - sig/display_stages.rbs
273
- - sig/git.rbs
274
297
  - sig/lsp.rbs
275
298
  - sig/markdown.rbs
276
299
  - sig/performance_recorder.rbs
@@ -294,6 +317,7 @@ metadata:
294
317
  changelog_uri: https://github.com/noxdea/canopus/blob/main/CHANGELOG.md
295
318
  allowed_push_host: https://rubygems.org
296
319
  rubygems_mfa_required: 'true'
320
+ post_install_message:
297
321
  rdoc_options: []
298
322
  require_paths:
299
323
  - lib
@@ -308,7 +332,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
308
332
  - !ruby/object:Gem::Version
309
333
  version: '0'
310
334
  requirements: []
311
- rubygems_version: 4.0.19
335
+ rubygems_version: 3.3.27
336
+ signing_key:
312
337
  specification_version: 4
313
338
  summary: A pure Ruby text editor with persistent buffers and language tools
314
339
  test_files: []