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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +118 -86
- data/assets/canopus-editor.png +0 -0
- data/assets/logo.png +0 -0
- data/docs/adr/000-template.md +24 -0
- data/docs/adr/002-bounded-diff-engine.md +4 -4
- data/docs/adr/006-tab-close-lifetime.md +25 -0
- data/docs/adr/007-terminal-key-routing.md +24 -0
- data/lib/canopus/controller.rb +101 -23
- data/lib/canopus/language/syntax_worker.rb +1 -0
- data/lib/canopus/pane.rb +18 -2
- data/lib/canopus/settings.rb +53 -1
- data/lib/canopus/terminal/pty.rb +92 -17
- data/lib/canopus/version.rb +1 -1
- data/lib/canopus/workspace/git_aware.rb +25 -20
- data/lib/canopus/workspace/session_persistable.rb +32 -1
- data/lib/canopus/workspace/view/terminal_presentable.rb +57 -17
- data/lib/canopus/workspace/view.rb +22 -10
- data/lib/canopus/workspace.rb +303 -17
- data/lib/canopus.rb +2 -1
- data/sig/canopus.rbs +28 -1
- data/sig/controller.rbs +1 -0
- data/sig/terminal.rbs +7 -2
- data/sig/workspace_services.rbs +4 -2
- data/tools/check_dependencies.rb +1 -1
- metadata +40 -15
- data/lib/canopus/git/blame.rb +0 -50
- data/lib/canopus/git/commit.rb +0 -7
- data/lib/canopus/git/corrupt_object.rb +0 -7
- data/lib/canopus/git/diff.rb +0 -131
- data/lib/canopus/git/index.rb +0 -94
- data/lib/canopus/git/object_database.rb +0 -45
- data/lib/canopus/git/pack.rb +0 -186
- data/lib/canopus/git/repository.rb +0 -313
- data/lib/canopus/git/status.rb +0 -74
- data/lib/canopus/git/tree_entry.rb +0 -7
- data/lib/canopus/git.rb +0 -15
- data/sig/git.rbs +0 -140
data/lib/canopus/workspace.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
29
|
-
@
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
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") {
|
|
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
|
-
@
|
|
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
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
|
-
|
|
114
|
-
|
|
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
|
data/sig/workspace_services.rbs
CHANGED
|
@@ -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: () ->
|
|
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
|
|
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
|
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 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.
|
|
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:
|
|
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:
|
|
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: []
|