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.
@@ -4,7 +4,7 @@ module Canopus
4
4
  module Workspace::GitAware
5
5
  def git
6
6
  return @git if defined?(@git)
7
- @git = Git::Repository.new(@root)
7
+ @git = Thuban::Repository.new(@root)
8
8
  @git = nil unless @git.root
9
9
  rescue ArgumentError
10
10
  @git = nil
@@ -29,7 +29,7 @@ module Canopus
29
29
  end
30
30
  def invalidate_git
31
31
  @git_generation = (@git_generation || 0) + 1
32
- @git_status = @git_hunk_cache = nil
32
+ @git_status = @git_diff_cache = nil
33
33
  @panes.each do |pane|
34
34
  pane.editors.each do |current|
35
35
  current.display_map.block_map.blocks.values.each { |block| current.display_map.remove_block(block.id) if block.kind == :git_diff }
@@ -57,43 +57,48 @@ module Canopus
57
57
  raise Error, "Current document is not in a Git repository" unless git && buffer.path && buffer.path.start_with?(git.root + File::SEPARATOR)
58
58
  buffer.path.delete_prefix(git.root + File::SEPARATOR)
59
59
  end
60
- def git_hunks(buffer = editor.buffer, async: true)
61
- return [] unless git && buffer.path && buffer.path.start_with?(git.root + File::SEPARATOR) && buffer.rope.bytesize < 10 << 20
60
+ def git_diff(buffer = editor.buffer, async: true)
61
+ return unless git && buffer.path && buffer.path.start_with?(git.root + File::SEPARATOR) && buffer.rope.bytesize < 10 << 20
62
62
  path = git_relative_path(buffer)
63
- @git_hunk_cache ||= {}
63
+ @git_diff_cache ||= {}
64
64
  key = [buffer.object_id, path, buffer.version, git.head]
65
- @git_hunk_cache.clear if @git_hunk_cache.length > 20
66
- return @git_hunk_cache[key] if @git_hunk_cache.key?(key)
65
+ @git_diff_cache.clear if @git_diff_cache.length > 20
66
+ return @git_diff_cache[key] if @git_diff_cache.key?(key)
67
67
  if @window && async
68
- @git_hunk_jobs ||= {}
69
- unless @git_hunk_jobs[buffer]&.alive?
68
+ @git_diff_jobs ||= {}
69
+ unless @git_diff_jobs[buffer]&.alive?
70
70
  snapshot = buffer.rope
71
- @git_hunk_jobs[buffer] = Thread.new do
71
+ @git_diff_jobs[buffer] = Thread.new do
72
72
  before = Buffer.decode_bytes(git.blob(path, reference: key.last || "HEAD").to_s).first
73
- hunks = Git::Diff.hunks(before, snapshot.to_s, context: 0)
74
- post { (@git_hunk_cache ||= {})[key] = hunks } unless @closed
73
+ diff = Porrima.diff(before, snapshot.to_s, context: 0)
74
+ diff.hunks
75
+ diff.marks
76
+ post { (@git_diff_cache ||= {})[key] = diff } unless @closed
75
77
  rescue StandardError => error
76
78
  post { @message = "Git diff: #{error.message}" } unless @closed
77
79
  end
78
80
  end
79
- []
81
+ nil
80
82
  else
81
83
  before = Buffer.decode_bytes(git.blob(path).to_s).first
82
- @git_hunk_cache[key] = Git::Diff.hunks(before, buffer.text, context: 0)
84
+ diff = Porrima.diff(before, buffer.text, context: 0)
85
+ diff.hunks
86
+ diff.marks
87
+ @git_diff_cache[key] = diff
83
88
  end
84
89
  end
90
+ def git_hunks(buffer = editor.buffer, async: true) = git_diff(buffer, async: async)&.hunks || []
91
+ def git_gutter_marks(buffer = editor.buffer) = git_diff(buffer)&.marks || []
85
92
  def show_git_diff
86
93
  path = git_relative_path
87
94
  before = Buffer.decode_bytes(git.blob(path).to_s).first
88
- buffer = Buffer.new(Git::Diff.unified(before, editor.buffer.text, old_name: "a/#{path}", new_name: "b/#{path}"), read_only: true)
95
+ buffer = Buffer.new(Porrima.unified(before, editor.buffer.text, old_name: "a/#{path}", new_name: "b/#{path}"), read_only: true)
89
96
  @buffers[buffer.object_id] = buffer
90
97
  @active_pane.open(buffer).language = Language::Definition.new("diff", "diff", [], "", /\A\z/, /\A\z/, [])
91
98
  end
92
99
  def toggle_git_hunk(current = editor, row: nil)
93
100
  row ||= current.buffer.rope.point_at(current.primary.head).row
94
- hunk = git_hunks(current.buffer, async: false).find do |item|
95
- (row + 1).between?([item.new_start, 1].max, [item.new_start + item.new_count - 1, item.new_start].max)
96
- end
101
+ hunk = git_diff(current.buffer, async: false)&.hunk_at(new_line: row + 1)
97
102
  raise Error, "No change at the cursor" unless hunk
98
103
  id = [:git_hunk, current.buffer.path, current.buffer.version, git.head, hunk.old_start, hunk.new_start]
99
104
  map = current.display_map
@@ -122,9 +127,9 @@ module Canopus
122
127
  end
123
128
  def revert_current_hunk
124
129
  row = editor.buffer.rope.point_at(editor.primary.head).row + 1
125
- hunk = git_hunks(async: false).find { |item| row.between?([item.new_start, 1].max, [item.new_start + item.new_count - 1, item.new_start].max) }
130
+ hunk = git_diff(async: false)&.hunk_at(new_line: row)
126
131
  raise Error, "No change at the cursor" unless hunk
127
- replacement = Git::Diff.revert(editor.buffer.text, hunk)
132
+ replacement = Porrima.revert(editor.buffer.text, hunk)
128
133
  editor.buffer.edit([[0...editor.buffer.rope.bytesize, replacement]], kind: :revert_hunk)
129
134
  end
130
135
  def checkout_branch(name)
@@ -20,6 +20,8 @@ module Canopus
20
20
  end
21
21
  state = {version: 2, root: @root, layout: encode_layout(@layout), recent_files: @recent_files || [],
22
22
  active_pane: @panes.index(@active_pane), docks: @docks,
23
+ terminals: @terminals.map { |current| {cwd: current.vt.cwd || (current.respond_to?(:initial_cwd) ? current.initial_cwd : @root), title: @terminal_names[current]} },
24
+ active_terminal: @active_terminal_index, terminal_visible: !!@terminal_visible,
23
25
  panes: @panes.map do |pane|
24
26
  {active: pane.active_index, tabs: pane.editors.map do |current|
25
27
  {buffer_id: record.call(current.buffer), cursor: current.primary.head, pinned: pane.pinned.include?(current),
@@ -133,6 +135,7 @@ module Canopus
133
135
  end
134
136
  active = data.fetch("active_pane", 0)
135
137
  raise Error, "invalid active pane" unless active.is_a?(Integer) && active.between?(0, restored_panes.length - 1)
138
+ terminal_records = validate_session_terminals(data) if @settings["terminal"]["restore_on_startup"]
136
139
  clear_vim_states
137
140
  close_language_documents
138
141
  @panes.each { |pane| pane.editors.each(&:dispose) }
@@ -140,7 +143,7 @@ module Canopus
140
143
  @panes, @buffers, @layout, @docks = restored_panes, restored_buffers, restored_layout, restored_docks
141
144
  @active_pane = @panes[active]
142
145
  @recent_files = Array(data["recent_files"]).select { |item| item.is_a?(String) && File.file?(item) }.first(100)
143
- new_buffer unless editor
146
+ restore_terminals(data, terminal_records) if terminal_records
144
147
  self
145
148
  rescue StandardError
146
149
  unless @panes.equal?(restored_panes)
@@ -149,5 +152,33 @@ module Canopus
149
152
  end
150
153
  raise
151
154
  end
155
+
156
+ private
157
+
158
+ def validate_session_terminals(data)
159
+ records = data.fetch("terminals", [])
160
+ raise Error, "invalid session terminals" unless records.is_a?(Array) && records.length <= 100 && records.all? { |item| item.is_a?(Hash) && item["cwd"].is_a?(String) }
161
+ active = data.fetch("active_terminal", 0)
162
+ raise Error, "invalid active terminal" unless records.empty? || active.is_a?(Integer) && active.between?(0, records.length - 1)
163
+ records
164
+ end
165
+
166
+ def restore_terminals(data, records)
167
+ old, old_index, old_visible = @terminals, @active_terminal_index, @terminal_visible
168
+ @terminals = []
169
+ records.each do |record|
170
+ cwd = File.directory?(record["cwd"]) ? record["cwd"] : @root
171
+ created = new_terminal(cwd: cwd)
172
+ rename_terminal(record["title"], created) if record["title"].is_a?(String)
173
+ end
174
+ @active_terminal_index = @terminals.empty? ? 0 : data.fetch("active_terminal", 0)
175
+ @terminal_visible = !!data["terminal_visible"] && !@terminals.empty?
176
+ old.each { |current| current.close if current.respond_to?(:close) }
177
+ rescue StandardError
178
+ @terminals.each { |current| current.close if current.respond_to?(:close) }
179
+ @terminals = old
180
+ @active_terminal_index, @terminal_visible = old_index, old_visible
181
+ @message = "Session restored; terminals could not start"
182
+ end
152
183
  end
153
184
  end
@@ -65,7 +65,7 @@ module Canopus
65
65
  def terminal_point(point)
66
66
  grid = @workspace.terminal.grid
67
67
  column = ((point.x - @terminal_bounds.x) / @terminal_cell_width).floor.clamp(0, grid.columns - 1)
68
- row = ((point.y - @terminal_bounds.y) / @line_height).floor.clamp(0, grid.rows - 1)
68
+ row = ((point.y - @terminal_bounds.y) / (@terminal_line_height || @line_height)).floor.clamp(0, grid.rows - 1)
69
69
  [column, row]
70
70
  end
71
71
  def terminal_scroll(delta)
@@ -136,15 +136,36 @@ module Canopus
136
136
  end
137
137
  def render_terminal(bounds)
138
138
  fill(bounds, :panel)
139
+ @terminal_bounds = nil
139
140
  terminal = @workspace.terminal
140
- return unless terminal
141
- text(terminal.vt.title.to_s.empty? ? "Terminal" : terminal.vt.title, bounds.x + 12, bounds.y + 5, color: :muted, size: 12)
142
- @terminal_cell_width = @cx.text_system&.layout_line("M", size: @font_size)&.width || @font_size * 0.6
143
- columns = [(bounds.width - 24) / @terminal_cell_width, 1].max.floor
144
- rows = [(bounds.height - 28) / @line_height, 1].max.floor
145
- terminal.resize(columns: columns, rows: rows) if terminal.grid.columns != columns || terminal.grid.rows != rows
141
+ unless terminal
142
+ text("No terminals Ctrl+Shift+` to create one", bounds.x + 12, bounds.y + 8, color: :muted, size: 12)
143
+ return
144
+ end
145
+ if @terminal_owner != terminal
146
+ @terminal_owner, @terminal_scroll, @terminal_selection = terminal, 0, nil
147
+ end
148
+ x = bounds.x
149
+ @workspace.terminals.each_with_index do |current, index|
150
+ label = @workspace.terminal_title(current)
151
+ width = [[label.length * 7.5 + 42, 100].max, 220].min
152
+ tab = Zaniah::Bounds.new(x, bounds.y, width, 28)
153
+ fill(tab, :active_tab) if current.equal?(terminal)
154
+ text(label, tab.x + 10, tab.y + 6, color: current.equal?(terminal) ? :foreground : :muted, size: 12)
155
+ region(tab, role: :tab, label: label, action: [:terminal_tab, index])
156
+ close = Zaniah::Bounds.new(tab.right - 25, tab.y, 25, tab.height)
157
+ text("×", close.x + 6, close.y + 5, color: :muted, size: 12)
158
+ region(close, role: :button, label: "Close #{label}", action: [:terminal_close, index])
159
+ x += width
160
+ end
161
+ @terminal_font_size = @workspace.settings["terminal"]["font_size"] || @font_size
162
+ @terminal_line_height = (@terminal_font_size * @workspace.settings["terminal"]["line_height"]).ceil
163
+ @terminal_cell_width = @cx.text_system&.layout_line("M", size: @terminal_font_size)&.width || @terminal_font_size * 0.6
164
+ columns = [(bounds.width - 24) / @terminal_cell_width, @workspace.settings["terminal"]["min_cols"]].max.floor
165
+ rows = [(bounds.height - 40) / @terminal_line_height, @workspace.settings["terminal"]["min_rows"]].max.floor
166
+ @workspace.resize_terminal(columns, rows)
146
167
  grid = terminal.grid
147
- @terminal_bounds = Zaniah::Bounds.new(bounds.x + 12, bounds.y + 28, columns * @terminal_cell_width, rows * @line_height)
168
+ @terminal_bounds = Zaniah::Bounds.new(bounds.x + 12, bounds.y + 32, columns * @terminal_cell_width, rows * @terminal_line_height).intersect(bounds)
148
169
  @terminal_scroll = (@terminal_scroll || 0).clamp(0, grid.scrollback.length)
149
170
  @terminal_first = grid.scrollback.length - @terminal_scroll.floor
150
171
  selection = @terminal_selection&.sort
@@ -153,11 +174,11 @@ module Canopus
153
174
  cells = terminal_row(@terminal_first + row)
154
175
  cells.each_with_index do |cell, column|
155
176
  next if cell.width.zero?
156
- x, y = @terminal_bounds.x + column * @terminal_cell_width, @terminal_bounds.y + row * @line_height
177
+ x, y = @terminal_bounds.x + column * @terminal_cell_width, @terminal_bounds.y + row * @terminal_line_height
157
178
  foreground = terminal_color(cell.foreground, :foreground)
158
179
  background = terminal_color(cell.background, :panel)
159
180
  foreground, background = background, foreground if cell.attributes[:inverse]
160
- rect = Zaniah::Bounds.new(x, y, cell.width * @terminal_cell_width, @line_height)
181
+ rect = Zaniah::Bounds.new(x, y, cell.width * @terminal_cell_width, @terminal_line_height)
161
182
  fill(rect, background) if background != @theme[:panel]
162
183
  position = [@terminal_first + row, column]
163
184
  fill(rect, :selection) if selection && (position <=> selection.first) >= 0 && (position <=> selection.last) <= 0
@@ -165,20 +186,39 @@ module Canopus
165
186
  foreground = Zaniah::Color.parse(foreground).opacity(0.6) if cell.attributes[:dim]
166
187
  unless cell.text.strip.empty?
167
188
  font = terminal_font(cell)
168
- text(cell.text, x, y, color: foreground, font: font)
169
- text(cell.text, x + 0.5, y, color: foreground, font: font) if cell.attributes[:bold]
189
+ text(cell.text, x, y, color: foreground, size: @terminal_font_size, font: font)
190
+ text(cell.text, x + 0.5, y, color: foreground, size: @terminal_font_size, font: font) if cell.attributes[:bold]
170
191
  end
171
192
  if cell.attributes[:underline] || cell.hyperlink
172
- fill(Zaniah::Bounds.new(x, y + @line_height - 2, rect.width, 1), terminal_color(cell.attributes[:underline_color], :foreground))
193
+ fill(Zaniah::Bounds.new(x, y + @terminal_line_height - 2, rect.width, 1), terminal_color(cell.attributes[:underline_color], :foreground))
173
194
  end
174
- fill(Zaniah::Bounds.new(x, y + @line_height / 2, rect.width, 1), foreground) if cell.attributes[:strikethrough]
195
+ fill(Zaniah::Bounds.new(x, y + @terminal_line_height / 2, rect.width, 1), foreground) if cell.attributes[:strikethrough]
175
196
  end
176
197
  end
177
- if grid.cursor_visible && @terminal_scroll.zero?
178
- fill(Zaniah::Bounds.new(@terminal_bounds.x + grid.cursor_x * @terminal_cell_width,
179
- @terminal_bounds.y + grid.cursor_y * @line_height, @terminal_cell_width, @line_height), "#e9a66166")
198
+ blinking = @workspace.settings["terminal"]["blinking"]
199
+ if grid.cursor_visible && @terminal_scroll.zero? &&
200
+ (blinking == "off" || @cursor_visible || !@workspace.terminal_composition&.text.to_s.empty?)
201
+ x = @terminal_bounds.x + grid.cursor_x * @terminal_cell_width
202
+ y = @terminal_bounds.y + grid.cursor_y * @terminal_line_height
203
+ cursor = case @workspace.settings["terminal"]["cursor_shape"]
204
+ when "bar" then Zaniah::Bounds.new(x, y, 2, @terminal_line_height)
205
+ when "underline" then Zaniah::Bounds.new(x, y + @terminal_line_height - 2, @terminal_cell_width, 2)
206
+ else Zaniah::Bounds.new(x, y, @terminal_cell_width, @terminal_line_height)
207
+ end
208
+ fill(cursor, "#e9a66166")
209
+ composition = @workspace.terminal_composition
210
+ if composition && !composition.text.empty?
211
+ layout = text(composition.text, x, y, color: :accent, size: @terminal_font_size)
212
+ fill(Zaniah::Bounds.new(x, y + @terminal_line_height - 2, layout&.width || 20, 1), :accent)
213
+ @cx.window.ime_state = Zaniah::Bounds.new(x, y, 2, @terminal_line_height)
214
+ end
180
215
  end
181
216
  end
217
+ if terminal.respond_to?(:status) && terminal.status
218
+ code = terminal.status.exited? ? terminal.status.exitstatus : "signal #{terminal.status.termsig}"
219
+ text("Process exited with #{code} — press Enter to restart", @terminal_bounds.x + 4,
220
+ [@terminal_bounds.bottom - @terminal_line_height, @terminal_bounds.y].max, color: :muted, size: 11)
221
+ end
182
222
  region(@terminal_bounds, role: :terminal, label: "Terminal", action: [:terminal])
183
223
  end
184
224
  end
@@ -170,7 +170,7 @@ module Canopus
170
170
  height = bounds.height / panels.length
171
171
  area = Zaniah::Bounds.new(bounds.x + 8, bounds.y + height * index, [bounds.width - 16, 0].max, height)
172
172
  text(name, area.x + 4, area.y + 10, color: :muted, size: 12)
173
- key = [name, @workspace.editor.buffer.object_id, @workspace.editor.buffer.version, @theme.object_id]
173
+ key = [name, @workspace.editor&.buffer&.object_id, @workspace.editor&.buffer&.version, @theme.object_id]
174
174
  @panel_cache ||= {}
175
175
  @panel_cache.clear if @panel_cache.length > 50
176
176
  element = @panel_cache[key] ||= render.call
@@ -219,11 +219,19 @@ module Canopus
219
219
  end
220
220
  text(name, x + 14, bounds.y + 10, color: pane.active.equal?(editor) ? :foreground : :muted, size: 12)
221
221
  region(tab, role: :tab, label: name, action: [:tab, pane, editor])
222
+ close = Zaniah::Bounds.new(tab.right - 26, tab.y, 26, tab.height)
223
+ text(editor.buffer.dirty? ? "●" : "×", close.x + 6, close.y + 9, color: :muted, size: 12)
224
+ region(close, role: :button, label: "Close #{name}", action: [:tab_close, pane, editor])
222
225
  x += width
223
226
  end
224
227
  end
225
228
  editor = pane.active
226
- return unless editor
229
+ unless editor
230
+ text("Open a file or create a new one", bounds.x + 24, bounds.y + 58, color: :muted, size: 14)
231
+ text("Cmd/Ctrl+P Open Cmd/Ctrl+N New", bounds.x + 24, bounds.y + 84, color: :muted, size: 11)
232
+ fill(Zaniah::Bounds.new(bounds.right - 1, bounds.y, 1, bounds.height), :border)
233
+ return
234
+ end
227
235
  area = Zaniah::Bounds.new(bounds.x, bounds.y + 34, bounds.width, [bounds.height - 34, 0].max)
228
236
  @editor_bounds[editor] = area
229
237
  region(area, role: :textbox, label: editor.buffer.path || "Untitled document", action: [:editor, pane, editor])
@@ -236,7 +244,7 @@ module Canopus
236
244
  editor.viewport_rows = [(bounds.height / @line_height).floor, 1].max
237
245
  map, first = editor.display_map, editor.scroll_y.floor
238
246
  last = [first + editor.viewport_rows + 1, map.row_count].min
239
- hunks = @workspace.git_hunks(editor.buffer)
247
+ marks = @workspace.git_gutter_marks(editor.buffer)
240
248
  cursor_offset = @workspace.settings["vim_mode"] && active ? @workspace.vim.cursor_position : editor.primary.head
241
249
  cursor = map.to_display(cursor_offset)
242
250
  if map.wrap_map.width && !editor.buffer.read_only
@@ -280,9 +288,12 @@ module Canopus
280
288
  y = bounds.y + (index - editor.scroll_y) * @line_height + 4
281
289
  fill(Zaniah::Bounds.new(bounds.x, y - 2, bounds.width, @line_height), :current_line) if cursor.row == index
282
290
  source_row = map.source_row(index)
283
- change = hunks.find { |hunk| (source_row + 1).between?([hunk.new_start, 1].max, [hunk.new_start + hunk.new_count - 1, hunk.new_start].max) }
291
+ change = marks.find do |mark|
292
+ first = [mark.new_line, 1].max
293
+ mark.kind == :removed ? source_row + 1 == first : (source_row + 1).between?(first, first + mark.count - 1)
294
+ end
284
295
  if change
285
- color = change.new_count.zero? ? @theme[:error] : change.old_count.zero? ? "#80b987" : @theme[:accent]
296
+ color = change.kind == :removed ? @theme[:error] : change.kind == :added ? "#80b987" : @theme[:accent]
286
297
  fill(Zaniah::Bounds.new(bounds.x + 2, y - 1, 3, @line_height), color)
287
298
  region(Zaniah::Bounds.new(bounds.x, y - 1, 10, @line_height), role: :button, label: "Toggle Git hunk", action: [:git_hunk, editor, source_row]) if row.kind == :text
288
299
  end
@@ -327,9 +338,9 @@ module Canopus
327
338
  previous_width = @line_widths[editor]
328
339
  measured_width = [measured_width, previous_width.last].max if previous_width && previous_width.first == editor.buffer.version
329
340
  @line_widths[editor] = [editor.buffer.version, measured_width]
330
- paint_scrollbars(editor, bounds, measured_width, hunks)
341
+ paint_scrollbars(editor, bounds, measured_width, marks)
331
342
  end
332
- def paint_scrollbars(editor, bounds, content_width, hunks)
343
+ def paint_scrollbars(editor, bounds, content_width, marks)
333
344
  track = Zaniah::Bounds.new(bounds.right - 9, bounds.y, 9, [bounds.height - 9, 0].max)
334
345
  total = editor.display_map.row_count
335
346
  maximum = [total - editor.viewport_rows, 0].max
@@ -337,8 +348,8 @@ module Canopus
337
348
  height = [24, track.height * editor.viewport_rows / total].max.clamp(0, track.height)
338
349
  thumb = Zaniah::Bounds.new(track.x + 2, track.y + (track.height - height) * editor.scroll_y / maximum, 5, height)
339
350
  fill(thumb, :muted)
340
- hunks.first(500).each do |hunk|
341
- y = track.y + track.height * [hunk.new_start - 1, 0].max / [editor.buffer.line_count, 1].max
351
+ marks.first(500).each do |mark|
352
+ y = track.y + track.height * [mark.new_line - 1, 0].max / [editor.buffer.line_count, 1].max
342
353
  fill(Zaniah::Bounds.new(track.x, y, 3, 2), :accent)
343
354
  end
344
355
  frame_diagnostics(editor.buffer).first(500).each do |diagnostic|
@@ -576,7 +587,8 @@ module Canopus
576
587
  end
577
588
  def shortcut_labels
578
589
  return {} unless @keymap
579
- context = {"Editor" => true, "vim_mode" => @workspace.settings["vim_mode"] ? @workspace.vim.mode.to_s : false}
590
+ context = {"Editor" => !!@workspace.editor,
591
+ "vim_mode" => @workspace.settings["vim_mode"] && @workspace.editor ? @workspace.vim.mode.to_s : false}
580
592
  seen, labels = {}, {}
581
593
  @keymap.bindings.reverse_each do |binding|
582
594
  next unless binding.predicate.call(context)