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/controller.rb
CHANGED
|
@@ -5,7 +5,8 @@ require_relative "workspace/view"
|
|
|
5
5
|
module Canopus
|
|
6
6
|
class Controller
|
|
7
7
|
SHORTCUTS = {"cmd-s" => "file.save", "ctrl-s" => "file.save", "cmd-n" => "file.new", "ctrl-n" => "file.new",
|
|
8
|
-
"cmd-w" => "
|
|
8
|
+
"cmd-w" => "tab.close", "ctrl-w" => "tab.close", "cmd-alt-w" => "tab.close_all", "ctrl-alt-w" => "tab.close_all",
|
|
9
|
+
"cmd-shift-t" => "tab.reopen_closed", "ctrl-shift-t" => "tab.reopen_closed", "cmd-p" => "file.find", "ctrl-p" => "file.find",
|
|
9
10
|
"cmd-shift-p" => "command.palette", "ctrl-shift-p" => "command.palette", "cmd-z" => "edit.undo", "ctrl-z" => "edit.undo",
|
|
10
11
|
"cmd-shift-z" => "edit.redo", "ctrl-shift-z" => "edit.redo", "cmd-a" => "edit.select_all", "ctrl-a" => "edit.select_all",
|
|
11
12
|
"cmd-d" => "edit.select_next", "ctrl-d" => "edit.select_next", "cmd-f" => "search.buffer", "ctrl-f" => "search.buffer",
|
|
@@ -13,10 +14,15 @@ module Canopus
|
|
|
13
14
|
"alt-up" => "edit.move_line_up", "alt-down" => "edit.move_line_down",
|
|
14
15
|
"cmd-shift-f" => "search.project", "ctrl-shift-f" => "search.project", "cmd-alt-f" => "search.replace", "ctrl-h" => "search.replace",
|
|
15
16
|
"cmd-/" => "edit.toggle_comment", "ctrl-/" => "edit.toggle_comment", "cmd-b" => "view.project", "ctrl-b" => "view.project",
|
|
16
|
-
"ctrl-`" => "view.terminal", "cmd-\\" => "pane.split_right", "ctrl-\\" => "pane.split_right",
|
|
17
|
+
"ctrl-`" => "view.terminal", "ctrl-shift-`" => "terminal.new", "cmd-\\" => "pane.split_right", "ctrl-\\" => "pane.split_right",
|
|
17
18
|
"ctrl-space" => "language.completion", "f12" => "language.definition", "shift-f12" => "language.references",
|
|
18
19
|
"f2" => "language.rename", "alt-enter" => "language.codeAction", "cmd-shift-o" => "language.outline",
|
|
19
20
|
"ctrl-shift-o" => "language.outline", "cmd-k" => "language.hover", "ctrl-k" => "language.hover"}.freeze
|
|
21
|
+
TERMINAL_SHORTCUTS = {"ctrl-`" => "terminal.toggle", "ctrl-shift-`" => "terminal.new", "cmd-w" => "terminal.close",
|
|
22
|
+
"ctrl-w" => "terminal.close", "cmd-c" => "terminal.copy", "ctrl-shift-c" => "terminal.copy",
|
|
23
|
+
"cmd-v" => "terminal.paste", "ctrl-shift-v" => "terminal.paste", "cmd-shift-p" => "command.palette",
|
|
24
|
+
"ctrl-shift-p" => "command.palette", "ctrl-shift-]" => "terminal.next", "ctrl-shift-[" => "terminal.prev",
|
|
25
|
+
"cmd-k" => "terminal.clear"}.merge((1..9).to_h { |index| ["cmd-#{index}", "terminal.select_#{index}"] }).freeze
|
|
20
26
|
attr_reader :workspace, :view, :window, :keymap
|
|
21
27
|
def initialize(workspace, window)
|
|
22
28
|
@workspace, @window, @view = workspace, window, Workspace::View.new(workspace)
|
|
@@ -27,8 +33,7 @@ module Canopus
|
|
|
27
33
|
window.on_close { close_requested }
|
|
28
34
|
window.on_appearance { |_| workspace.apply_settings if workspace.settings["theme"] == "auto" } if window.respond_to?(:on_appearance)
|
|
29
35
|
window.on_tick do
|
|
30
|
-
|
|
31
|
-
window.request_frame if data && !data.empty?
|
|
36
|
+
workspace.drain_terminals
|
|
32
37
|
workspace.drain
|
|
33
38
|
workspace.poll_changes
|
|
34
39
|
workspace.poll_settings
|
|
@@ -65,7 +70,12 @@ module Canopus
|
|
|
65
70
|
when Zaniah::Input::TextInput
|
|
66
71
|
input_text(event.text)
|
|
67
72
|
when Zaniah::Input::Composition
|
|
68
|
-
@workspace.
|
|
73
|
+
if @terminal_focus && @workspace.terminal_visible
|
|
74
|
+
@workspace.terminal_composition = event
|
|
75
|
+
else
|
|
76
|
+
@workspace.new_buffer unless @workspace.editor
|
|
77
|
+
@workspace.editor.composition = event
|
|
78
|
+
end
|
|
69
79
|
when Zaniah::Input::KeyDown
|
|
70
80
|
key(event.keystroke)
|
|
71
81
|
when Zaniah::Input::MouseDown
|
|
@@ -81,6 +91,7 @@ module Canopus
|
|
|
81
91
|
terminal_mouse(event, :move)
|
|
82
92
|
end
|
|
83
93
|
when Zaniah::Input::MouseUp
|
|
94
|
+
@workspace.flush_terminal_resize if @resize_drag&.first == :dock_resize
|
|
84
95
|
@resize_drag = nil
|
|
85
96
|
@scroll_drag = nil
|
|
86
97
|
if @drag_file
|
|
@@ -92,6 +103,10 @@ module Canopus
|
|
|
92
103
|
@drag_file = nil
|
|
93
104
|
end
|
|
94
105
|
terminal_mouse(event, :release) if @terminal_drag && @terminal_drag != :selection
|
|
106
|
+
if @terminal_drag == :selection && @workspace.settings["terminal"]["copy_on_select"]
|
|
107
|
+
@clipboard = @view.terminal_selected_text
|
|
108
|
+
@window.clipboard = @clipboard if @window.respond_to?(:clipboard=)
|
|
109
|
+
end
|
|
95
110
|
@terminal_drag = nil
|
|
96
111
|
if @drag_tab
|
|
97
112
|
from, editor, origin = @drag_tab
|
|
@@ -103,6 +118,14 @@ module Canopus
|
|
|
103
118
|
end
|
|
104
119
|
@drag_tab = nil
|
|
105
120
|
end
|
|
121
|
+
if @drag_terminal_tab
|
|
122
|
+
from, origin = @drag_terminal_tab
|
|
123
|
+
target = @view.hit(event.position)
|
|
124
|
+
if target&.first == :terminal_tab && (event.position.x - origin.x).abs + (event.position.y - origin.y).abs > 10
|
|
125
|
+
@workspace.move_terminal(from, target[1])
|
|
126
|
+
end
|
|
127
|
+
@drag_terminal_tab = nil
|
|
128
|
+
end
|
|
106
129
|
@drag = nil
|
|
107
130
|
when Zaniah::Input::ScrollWheel
|
|
108
131
|
scroll(event)
|
|
@@ -114,16 +137,20 @@ module Canopus
|
|
|
114
137
|
end
|
|
115
138
|
def input_text(text)
|
|
116
139
|
if @workspace.palette
|
|
117
|
-
return if [:workspace_edit, :confirm_close, :trash_file].include?(@workspace.palette[:kind])
|
|
140
|
+
return if [:workspace_edit, :confirm_close, :confirm_tab_close, :confirm_terminal_close, :confirm_terminal_paste, :trash_file].include?(@workspace.palette[:kind])
|
|
118
141
|
@workspace.palette[:query] << text
|
|
119
142
|
@workspace.update_palette
|
|
120
143
|
elsif @terminal_focus && @workspace.terminal_visible
|
|
144
|
+
@workspace.terminal_composition = nil
|
|
121
145
|
@workspace.terminal.write(text)
|
|
122
|
-
elsif @workspace.settings["vim_mode"]
|
|
123
|
-
text.each_grapheme_cluster { |character| @workspace.vim.feed(character) }
|
|
124
146
|
else
|
|
125
|
-
@workspace.
|
|
126
|
-
@workspace.
|
|
147
|
+
@workspace.new_buffer unless @workspace.editor
|
|
148
|
+
if @workspace.settings["vim_mode"]
|
|
149
|
+
text.each_grapheme_cluster { |character| @workspace.vim.feed(character) }
|
|
150
|
+
else
|
|
151
|
+
@workspace.editor.composition = nil
|
|
152
|
+
@workspace.editor.insert_text(text)
|
|
153
|
+
end
|
|
127
154
|
end
|
|
128
155
|
end
|
|
129
156
|
def key(stroke)
|
|
@@ -132,13 +159,27 @@ module Canopus
|
|
|
132
159
|
stroke = Zaniah::Input::Keystroke.normalize(stroke)
|
|
133
160
|
return palette_key(stroke) if @workspace.palette
|
|
134
161
|
return if @composing && (stroke.split("-") & %w[ctrl cmd]).empty?
|
|
135
|
-
|
|
136
|
-
if ["cmd-q", "ctrl-q"].include?(stroke)
|
|
162
|
+
if stroke == "cmd-q" || stroke == "ctrl-q" && !(@terminal_focus && @workspace.terminal_visible)
|
|
137
163
|
return @window.close
|
|
138
164
|
end
|
|
139
|
-
if @terminal_focus && @workspace.terminal_visible &&
|
|
140
|
-
if
|
|
141
|
-
return @workspace.
|
|
165
|
+
if @terminal_focus && @workspace.terminal_visible && @workspace.terminal
|
|
166
|
+
if stroke == "enter" && @workspace.terminal.respond_to?(:alive?) && !@workspace.terminal.alive?
|
|
167
|
+
return @workspace.restart_terminal
|
|
168
|
+
end
|
|
169
|
+
mode = @workspace.settings["vim_mode"] && @workspace.editor ? @workspace.vim.mode.to_s : false
|
|
170
|
+
action = @keymap.dispatch(stroke, context: {"Terminal" => true, "Editor" => false, "vim_mode" => mode})
|
|
171
|
+
if action && action != :pending
|
|
172
|
+
if action == "terminal.paste"
|
|
173
|
+
return terminal_paste(@window.respond_to?(:clipboard) ? @window.clipboard.to_s : @clipboard)
|
|
174
|
+
elsif action == "terminal.copy"
|
|
175
|
+
@clipboard = @view.terminal_selected_text
|
|
176
|
+
@window.clipboard = @clipboard if @window.respond_to?(:clipboard=)
|
|
177
|
+
return
|
|
178
|
+
end
|
|
179
|
+
@drag_terminal_tab = nil if action == "terminal.close"
|
|
180
|
+
return @workspace.call(action)
|
|
181
|
+
elsif action == :pending
|
|
182
|
+
return
|
|
142
183
|
elsif ["cmd-c", "ctrl-shift-c"].include?(stroke)
|
|
143
184
|
@clipboard = @view.terminal_selected_text
|
|
144
185
|
@window.clipboard = @clipboard if @window.respond_to?(:clipboard=)
|
|
@@ -150,23 +191,26 @@ module Canopus
|
|
|
150
191
|
name = {"esc" => "escape", "pageup" => "page_up", "pagedown" => "page_down", "space" => " "}.fetch(name, name)
|
|
151
192
|
return @workspace.terminal.key(name, control: parts.include?("ctrl"), alt: parts.include?("alt"), shift: parts.include?("shift"))
|
|
152
193
|
end
|
|
153
|
-
|
|
154
|
-
|
|
194
|
+
return @workspace.settings_completions if stroke == "ctrl-space" && @workspace.editor && @workspace.settings_document?(@workspace.editor.buffer)
|
|
195
|
+
mode = @workspace.settings["vim_mode"] && @workspace.editor ? @workspace.vim.mode.to_s : false
|
|
196
|
+
action = @keymap.dispatch(stroke, context: {"Editor" => !!@workspace.editor, "vim_mode" => mode})
|
|
155
197
|
if action
|
|
156
198
|
# Native adapters deliver text separately after printable key down.
|
|
157
199
|
# Cocoa also sends an empty Composition before that ordinary commit.
|
|
158
200
|
@suppress_key_text = stroke.match?(/\A(?:(?:alt|shift)-)*(?:space|[^\x00-\x1f\x7f])\z/)
|
|
201
|
+
@drag_tab = nil if action.to_s.start_with?("tab.close") || action == "pane.close"
|
|
159
202
|
return action == :pending ? nil : @workspace.call(action)
|
|
160
203
|
end
|
|
161
204
|
if ["cmd-c", "ctrl-c", "cmd-x", "ctrl-x", "cmd-v", "ctrl-v"].include?(stroke) && !(@workspace.settings["vim_mode"] && stroke == "ctrl-v")
|
|
162
205
|
return clipboard(stroke.split("-").last)
|
|
163
206
|
end
|
|
164
|
-
if ["tab", "shift-tab"].include?(stroke) && @workspace.editor
|
|
207
|
+
if ["tab", "shift-tab"].include?(stroke) && @workspace.editor&.snippet_active?
|
|
165
208
|
stroke == "tab" ? @workspace.editor.next_snippet : @workspace.editor.previous_snippet
|
|
166
209
|
return @workspace.show_snippet_choices
|
|
167
210
|
end
|
|
168
211
|
parts = stroke.split("-")
|
|
169
212
|
name = parts.pop
|
|
213
|
+
@workspace.new_buffer unless @workspace.editor
|
|
170
214
|
if @workspace.settings["vim_mode"]
|
|
171
215
|
@workspace.vim.feed(stroke) if name.length > 1 || parts.include?("ctrl")
|
|
172
216
|
return
|
|
@@ -285,6 +329,7 @@ module Canopus
|
|
|
285
329
|
unless @keymap_groups == groups
|
|
286
330
|
replacement = Zaniah::Input::Keymap.new
|
|
287
331
|
SHORTCUTS.each { |keys, action| replacement.bind(keys, action) }
|
|
332
|
+
TERMINAL_SHORTCUTS.each { |keys, action| replacement.bind(keys, action, context: "Terminal") }
|
|
288
333
|
groups.each do |group|
|
|
289
334
|
group.fetch("bindings").each { |keys, action| replacement.bind(keys, action, context: group.fetch("context")) }
|
|
290
335
|
end
|
|
@@ -295,7 +340,7 @@ module Canopus
|
|
|
295
340
|
end
|
|
296
341
|
def palette_input(event)
|
|
297
342
|
return false unless @workspace.palette
|
|
298
|
-
@drag = @resize_drag = @scroll_drag = @terminal_drag = @drag_file = @drag_tab = nil
|
|
343
|
+
@drag = @resize_drag = @scroll_drag = @terminal_drag = @drag_file = @drag_tab = @drag_terminal_tab = nil
|
|
299
344
|
case event
|
|
300
345
|
when Zaniah::Input::KeyDown then key(event.keystroke)
|
|
301
346
|
when Zaniah::Input::TextInput then input_text(event.text)
|
|
@@ -341,7 +386,7 @@ module Canopus
|
|
|
341
386
|
palette[:details_scroll] = (palette.fetch(:details_scroll, 0) + (stroke == "pageup" ? -step : step)).clamp(0, maximum)
|
|
342
387
|
end
|
|
343
388
|
when "backspace"
|
|
344
|
-
return if [:workspace_edit, :confirm_close, :trash_file].include?(palette[:kind])
|
|
389
|
+
return if [:workspace_edit, :confirm_close, :confirm_tab_close, :confirm_terminal_close, :confirm_terminal_paste, :trash_file].include?(palette[:kind])
|
|
345
390
|
palette[:query] = palette[:query].grapheme_clusters[0...-1].join
|
|
346
391
|
@workspace.update_palette
|
|
347
392
|
when "enter"
|
|
@@ -373,6 +418,19 @@ module Canopus
|
|
|
373
418
|
when 1 then @discard_close = true; @window.close
|
|
374
419
|
else @workspace.palette = nil
|
|
375
420
|
end
|
|
421
|
+
elsif palette[:kind] == :confirm_tab_close
|
|
422
|
+
@workspace.resolve_tab_close([:save, :discard, :cancel].fetch(palette[:index]))
|
|
423
|
+
elsif palette[:kind] == :confirm_terminal_close
|
|
424
|
+
index = @workspace.terminals.index(palette[:terminal])
|
|
425
|
+
@workspace.palette = nil
|
|
426
|
+
@workspace.close_terminal(index) if palette[:index].zero? && index
|
|
427
|
+
elsif palette[:kind] == :confirm_terminal_paste
|
|
428
|
+
value = palette[:text]
|
|
429
|
+
@workspace.palette = nil
|
|
430
|
+
@workspace.terminal&.paste(value) if palette[:index].zero?
|
|
431
|
+
elsif palette[:kind] == :terminal_rename
|
|
432
|
+
@workspace.rename_terminal(palette[:query])
|
|
433
|
+
@workspace.palette = nil
|
|
376
434
|
else
|
|
377
435
|
@workspace.palette_accept
|
|
378
436
|
end
|
|
@@ -391,7 +449,7 @@ module Canopus
|
|
|
391
449
|
return
|
|
392
450
|
end
|
|
393
451
|
end
|
|
394
|
-
@terminal_focus =
|
|
452
|
+
@terminal_focus = [:terminal, :terminal_tab, :terminal_close].include?(kind)
|
|
395
453
|
case kind
|
|
396
454
|
when :git_hunk then @workspace.toggle_git_hunk(args[0], row: args[1]) if event.button == :left
|
|
397
455
|
when :hover_link
|
|
@@ -419,12 +477,23 @@ module Canopus
|
|
|
419
477
|
@terminal_drag = :selection
|
|
420
478
|
@view.terminal_select(event.position)
|
|
421
479
|
end
|
|
480
|
+
when :terminal_tab
|
|
481
|
+
@workspace.activate_terminal(args.first)
|
|
482
|
+
@drag_terminal_tab = [args.first, event.position] if event.button == :left
|
|
483
|
+
when :terminal_close
|
|
484
|
+
@workspace.request_terminal_close(args.first) if event.button == :left
|
|
422
485
|
when :file then @workspace.open(args.first)
|
|
423
486
|
when :directory then @workspace.project_tree.toggle(args.first)
|
|
424
487
|
when :tab
|
|
425
488
|
pane, editor = args
|
|
426
|
-
@workspace.
|
|
427
|
-
|
|
489
|
+
if event.button == :middle && @workspace.settings["tabs"]["close_on_middle_click"]
|
|
490
|
+
@workspace.request_close([editor])
|
|
491
|
+
elsif event.button == :left
|
|
492
|
+
@workspace.activate_tab(pane, editor)
|
|
493
|
+
@drag_tab = [pane, editor, event.position]
|
|
494
|
+
end
|
|
495
|
+
when :tab_close
|
|
496
|
+
@workspace.request_close([args.last]) if event.button == :left
|
|
428
497
|
when :editor
|
|
429
498
|
pane, editor = args
|
|
430
499
|
@workspace.focus(pane)
|
|
@@ -489,6 +558,15 @@ module Canopus
|
|
|
489
558
|
@workspace.terminal.mouse(button: @terminal_drag, column: column, row: row, action: action,
|
|
490
559
|
shift: modifiers.include?("shift"), alt: modifiers.include?("alt"), control: modifiers.include?("ctrl"))
|
|
491
560
|
end
|
|
561
|
+
|
|
562
|
+
def terminal_paste(text)
|
|
563
|
+
if @workspace.settings["terminal"]["confirm_multiline_paste"] && text.match?(/[\r\n].*[\r\n]?/m)
|
|
564
|
+
@workspace.palette = {kind: :confirm_terminal_paste, query: "Paste multiple lines into the terminal?", index: 1,
|
|
565
|
+
matches: ["Paste", "Cancel"], text: text}
|
|
566
|
+
else
|
|
567
|
+
@workspace.terminal.paste(text)
|
|
568
|
+
end
|
|
569
|
+
end
|
|
492
570
|
def resize_drag(point)
|
|
493
571
|
kind, target, bounds = @resize_drag
|
|
494
572
|
if kind == :split_resize
|
data/lib/canopus/pane.rb
CHANGED
|
@@ -27,13 +27,29 @@ module Canopus
|
|
|
27
27
|
def pin(editor = active)
|
|
28
28
|
@pinned << editor unless @pinned.include?(editor)
|
|
29
29
|
end
|
|
30
|
-
def close(editor = active, discard: false)
|
|
30
|
+
def close(editor = active, discard: false, activate: :history)
|
|
31
31
|
raise Error, "buffer has unsaved changes" if editor.buffer.dirty? && !discard
|
|
32
|
+
index = @editors.index(editor)
|
|
33
|
+
raise Error, "tab is not in pane" unless index
|
|
34
|
+
current = active
|
|
35
|
+
historical = @past.reverse.find { |item| !item.equal?(editor) && @editors.include?(item) }
|
|
32
36
|
@pinned.delete(editor)
|
|
33
37
|
@editors.delete(editor)
|
|
34
38
|
@past.delete(editor)
|
|
35
39
|
@future.delete(editor)
|
|
36
|
-
@
|
|
40
|
+
@future.clear if current.equal?(editor)
|
|
41
|
+
@active_index = if @editors.empty?
|
|
42
|
+
0
|
|
43
|
+
elsif !current.equal?(editor)
|
|
44
|
+
@editors.index(current)
|
|
45
|
+
elsif activate == :history && historical
|
|
46
|
+
@past.delete(historical)
|
|
47
|
+
@editors.index(historical)
|
|
48
|
+
elsif activate == :left
|
|
49
|
+
[index - 1, 0].max
|
|
50
|
+
else
|
|
51
|
+
[index, @editors.length - 1].min
|
|
52
|
+
end
|
|
37
53
|
editor.dispose
|
|
38
54
|
end
|
|
39
55
|
def detach(editor)
|
data/lib/canopus/settings.rb
CHANGED
|
@@ -7,7 +7,15 @@ module Canopus
|
|
|
7
7
|
class Settings
|
|
8
8
|
DEFAULTS = {"font_size" => 14, "tab_size" => 4, "use_tabs" => false, "soft_wrap" => false, "vim_mode" => false, "keymap" => [].freeze,
|
|
9
9
|
"scroll_friction" => 12,
|
|
10
|
-
"theme" => "Canopus Dark", "font_family" => nil, "icon_theme" => nil, "languages" => {}, "language_servers" => {}
|
|
10
|
+
"theme" => "Canopus Dark", "font_family" => nil, "icon_theme" => nil, "languages" => {}, "language_servers" => {},
|
|
11
|
+
"tabs" => {"activate_on_close" => "history", "close_on_middle_click" => true, "close_empty_pane" => true,
|
|
12
|
+
"reopen_history_limit" => 20, "confirm_on_close_dirty" => true}.freeze,
|
|
13
|
+
"dock" => {"bottom" => {"size" => 280, "visible" => false}.freeze}.freeze,
|
|
14
|
+
"terminal" => {"shell" => nil, "working_directory" => "project", "env" => {}.freeze, "scrollback_lines" => 10_000,
|
|
15
|
+
"font_size" => nil, "line_height" => 1.2, "copy_on_select" => false, "blinking" => "terminal_controlled", "cursor_shape" => "block",
|
|
16
|
+
"close_on_exit" => "clean", "confirm_close_running" => true, "confirm_multiline_paste" => true,
|
|
17
|
+
"restore_on_startup" => false, "hide_when_empty" => false, "max_bytes_per_frame" => 262_144,
|
|
18
|
+
"queue_limit_bytes" => 8_388_608, "resize_debounce_ms" => 100, "min_rows" => 4, "min_cols" => 20}.freeze}.freeze
|
|
11
19
|
SCHEMA = {"$schema" => "https://json-schema.org/draft/2020-12/schema", "type" => "object", "properties" => {
|
|
12
20
|
"font_size" => {"type" => "number", "minimum" => 6, "maximum" => 96},
|
|
13
21
|
"tab_size" => {"type" => "integer", "minimum" => 1, "maximum" => 16},
|
|
@@ -19,6 +27,7 @@ module Canopus
|
|
|
19
27
|
"bindings" => {"type" => "object", "maxProperties" => 1024, "additionalProperties" => {"type" => ["string", "null"]}}}}},
|
|
20
28
|
"theme" => {"type" => "string"}, "font_family" => {"type" => ["string", "null"]},
|
|
21
29
|
"icon_theme" => {"type" => ["string", "null"]},
|
|
30
|
+
"tabs" => {"type" => "object"}, "terminal" => {"type" => "object"}, "dock" => {"type" => "object"},
|
|
22
31
|
"languages" => {"type" => "object", "additionalProperties" => {"$ref" => "#"}},
|
|
23
32
|
"language_servers" => {"type" => "object"}}}.freeze
|
|
24
33
|
attr_reader :values, :errors, :layers
|
|
@@ -85,6 +94,9 @@ module Canopus
|
|
|
85
94
|
raise Error, "theme must be a string" unless @values["theme"].is_a?(String)
|
|
86
95
|
raise Error, "font_family must be a string or null" unless @values["font_family"].nil? || @values["font_family"].is_a?(String)
|
|
87
96
|
raise Error, "icon_theme must be a string or null" unless @values["icon_theme"].nil? || @values["icon_theme"].is_a?(String)
|
|
97
|
+
validate_tabs!
|
|
98
|
+
validate_terminal!
|
|
99
|
+
validate_dock!
|
|
88
100
|
@values["languages"] = @values["languages"].to_h do |name, layer|
|
|
89
101
|
raise Error, "language settings must be objects" unless name.is_a?(String) && layer.is_a?(Hash)
|
|
90
102
|
raise Error, "language settings cannot contain nested languages" if layer.key?("languages")
|
|
@@ -114,5 +126,45 @@ module Canopus
|
|
|
114
126
|
rescue ArgumentError => error
|
|
115
127
|
raise Error, "invalid keymap: #{error.message}"
|
|
116
128
|
end
|
|
129
|
+
|
|
130
|
+
def validate_tabs!
|
|
131
|
+
tabs = @values["tabs"]
|
|
132
|
+
raise Error, "tabs must be an object" unless tabs.is_a?(Hash)
|
|
133
|
+
raise Error, "invalid tabs.activate_on_close" unless %w[history neighbour left right].include?(tabs["activate_on_close"])
|
|
134
|
+
%w[close_on_middle_click close_empty_pane confirm_on_close_dirty].each do |key|
|
|
135
|
+
raise Error, "tabs.#{key} must be true or false" unless [true, false].include?(tabs[key])
|
|
136
|
+
end
|
|
137
|
+
limit = tabs["reopen_history_limit"]
|
|
138
|
+
raise Error, "invalid tabs.reopen_history_limit" unless limit.is_a?(Integer) && limit.between?(0, 1000)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def validate_terminal!
|
|
142
|
+
terminal = @values["terminal"]
|
|
143
|
+
raise Error, "terminal must be an object" unless terminal.is_a?(Hash)
|
|
144
|
+
raise Error, "invalid terminal.shell" unless terminal["shell"].nil? || terminal["shell"].is_a?(String) ||
|
|
145
|
+
(terminal["shell"].is_a?(Array) && terminal["shell"].all? { |value| value.is_a?(String) })
|
|
146
|
+
raise Error, "invalid terminal.working_directory" unless terminal["working_directory"].is_a?(String)
|
|
147
|
+
raise Error, "invalid terminal.env" unless terminal["env"].is_a?(Hash) && terminal["env"].all? { |key, value| key.is_a?(String) && (value.nil? || value.is_a?(String)) }
|
|
148
|
+
raise Error, "invalid terminal.font_size" unless terminal["font_size"].nil? || terminal["font_size"].is_a?(Numeric) && terminal["font_size"].between?(6, 96)
|
|
149
|
+
raise Error, "invalid terminal.line_height" unless terminal["line_height"].is_a?(Numeric) && terminal["line_height"].between?(0.5, 4)
|
|
150
|
+
%w[confirm_close_running confirm_multiline_paste copy_on_select restore_on_startup hide_when_empty].each do |key|
|
|
151
|
+
raise Error, "terminal.#{key} must be true or false" unless [true, false].include?(terminal[key])
|
|
152
|
+
end
|
|
153
|
+
raise Error, "invalid terminal.close_on_exit" unless %w[never clean always].include?(terminal["close_on_exit"])
|
|
154
|
+
raise Error, "invalid terminal.blinking" unless %w[off on terminal_controlled].include?(terminal["blinking"])
|
|
155
|
+
raise Error, "invalid terminal.cursor_shape" unless %w[block bar underline].include?(terminal["cursor_shape"])
|
|
156
|
+
{"scrollback_lines" => 0..1_000_000, "max_bytes_per_frame" => 1..16_777_216,
|
|
157
|
+
"queue_limit_bytes" => 65_536..268_435_456, "resize_debounce_ms" => 0..10_000,
|
|
158
|
+
"min_rows" => 1..1000, "min_cols" => 1..1000}.each do |key, range|
|
|
159
|
+
value = terminal[key]
|
|
160
|
+
raise Error, "invalid terminal.#{key}" unless value.is_a?(Integer) && range.cover?(value)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def validate_dock!
|
|
165
|
+
dock = @values["dock"]
|
|
166
|
+
bottom = dock["bottom"] if dock.is_a?(Hash)
|
|
167
|
+
raise Error, "invalid dock.bottom" unless bottom.is_a?(Hash) && bottom["size"].is_a?(Numeric) && bottom["size"].positive? && [true, false].include?(bottom["visible"])
|
|
168
|
+
end
|
|
117
169
|
end
|
|
118
170
|
end
|
data/lib/canopus/terminal/pty.rb
CHANGED
|
@@ -6,9 +6,13 @@ require "shellwords"
|
|
|
6
6
|
module Canopus
|
|
7
7
|
module Terminal
|
|
8
8
|
class PTY
|
|
9
|
-
attr_reader :reader, :writer, :pid, :grid, :vt, :status
|
|
9
|
+
attr_reader :reader, :writer, :pid, :grid, :vt, :status, :initial_cwd, :command_name
|
|
10
10
|
|
|
11
|
-
def initialize(command: ENV.fetch("SHELL", "/bin/sh"), cwd: Dir.pwd, columns: 80, rows: 24, env: {}, scrollback: 10_000
|
|
11
|
+
def initialize(command: ENV.fetch("SHELL", "/bin/sh"), cwd: Dir.pwd, columns: 80, rows: 24, env: {}, scrollback: 10_000,
|
|
12
|
+
queue_limit_bytes: 8_388_608)
|
|
13
|
+
raise ArgumentError, "terminal queue limit must be a positive integer" unless queue_limit_bytes.is_a?(Integer) && queue_limit_bytes.positive?
|
|
14
|
+
@initial_cwd = File.expand_path(cwd)
|
|
15
|
+
@command_name = File.basename(Array(command).first.to_s)
|
|
12
16
|
@grid = Grid.new(columns: columns, rows: rows, scrollback: scrollback)
|
|
13
17
|
if RUBY_PLATFORM.match?(/mswin|mingw/)
|
|
14
18
|
require "zaniah/platform/windows/terminal"
|
|
@@ -27,38 +31,82 @@ module Canopus
|
|
|
27
31
|
@writer.sync = true
|
|
28
32
|
@vt = VT.new(grid) { |bytes| write(bytes) }
|
|
29
33
|
resize(columns: columns, rows: rows)
|
|
34
|
+
@queue, @queue_bytes, @queue_limit_bytes = [], 0, queue_limit_bytes
|
|
35
|
+
@queue_lock, @queue_ready = Mutex.new, ConditionVariable.new
|
|
36
|
+
start_reader
|
|
30
37
|
end
|
|
31
38
|
|
|
32
39
|
# nil means EOF; an empty String means that no data was ready yet.
|
|
33
|
-
def read(timeout: 0, max_bytes: 65_536)
|
|
40
|
+
def read(timeout: 0, max_bytes: 65_536, max_seconds: nil)
|
|
41
|
+
raise ArgumentError, "terminal read limit must be a positive integer" unless max_bytes.is_a?(Integer) && max_bytes.positive?
|
|
42
|
+
raise ArgumentError, "terminal read timeout must be nonnegative" unless timeout.is_a?(Numeric) && timeout >= 0
|
|
43
|
+
raise ArgumentError, "terminal parse budget must be nonnegative" unless max_seconds.nil? || max_seconds.is_a?(Numeric) && max_seconds >= 0
|
|
34
44
|
return nil if @eof
|
|
35
45
|
if @native
|
|
36
46
|
data = @native.read_available(limit: max_bytes)
|
|
37
47
|
data ? vt.feed(data) : @eof = true
|
|
38
48
|
return data
|
|
39
49
|
end
|
|
40
|
-
|
|
41
|
-
data =
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
50
|
+
deadline = max_seconds && Process.clock_gettime(Process::CLOCK_MONOTONIC) + [max_seconds, 0].max
|
|
51
|
+
data = +"".b
|
|
52
|
+
loop do
|
|
53
|
+
break if data.bytesize >= max_bytes || deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
54
|
+
piece = @queue_lock.synchronize do
|
|
55
|
+
@queue_ready.wait(@queue_lock, timeout) if data.empty? && @queue.empty? && !@reader_eof && timeout.positive?
|
|
56
|
+
if @queue.empty?
|
|
57
|
+
@eof = true if @reader_eof
|
|
58
|
+
nil
|
|
59
|
+
else
|
|
60
|
+
chunk = @queue.shift
|
|
61
|
+
take = [max_bytes - data.bytesize, chunk.bytesize, 16_384].min
|
|
62
|
+
result = chunk.byteslice(0, take)
|
|
63
|
+
@queue.unshift(chunk.byteslice(take..)) if take < chunk.bytesize
|
|
64
|
+
@queue_bytes -= take
|
|
65
|
+
@queue_ready.broadcast
|
|
66
|
+
result
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
break unless piece
|
|
70
|
+
vt.feed(piece)
|
|
71
|
+
data << piece
|
|
47
72
|
end
|
|
48
|
-
data
|
|
49
|
-
rescue Errno::EIO, EOFError
|
|
50
|
-
@eof = true
|
|
51
|
-
nil
|
|
73
|
+
@eof && data.empty? ? nil : data
|
|
52
74
|
end
|
|
53
75
|
|
|
76
|
+
def pending? = @native ? false : @queue_lock.synchronize { !@queue.empty? }
|
|
77
|
+
|
|
54
78
|
def write(bytes) = @native ? @native.write(bytes) : writer.write(bytes)
|
|
55
79
|
def paste(text) = write(vt.paste(text))
|
|
56
80
|
def key(name, **modifiers) = write(vt.key(name, **modifiers))
|
|
57
81
|
def mouse(**event) = write(vt.mouse(**event))
|
|
58
82
|
|
|
59
83
|
def resize(columns:, rows:)
|
|
60
|
-
|
|
84
|
+
dimensions = [columns, rows]
|
|
85
|
+
return self if @pty_size == dimensions
|
|
86
|
+
grid.resize(columns: columns, rows: rows) unless grid.columns == columns && grid.rows == rows
|
|
61
87
|
@native ? @native.resize(columns, rows) : reader.winsize = [rows, columns]
|
|
88
|
+
@pty_size = dimensions
|
|
89
|
+
self
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def busy?
|
|
93
|
+
!@native && alive? && reader.tcgetpgrp != pid
|
|
94
|
+
rescue IOError, SystemCallError, NoMethodError
|
|
95
|
+
false
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def foreground_process_name(now: Process.clock_gettime(Process::CLOCK_MONOTONIC))
|
|
99
|
+
return @foreground_name if @foreground_checked && now - @foreground_checked < 1
|
|
100
|
+
@foreground_checked = now
|
|
101
|
+
group = reader.tcgetpgrp unless @native
|
|
102
|
+
value = if group && group != pid && File.file?("/proc/#{group}/comm")
|
|
103
|
+
File.read("/proc/#{group}/comm")
|
|
104
|
+
elsif group && group != pid
|
|
105
|
+
IO.popen(["ps", "-o", "comm=", "-p", group.to_s], &:read)
|
|
106
|
+
end
|
|
107
|
+
@foreground_name = value&.strip&.then { |name| File.basename(name) unless name.empty? }
|
|
108
|
+
rescue IOError, SystemCallError, NoMethodError
|
|
109
|
+
@foreground_name = nil
|
|
62
110
|
end
|
|
63
111
|
|
|
64
112
|
def signal(name = "INT")
|
|
@@ -81,11 +129,13 @@ module Canopus
|
|
|
81
129
|
|
|
82
130
|
def close
|
|
83
131
|
return @native.close if @native
|
|
132
|
+
@closing = true
|
|
133
|
+
@queue_lock.synchronize { @queue_ready.broadcast }
|
|
84
134
|
signal("HUP") if alive?
|
|
85
135
|
reader.close unless reader.closed?
|
|
86
136
|
writer.close unless writer.closed?
|
|
87
137
|
# A shell may trap HUP: reap it after a short grace period.
|
|
88
|
-
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) +
|
|
138
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 2
|
|
89
139
|
while alive? && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
|
|
90
140
|
sleep 0.01
|
|
91
141
|
end
|
|
@@ -93,11 +143,36 @@ module Canopus
|
|
|
93
143
|
signal("KILL")
|
|
94
144
|
_, @status = Process.waitpid2(pid)
|
|
95
145
|
end
|
|
146
|
+
@reader_thread&.join(0.2)
|
|
96
147
|
@eof = true
|
|
97
148
|
self
|
|
98
|
-
rescue Errno::ECHILD
|
|
149
|
+
rescue Errno::ECHILD, IOError
|
|
99
150
|
self
|
|
100
151
|
end
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
def start_reader
|
|
156
|
+
@reader_thread = Thread.new do
|
|
157
|
+
loop do
|
|
158
|
+
data = reader.readpartial(65_536)
|
|
159
|
+
@queue_lock.synchronize do
|
|
160
|
+
@queue_ready.wait(@queue_lock) while !@closing && @queue_bytes >= @queue_limit_bytes
|
|
161
|
+
break if @closing
|
|
162
|
+
@queue << data
|
|
163
|
+
@queue_bytes += data.bytesize
|
|
164
|
+
@queue_ready.broadcast
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
rescue Errno::EIO, EOFError, IOError
|
|
168
|
+
nil
|
|
169
|
+
ensure
|
|
170
|
+
@queue_lock.synchronize do
|
|
171
|
+
@reader_eof = true
|
|
172
|
+
@queue_ready.broadcast
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
101
176
|
end
|
|
102
177
|
end
|
|
103
178
|
end
|
data/lib/canopus/version.rb
CHANGED