canopus 0.1.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.
Files changed (146) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +148 -0
  5. data/docs/adr/001-runtime-gem-components.md +24 -0
  6. data/docs/adr/002-bounded-diff-engine.md +22 -0
  7. data/docs/adr/003-background-language-analysis.md +22 -0
  8. data/docs/adr/004-persistent-display-map.md +23 -0
  9. data/docs/adr/005-windows-runtime.md +27 -0
  10. data/docs/adr/README.md +36 -0
  11. data/docs/distribution.md +27 -0
  12. data/docs/lsp.md +38 -0
  13. data/docs/performance.md +40 -0
  14. data/docs/snippets.md +32 -0
  15. data/docs/vim.md +24 -0
  16. data/docs/workspace_edits.md +32 -0
  17. data/examples/native_smoke.rb +25 -0
  18. data/examples/plugins/word_count.rb +11 -0
  19. data/exe/canopus +19 -0
  20. data/lib/canopus/block_map.rb +19 -0
  21. data/lib/canopus/buffer.rb +293 -0
  22. data/lib/canopus/cli.rb +171 -0
  23. data/lib/canopus/controller.rb +507 -0
  24. data/lib/canopus/data_compat.rb +25 -0
  25. data/lib/canopus/display_map/line_builder.rb +54 -0
  26. data/lib/canopus/display_map/line_set.rb +8 -0
  27. data/lib/canopus/display_map/pending_line_set.rb +9 -0
  28. data/lib/canopus/display_map/summary.rb +23 -0
  29. data/lib/canopus/display_map/worker.rb +89 -0
  30. data/lib/canopus/display_map.rb +350 -0
  31. data/lib/canopus/display_point.rb +5 -0
  32. data/lib/canopus/editor/snippet_expandable.rb +281 -0
  33. data/lib/canopus/editor.rb +445 -0
  34. data/lib/canopus/error.rb +5 -0
  35. data/lib/canopus/fold_map.rb +87 -0
  36. data/lib/canopus/git/blame.rb +50 -0
  37. data/lib/canopus/git/commit.rb +7 -0
  38. data/lib/canopus/git/corrupt_object.rb +7 -0
  39. data/lib/canopus/git/diff.rb +131 -0
  40. data/lib/canopus/git/index.rb +94 -0
  41. data/lib/canopus/git/object_database.rb +45 -0
  42. data/lib/canopus/git/pack.rb +186 -0
  43. data/lib/canopus/git/repository.rb +313 -0
  44. data/lib/canopus/git/status.rb +74 -0
  45. data/lib/canopus/git/tree_entry.rb +7 -0
  46. data/lib/canopus/git.rb +15 -0
  47. data/lib/canopus/icon_theme.rb +43 -0
  48. data/lib/canopus/language/background_analysis/job.rb +12 -0
  49. data/lib/canopus/language/background_analysis/scheduler.rb +70 -0
  50. data/lib/canopus/language/background_analysis.rb +280 -0
  51. data/lib/canopus/language/definition.rb +7 -0
  52. data/lib/canopus/language/document.rb +143 -0
  53. data/lib/canopus/language/symbol.rb +7 -0
  54. data/lib/canopus/language/syntax_worker.rb +91 -0
  55. data/lib/canopus/language.rb +42 -0
  56. data/lib/canopus/lazy_rope.rb +218 -0
  57. data/lib/canopus/lsp/client.rb +299 -0
  58. data/lib/canopus/lsp/error.rb +7 -0
  59. data/lib/canopus/lsp/future/subscription.rb +9 -0
  60. data/lib/canopus/lsp/future.rb +87 -0
  61. data/lib/canopus/lsp/protocol.rb +83 -0
  62. data/lib/canopus/lsp/server_error.rb +13 -0
  63. data/lib/canopus/lsp/timeout.rb +7 -0
  64. data/lib/canopus/lsp/transport.rb +123 -0
  65. data/lib/canopus/lsp.rb +19 -0
  66. data/lib/canopus/markdown.rb +69 -0
  67. data/lib/canopus/match_data_compat.rb +17 -0
  68. data/lib/canopus/multi_buffer.rb +266 -0
  69. data/lib/canopus/pane.rb +61 -0
  70. data/lib/canopus/patch/composite.rb +12 -0
  71. data/lib/canopus/patch/reload.rb +18 -0
  72. data/lib/canopus/patch.rb +27 -0
  73. data/lib/canopus/performance_recorder.rb +207 -0
  74. data/lib/canopus/plugins/api.rb +50 -0
  75. data/lib/canopus/plugins/isolated_runtime.rb +167 -0
  76. data/lib/canopus/plugins/local_runtime.rb +25 -0
  77. data/lib/canopus/plugins/permission_denied.rb +7 -0
  78. data/lib/canopus/plugins/registry.rb +30 -0
  79. data/lib/canopus/plugins.rb +11 -0
  80. data/lib/canopus/project/ignore_matcher.rb +104 -0
  81. data/lib/canopus/project/search.rb +164 -0
  82. data/lib/canopus/project/search_worker/cancelled.rb +3 -0
  83. data/lib/canopus/project/search_worker/runner.rb +9 -0
  84. data/lib/canopus/project/search_worker.rb +108 -0
  85. data/lib/canopus/project/tree.rb +48 -0
  86. data/lib/canopus/project/watcher.rb +59 -0
  87. data/lib/canopus/project.rb +127 -0
  88. data/lib/canopus/regexp_compat.rb +30 -0
  89. data/lib/canopus/save_conflict.rb +5 -0
  90. data/lib/canopus/selection.rb +11 -0
  91. data/lib/canopus/settings.rb +118 -0
  92. data/lib/canopus/snippet/transform.rb +209 -0
  93. data/lib/canopus/snippet.rb +183 -0
  94. data/lib/canopus/tab_map.rb +30 -0
  95. data/lib/canopus/terminal/cell.rb +7 -0
  96. data/lib/canopus/terminal/grid.rb +321 -0
  97. data/lib/canopus/terminal/pty.rb +103 -0
  98. data/lib/canopus/terminal/scrollback.rb +38 -0
  99. data/lib/canopus/terminal/vt.rb +398 -0
  100. data/lib/canopus/terminal.rb +13 -0
  101. data/lib/canopus/theme.rb +43 -0
  102. data/lib/canopus/version.rb +5 -0
  103. data/lib/canopus/vim/commandable.rb +148 -0
  104. data/lib/canopus/vim/motionable.rb +321 -0
  105. data/lib/canopus/vim/operator_capable.rb +377 -0
  106. data/lib/canopus/vim/text_object_selectable.rb +141 -0
  107. data/lib/canopus/vim.rb +426 -0
  108. data/lib/canopus/workspace/edit/executable.rb +139 -0
  109. data/lib/canopus/workspace/edit/node.rb +8 -0
  110. data/lib/canopus/workspace/edit/plan.rb +170 -0
  111. data/lib/canopus/workspace/edit/resource_preparable.rb +98 -0
  112. data/lib/canopus/workspace/edit.rb +6 -0
  113. data/lib/canopus/workspace/file_change_aware.rb +40 -0
  114. data/lib/canopus/workspace/file_previewable.rb +31 -0
  115. data/lib/canopus/workspace/git_aware.rb +140 -0
  116. data/lib/canopus/workspace/language_aware.rb +413 -0
  117. data/lib/canopus/workspace/language_server_configurable.rb +181 -0
  118. data/lib/canopus/workspace/project_searchable.rb +208 -0
  119. data/lib/canopus/workspace/project_tree_editable.rb +82 -0
  120. data/lib/canopus/workspace/session_persistable.rb +153 -0
  121. data/lib/canopus/workspace/settings_aware.rb +91 -0
  122. data/lib/canopus/workspace/view/terminal_presentable.rb +185 -0
  123. data/lib/canopus/workspace/view.rb +643 -0
  124. data/lib/canopus/workspace.rb +525 -0
  125. data/lib/canopus/wrap_map.rb +108 -0
  126. data/lib/canopus.rb +24 -0
  127. data/sig/canopus.rbs +375 -0
  128. data/sig/controller.rbs +55 -0
  129. data/sig/display_stages.rbs +49 -0
  130. data/sig/git.rbs +140 -0
  131. data/sig/lsp.rbs +99 -0
  132. data/sig/markdown.rbs +11 -0
  133. data/sig/performance_recorder.rbs +23 -0
  134. data/sig/search_services.rbs +8 -0
  135. data/sig/services.rbs +81 -0
  136. data/sig/snippet.rbs +42 -0
  137. data/sig/terminal.rbs +125 -0
  138. data/sig/vim.rbs +27 -0
  139. data/sig/workspace_edits.rbs +11 -0
  140. data/sig/workspace_services.rbs +117 -0
  141. data/tools/certify_snippet_regex.rb +35 -0
  142. data/tools/check_dependencies.rb +80 -0
  143. data/tools/native_check.rb +70 -0
  144. data/tools/package.rb +113 -0
  145. data/tools/package_test.rb +32 -0
  146. metadata +314 -0
@@ -0,0 +1,525 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require_relative "settings"
6
+ require_relative "theme"
7
+ require_relative "vim"
8
+ require_relative "lsp"
9
+ require_relative "pane"
10
+ require_relative "project/tree"
11
+
12
+ module Canopus
13
+ class Workspace
14
+ 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 :message, :palette
17
+
18
+ def initialize(root: Dir.pwd, settings: nil)
19
+ @root = File.realpath(root)
20
+ @settings = settings || Settings.new(Settings.user_path, File.join(@root, ".canopus", "settings.jsonc"))
21
+ @panes, @buffers = [Pane.new], {}
22
+ @active_pane = @panes.first
23
+ @layout = {pane: @active_pane}
24
+ @theme = Theme.new(name: @settings["theme"])
25
+ @actions, @clients, @vim_states = Zaniah::Input::ActionRegistry.new, {}, {}
26
+ @show_project, @message = true, ""
27
+ @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
+ register_actions
31
+ end
32
+ def editor = @active_pane.active
33
+ def palette=(value)
34
+ previous, @palette = @palette, @closed ? nil : value
35
+ response = previous&.dig(:response)
36
+ unless response.nil? || response.equal?(@palette&.dig(:response))
37
+ response.fulfill({"applied" => false, "failureReason" => value ? "Replaced by another dialog" : "User cancelled"})
38
+ end
39
+ value&.dig(:response)&.fulfill({"applied" => false, "failureReason" => "Workspace closed"}) if @closed
40
+ @palette
41
+ end
42
+ def plugins = @plugins ||= Plugins::Registry.new(self)
43
+ def files
44
+ return @files if @files
45
+ @project_entries = @project ? @project.files(include_directories: true).to_a : []
46
+ @files = @project_entries.reject { |path| path.end_with?("/") }
47
+ end
48
+ def message=(value)
49
+ @message = value.to_s
50
+ notify(@message) unless @message.empty?
51
+ @message
52
+ end
53
+ def notify(text, now: Process.clock_gettime(Process::CLOCK_MONOTONIC))
54
+ @notifications ||= []
55
+ @notification_id = (@notification_id || 0) + 1
56
+ @notifications << {id: @notification_id, text: text.to_s.slice(0, 2_000), expires: now + 6}
57
+ @notifications.shift while @notifications.length > 3
58
+ @window&.request_frame
59
+ @notification_id
60
+ end
61
+ def notifications = @notifications || []
62
+ def dismiss_notification(id)
63
+ @notifications&.reject! { |item| item[:id] == id }
64
+ @window&.request_frame
65
+ end
66
+ def expire_notifications(now = Process.clock_gettime(Process::CLOCK_MONOTONIC))
67
+ !!@notifications&.reject! { |item| item[:expires] <= now }
68
+ end
69
+ def refresh_files
70
+ @files = @project_tree = @project_entries = nil
71
+ @finder_index = nil
72
+ if @palette && @palette[:kind] == :files
73
+ @palette.delete(:search)
74
+ update_palette
75
+ end
76
+ end
77
+ def layout = @layout
78
+ def vim = @vim_states[editor] ||= Vim.new(editor).tap { |state| state.on_command = ->(command) { ex_command(command) } }
79
+ def open(path)
80
+ absolute = canonical_path(path)
81
+ buffer = @buffers[absolute] ||= File.file?(absolute) ? Buffer.open(absolute) : Buffer.new("", path: absolute)
82
+ @vim_states[editor]&.deactivate unless editor&.buffer.equal?(buffer)
83
+ opened = @active_pane.open(buffer)
84
+ @recent_files ||= []
85
+ @recent_files.delete(absolute)
86
+ @recent_files.unshift(absolute)
87
+ @recent_files.pop if @recent_files.length > 100
88
+ @finder_index = nil
89
+ opened.language = definition_for(absolute)
90
+ apply_editor_settings(opened)
91
+ @project_tree&.reveal(absolute.delete_prefix(@root + File::SEPARATOR))
92
+ @window&.request_frame
93
+ opened
94
+ end
95
+ def canonical_path(path)
96
+ absolute = File.expand_path(path, @root)
97
+ return File.realpath(absolute) if File.file?(absolute)
98
+ parent, pieces = File.dirname(absolute), [File.basename(absolute)]
99
+ until File.directory?(parent)
100
+ pieces.unshift(File.basename(parent))
101
+ parent = File.dirname(parent)
102
+ end
103
+ File.join(File.realpath(parent), *pieces)
104
+ end
105
+ def save_buffer(buffer = editor.buffer, path: buffer.path)
106
+ if buffer.is_a?(MultiBuffer)
107
+ buffer.excerpts.map(&:buffer).uniq.each { |source| save_buffer(source) if source.dirty? }
108
+ return buffer
109
+ end
110
+ target = path && canonical_path(path)
111
+ raise Error, "Choose a path before saving" unless target
112
+ existing = @buffers[target]
113
+ raise Error, "Destination is already open in another buffer" if existing && !existing.equal?(buffer)
114
+ previous = buffer.path
115
+ result = buffer.save(target)
116
+ if previous != buffer.path
117
+ invalidate_git
118
+ # Close the old URI before registering this buffer under its new path.
119
+ @opened_lsp_documents&.keys&.each do |client, document|
120
+ next unless document.equal?(buffer)
121
+ @opened_lsp_documents.delete([client, document])
122
+ begin
123
+ client.close_document(LSP::Protocol.uri(previous)) if previous
124
+ rescue StandardError => error
125
+ @message = "File saved; language server close failed: #{error.message}"
126
+ end
127
+ end
128
+ @buffers.delete_if { |_, value| value.equal?(buffer) }
129
+ @buffers[buffer.path] = buffer
130
+ @panes.each do |pane|
131
+ pane.editors.each do |current|
132
+ next unless current.buffer.equal?(buffer)
133
+ current.language = definition_for(buffer.path)
134
+ apply_editor_settings(current)
135
+ end
136
+ end
137
+ refresh_files
138
+ end
139
+ @opened_lsp_documents&.each_key do |client, document|
140
+ next unless document.equal?(buffer)
141
+ begin
142
+ client.save_document(LSP::Protocol.uri(buffer.path))
143
+ rescue StandardError => error
144
+ self.message = "File saved; language server notification failed: #{error.message}"
145
+ end
146
+ end
147
+ result
148
+ end
149
+ def new_buffer
150
+ @vim_states[editor]&.deactivate
151
+ buffer = Buffer.new
152
+ @buffers[buffer.object_id] = buffer
153
+ opened = @active_pane.open(buffer)
154
+ apply_editor_settings(opened)
155
+ opened
156
+ end
157
+ def focus(pane)
158
+ raise Error, "pane is not in workspace" unless @panes.include?(pane)
159
+ @vim_states[editor]&.deactivate unless pane.equal?(@active_pane)
160
+ @active_pane = pane
161
+ new_buffer unless editor
162
+ @window&.request_frame
163
+ end
164
+ def activate_tab(pane, tab)
165
+ raise Error, "tab is not in pane" unless pane.editors.include?(tab)
166
+ @vim_states[editor]&.deactivate unless tab.equal?(editor)
167
+ focus(pane)
168
+ pane.activate(pane.editors.index(tab))
169
+ end
170
+ def split(direction = :horizontal)
171
+ raise ArgumentError, "invalid split" unless [:horizontal, :vertical].include?(direction)
172
+ pane = Pane.new
173
+ if editor
174
+ opened = pane.open(editor.buffer)
175
+ opened.language = editor.language_document.definition
176
+ apply_editor_settings(opened)
177
+ end
178
+ replace = lambda do |node|
179
+ if node[:pane] == @active_pane
180
+ {direction: direction, children: [node, {pane: pane}]}
181
+ elsif node[:children]
182
+ node.merge(children: node[:children].map { |child| replace.call(child) })
183
+ else node
184
+ end
185
+ end
186
+ @layout = replace.call(@layout)
187
+ @panes << pane
188
+ focus(pane)
189
+ pane
190
+ end
191
+ def close_editor(current = editor, discard: false)
192
+ raise Error, "buffer has unsaved changes" if current.buffer.dirty? && !discard
193
+ pane = @panes.find { |item| item.editors.include?(current) }
194
+ raise Error, "editor is not in workspace" unless pane
195
+ @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
203
+ end
204
+ new_buffer unless editor
205
+ end
206
+ def clear_vim_states
207
+ @vim_states.each_value(&:dispose)
208
+ @vim_states.clear
209
+ end
210
+ def move_tab(from:, to:, editor: from.active, index: nil)
211
+ raise Error, "unknown pane" unless @panes.include?(from) && @panes.include?(to)
212
+ raise Error, "tab is not in pane" unless from.editors.include?(editor)
213
+ index ||= to.editors.length
214
+ raise Error, "invalid tab position" unless index.is_a?(Integer) && index.between?(0, to.editors.length)
215
+ index -= 1 if from.equal?(to) && from.editors.index(editor) < index
216
+ pinned = from.pinned.include?(editor)
217
+ @vim_states[self.editor]&.deactivate unless editor.equal?(self.editor)
218
+ from.detach(editor)
219
+ to.editors.insert(index, editor)
220
+ to.active_index = index
221
+ to.pin(editor) if pinned
222
+ focus(to)
223
+ end
224
+ def toggle_dock(side)
225
+ dock = @docks.fetch(side)
226
+ dock[:visible] = !dock[:visible]
227
+ end
228
+ def register_panel(name, side: :left, &render)
229
+ raise ArgumentError, "invalid dock side" unless @docks.key?(side)
230
+ @panels[name] = [side, render]
231
+ register_action("panel.#{name}") { @docks[side][:visible] = true }
232
+ end
233
+ def register_action(name, description: name, &block) = @actions.register(name, description: description, &block)
234
+ def definition_for(path)
235
+ @languages.values.find { |definition| definition.extensions.include?(File.extname(path.to_s)) || definition.extensions.include?(File.basename(path.to_s)) } || Language.for_path(path)
236
+ end
237
+ def register_language(name, extensions:, lexer: "plaintext", comment: "#", indent_open: "[\\[{(]\\s*$", indent_close: "^\\s*[\\]})]", servers: [])
238
+ raise ArgumentError, "language extensions must be strings" unless extensions.is_a?(Array) && extensions.all? { |extension| extension.is_a?(String) }
239
+ @languages[name] = Language::Definition.new(name, lexer, extensions.freeze, comment, Regexp.new(indent_open), Regexp.new(indent_close), servers)
240
+ @panes.each do |pane|
241
+ pane.editors.each do |current|
242
+ current.language = definition_for(current.buffer.path)
243
+ apply_editor_settings(current)
244
+ end
245
+ end
246
+ end
247
+ def call(name, *args)
248
+ @actions.call(name, *args)
249
+ @window&.request_frame
250
+ rescue StandardError => error
251
+ @message = error.message
252
+ @window&.request_frame
253
+ end
254
+
255
+ def theme=(theme)
256
+ @theme = theme.is_a?(Theme) ? theme : Theme.new(name: theme)
257
+ @window&.request_frame
258
+ end
259
+ def palette_open(kind)
260
+ self.palette = {kind: kind, query: +"", index: 0, matches: []}
261
+ if [:search, :replace_query, :project_search].include?(kind)
262
+ @palette[:search_options] = {regexp: false, case_sensitive: true, whole_word: false, selection_only: false}
263
+ @palette[:selection] = editor.primary.range unless editor.primary.empty?
264
+ @palette[:editor], @palette[:version] = editor, editor.buffer.version
265
+ end
266
+ update_palette
267
+ end
268
+ def show_snippet_choices(current = editor)
269
+ choices = current.snippet_choices
270
+ return unless choices
271
+ self.palette = {kind: :snippet_choices, editor: current, query: +"", index: 0, matches: choices.dup}
272
+ end
273
+ def update_palette
274
+ return unless @palette
275
+ if [:completion, :locations, :symbols, :code_actions, :outline, :branches, :settings_keys, :snippet_choices].include?(@palette[:kind])
276
+ labels = @palette[:all_matches] ||= @palette[:matches].dup
277
+ session = @palette[:search] ||= Spica::Index.new(labels).session
278
+ session.query = @palette[:query]
279
+ matches = session.matches(12)
280
+ @palette[:indices] = matches.map(&:index)
281
+ @palette[:matches] = matches.map(&:candidate)
282
+ @palette[:index] = 0
283
+ return
284
+ end
285
+ @palette[:search] ||= case @palette[:kind]
286
+ when :commands then Spica::Index.new(@actions.entries.keys).session
287
+ when :files
288
+ @finder_index ||= Spica::Index.new(((@recent_files || []).select { |path| File.file?(path) }.map { |path| path.delete_prefix(@root + File::SEPARATOR) } + files).uniq, tie_break: :index)
289
+ @finder_index.session
290
+ end
291
+ @palette[:matches] = if @palette[:search]
292
+ @palette[:search].query = @palette[:query]
293
+ @palette[:search].matches(12).map(&:candidate)
294
+ else []
295
+ end
296
+ @palette[:index] = @palette[:index].clamp(0, [@palette[:matches].length - 1, 0].max)
297
+ end
298
+ def palette_accept
299
+ search_state = @palette.slice(:search_options, :selection, :editor, :version)
300
+ if @palette[:kind] == :snippet_choices
301
+ current = @palette
302
+ self.palette = nil
303
+ return @panes.any? { |pane| pane.editors.include?(current[:editor]) } && current[:editor].choose_snippet(current[:matches][current[:index]])
304
+ elsif [:completion, :locations, :symbols, :code_actions].include?(@palette[:kind])
305
+ current = @palette
306
+ self.palette = nil
307
+ index = current[:indices] ? current[:indices][current[:index]] : current[:index]
308
+ return accept_language_result(current, index) if index
309
+ elsif @palette[:kind] == :outline
310
+ current = @palette
311
+ index = @palette[:indices] ? @palette[:indices][@palette[:index]] : @palette[:index]
312
+ symbol = index && @palette[:items][index]
313
+ self.palette = nil
314
+ if symbol && current[:editor].equal?(editor) && current[:version] == editor.buffer.version && current[:document].equal?(editor.language_document)
315
+ editor.select(symbol.selection.begin)
316
+ editor.reveal_cursor
317
+ end
318
+ return
319
+ end
320
+ selected = @palette[:matches][@palette[:index]]
321
+ kind, query, pattern = @palette.values_at(:kind, :query, :pattern)
322
+ self.palette = nil
323
+ if kind == :files && selected
324
+ open(selected)
325
+ elsif kind == :commands && selected
326
+ call(selected)
327
+ elsif kind == :search
328
+ pattern, options = search_query(query, search_state)
329
+ matches = editor.search(pattern, **options)
330
+ editor.select(matches.first.begin, matches.first.end) unless matches.empty?
331
+ @message = "#{matches.length} matches"
332
+ elsif kind == :project_search
333
+ search_project(query, **search_state.fetch(:search_options, {}).reject { |key, _| key == :selection_only })
334
+ elsif kind == :replace_query
335
+ search_query(query, search_state) # Validate before accepting replacement text.
336
+ self.palette = {kind: :replace_value, query: +"", pattern: query, index: 0, matches: [], **search_state}
337
+ elsif kind == :replace_value
338
+ expression, options = search_query(pattern, search_state)
339
+ @message = "#{replace_in_buffer(expression, query, **options)} replacements (unsaved)"
340
+ elsif kind == :save_as
341
+ save_buffer(path: query)
342
+ elsif kind == :rename
343
+ language_request(:rename, name: query)
344
+ elsif kind == :workspace_symbols
345
+ language_request(:workspace_symbols, query: query)
346
+ elsif kind == :create_file || kind == :create_folder
347
+ create_project_entry(query, directory: kind == :create_folder)
348
+ elsif kind == :rename_file
349
+ rename_project_entry(@selected_project_path, query)
350
+ elsif kind == :trash_file && selected == "Move to trash"
351
+ trash_project_entry(@selected_project_path)
352
+ elsif kind == :branches && selected
353
+ checkout_branch(selected)
354
+ elsif kind == :settings_keys && selected
355
+ editor.insert_text("#{JSON.generate(selected)}: #{JSON.generate(Settings::DEFAULTS.fetch(selected))}", auto_indent: false)
356
+ end
357
+ end
358
+ def search_query(query, state)
359
+ if state[:editor] && (!state[:editor].equal?(editor) || state[:version] != editor.buffer.version)
360
+ raise Error, "Document changed; open search again"
361
+ end
362
+ options = state.fetch(:search_options, {})
363
+ pattern = options[:regexp] ? Regexp.new(query, options[:case_sensitive] == false ? Regexp::IGNORECASE : 0, timeout: 0.25) : query
364
+ search_options = options.slice(:case_sensitive, :whole_word)
365
+ if options[:selection_only]
366
+ raise Error, "Select text before opening search" unless state[:selection]
367
+ search_options[:range] = state[:selection]
368
+ end
369
+ [pattern, search_options]
370
+ end
371
+ def connect_server(language, command)
372
+ options = normalize_server_options({"command" => command})
373
+ (@client_lock ||= Mutex.new).synchronize { ensure_language_server(language, options) }
374
+ end
375
+ def drain
376
+ if @main_queue
377
+ loop do
378
+ callback = @main_queue.pop(true)
379
+ begin
380
+ callback.call unless @closed
381
+ rescue StandardError => error
382
+ @message = error.message
383
+ end
384
+ end
385
+ end
386
+ rescue ThreadError
387
+ nil
388
+ end
389
+ def close
390
+ @closed = true
391
+ cancel_project_search
392
+ @palette&.dig(:response)&.fulfill({"applied" => false, "failureReason" => "Workspace closed"})
393
+ self.palette = nil
394
+ @plugins&.close
395
+ @watcher&.close
396
+ stop_language_servers
397
+ @terminal&.close
398
+ clear_vim_states
399
+ @panes.each { |pane| pane.editors.each(&:dispose) }
400
+ @buffers.each_value(&:close)
401
+ end
402
+
403
+ private
404
+ def register_actions
405
+ register_action("file.new") { new_buffer }
406
+ 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 }
408
+ register_action("file.find") { palette_open(:files) }
409
+ register_action("project.new_file") { project_prompt(:create_file) }
410
+ register_action("project.new_folder") { project_prompt(:create_folder) }
411
+ register_action("project.rename") { project_prompt(:rename_file) }
412
+ register_action("project.trash") { project_prompt(:trash_file) }
413
+ register_action("git.diff") { show_git_diff }
414
+ register_action("git.toggle_hunk") { toggle_git_hunk }
415
+ register_action("git.blame") { show_git_blame }
416
+ register_action("git.revert_hunk") { revert_current_hunk }
417
+ register_action("git.branches") { self.palette = {kind: :branches, query: +"", index: 0, matches: git ? git.branches : []} }
418
+ register_action("command.palette") { palette_open(:commands) }
419
+ register_action("pane.split_right") { split(:horizontal) }
420
+ register_action("pane.split_down") { split(:vertical) }
421
+ register_action("pane.next") { focus(@panes[(@panes.index(@active_pane) + 1) % @panes.length]) }
422
+ register_action("tab.pin") { @active_pane.pin }
423
+ register_action("tab.back") { @vim_states[editor]&.deactivate; @active_pane.back }
424
+ register_action("tab.forward") { @vim_states[editor]&.deactivate; @active_pane.forward }
425
+ register_action("edit.undo") { editor.undo }
426
+ register_action("edit.redo") { editor.redo }
427
+ register_action("edit.select_all") { editor.select_all }
428
+ register_action("edit.select_next") { editor.select_next_occurrence }
429
+ register_action("edit.select_all_occurrences") { editor.select_next_occurrence(all: true) }
430
+ register_action("edit.duplicate_line") { editor.duplicate_lines }
431
+ %i[up down].each do |direction|
432
+ register_action("edit.move_line_#{direction}") { editor.move_lines(direction); editor.reveal_cursor }
433
+ register_action("editor.paragraph_#{direction}") { editor.move(:"paragraph_#{direction}") }
434
+ end
435
+ register_action("edit.toggle_comment") { editor.toggle_comment(prefix: editor.language_document.definition.comment) }
436
+ register_action("language.outline") { show_outline }
437
+ register_action("language.workspace_symbols") { palette_open(:workspace_symbols) }
438
+ %i[completion hover definition typeDefinition implementation references formatting codeAction signatureHelp documentSymbol inlayHint codeLens diagnostic semantic_tokens].each do |kind|
439
+ register_action("language.#{kind}") { language_request(kind) }
440
+ end
441
+ register_action("language.rename") { self.palette = {kind: :rename, query: +"", index: 0, matches: []} }
442
+ register_action("editor.fold") { fold_current }
443
+ register_action("editor.unfold") { editor.display_map.unfold(editor.primary.head) }
444
+ register_action("edit.indent") { editor.indent }
445
+ register_action("edit.outdent") { editor.indent(outdent: true) }
446
+ register_action("search.buffer") { palette_open(:search) }
447
+ register_action("search.project") { palette_open(:project_search) }
448
+ register_action("search.replace") { palette_open(:replace_query) }
449
+ register_action("settings.open") { open_settings }
450
+ register_action("settings.complete") { settings_completions }
451
+ register_action("language.diagnostics") { show_diagnostics }
452
+ register_action("view.project") { @show_project = !@show_project }
453
+ [:left, :right, :bottom].each { |side| register_action("view.dock_#{side}") { toggle_dock(side) } }
454
+ register_action("view.wrap") { editor.display_map.wrap_width = editor.display_map.wrap_map.width ? nil : 100 }
455
+ register_action("view.theme") { self.theme = @theme.name.include?("Dark") ? "Canopus Light" : "Canopus Dark" }
456
+ register_action("view.vim") do
457
+ @settings.merge!("vim_mode" => !@settings["vim_mode"])
458
+ clear_vim_states unless @settings["vim_mode"]
459
+ end
460
+ register_action("view.terminal") do
461
+ @terminal ||= Terminal::PTY.new(cwd: @root, columns: 100, rows: 12)
462
+ @terminal_visible = !@terminal_visible
463
+ end
464
+ end
465
+ def encode_layout(node)
466
+ node[:pane] ? {pane: @panes.index(node[:pane])} : {direction: node[:direction], ratio: node.fetch(:ratio, 0.5), children: node[:children].map { |child| encode_layout(child) }}
467
+ end
468
+ def project_prompt(kind)
469
+ path = @selected_project_path || editor.buffer.path&.delete_prefix(@root + File::SEPARATOR) || ""
470
+ @selected_project_path = path
471
+ query = if kind == :rename_file || kind == :trash_file
472
+ path
473
+ else
474
+ directory = File.directory?(File.join(@root, path)) ? path : File.dirname(path)
475
+ directory == "." || directory.empty? ? "" : directory + "/"
476
+ end
477
+ self.palette = {kind: kind, query: +query, index: 0, matches: kind == :trash_file ? ["Move to trash", "Cancel"] : []}
478
+ end
479
+ def decode_layout(node, depth = 0, panes: @panes)
480
+ raise Error, "session layout too deep" if depth > 32
481
+ if node.key?("pane")
482
+ {pane: panes.fetch(node["pane"])}
483
+ else
484
+ direction = node.fetch("direction").to_sym
485
+ raise Error, "invalid split direction" unless [:horizontal, :vertical].include?(direction)
486
+ children = node.fetch("children")
487
+ raise Error, "split must contain two panes" unless children.is_a?(Array) && children.length == 2
488
+ ratio = node.fetch("ratio", 0.5)
489
+ raise Error, "invalid split ratio" unless ratio.is_a?(Numeric) && ratio.between?(0.1, 0.9)
490
+ {direction: direction, ratio: ratio, children: children.map { |child| decode_layout(child, depth + 1, panes: panes) }}
491
+ end
492
+ end
493
+ def ex_command(command)
494
+ case command
495
+ when "w" then call("file.save")
496
+ when /\Aw / then save_buffer(path: command.delete_prefix("w "))
497
+ when "q" then call("file.close")
498
+ when "q!" then close_editor(discard: true)
499
+ when "wq" then call("file.save"); call("file.close")
500
+ when "split", "sp" then split(:vertical)
501
+ when "vsplit", "vs" then split(:horizontal)
502
+ else open(command.delete_prefix("e ")) if command.start_with?("e ")
503
+ end
504
+ end
505
+ end
506
+ end
507
+
508
+ require_relative "workspace/session_persistable"
509
+ require_relative "workspace/project_tree_editable"
510
+ require_relative "workspace/file_change_aware"
511
+ require_relative "workspace/language_server_configurable"
512
+ require_relative "workspace/language_aware"
513
+ require_relative "workspace/git_aware"
514
+ require_relative "workspace/project_searchable"
515
+ require_relative "workspace/settings_aware"
516
+ require_relative "workspace/file_previewable"
517
+ Canopus::Workspace.include Canopus::Workspace::SessionPersistable
518
+ Canopus::Workspace.include Canopus::Workspace::ProjectTreeEditable
519
+ Canopus::Workspace.include Canopus::Workspace::FileChangeAware
520
+ Canopus::Workspace.include Canopus::Workspace::LanguageServerConfigurable
521
+ Canopus::Workspace.include Canopus::Workspace::LanguageAware
522
+ Canopus::Workspace.include Canopus::Workspace::GitAware
523
+ Canopus::Workspace.include Canopus::Workspace::ProjectSearchable
524
+ Canopus::Workspace.include Canopus::Workspace::SettingsAware
525
+ Canopus::Workspace.include Canopus::Workspace::FilePreviewable
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canopus
4
+ class WrapMap
5
+ attr_reader :width, :font, :font_size, :font_paths, :typesetter
6
+ def initialize(width: nil, font: nil, font_size: 14, font_paths: nil, typesetter: nil)
7
+ raise ArgumentError, "use typesetter or font/font_paths, not both" if typesetter && (font || font_paths)
8
+ font ||= typesetter&.font
9
+ valid = font ? width.is_a?(Numeric) && width.finite? && width.positive? : width.is_a?(Integer) && width.positive?
10
+ raise ArgumentError, "wrap width must be positive" if width && !valid
11
+ raise ArgumentError, "font size must be finite and positive" unless font_size.is_a?(Numeric) && font_size.finite? && font_size.positive?
12
+ @width, @font, @font_size = width, font, font_size
13
+ @font_paths = font_paths&.frozen? ? font_paths : font_paths&.dup&.freeze
14
+ @typesetter, @typesetters = typesetter, {}
15
+ if @font
16
+ @layout_template = if typesetter
17
+ typesetter.fork(capacity: 32)
18
+ else
19
+ Zaniah::TextSystem::Typesetter.new(
20
+ font: Alhena::Font.new(@font.data, index: @font.index, axes: @font.axis_values),
21
+ font_db: Zaniah::TextSystem::FontDB.new(paths: @font_paths), capacity: 32,
22
+ shaper: :native, segmenter: :native)
23
+ end
24
+ end
25
+ end
26
+ def transform(text, offsets, checkpoint: nil)
27
+ return pixel_rows(text, offsets, checkpoint) if @width && @font
28
+ return [[text.freeze, offsets.freeze]] if !@width || (text.ascii_only? && text.length <= @width)
29
+ rows, offset, chunk, positions, cells = [], 0, +"", [offsets.first], 0
30
+ text.each_grapheme_cluster do |grapheme|
31
+ checkpoint&.call if (offset & 1023).zero?
32
+ advance = grapheme.ascii_only? ? grapheme.length : Zaniah::Unicode.width(grapheme)
33
+ if !chunk.empty? && cells + advance > @width
34
+ rows << [chunk.freeze, positions.freeze]
35
+ chunk, positions, cells = +"", [offsets[offset]], 0
36
+ end
37
+ chunk << grapheme
38
+ cells += advance
39
+ grapheme.length.times do
40
+ checkpoint&.call if (offset & 1023).zero?
41
+ offset += 1
42
+ positions << offsets[offset]
43
+ end
44
+ end
45
+ rows << [chunk.freeze, positions.freeze]
46
+ rows
47
+ end
48
+ def close
49
+ @typesetters.each_value(&:close)
50
+ @typesetters.clear
51
+ @layout_template&.close
52
+ @layout_template = nil
53
+ end
54
+
55
+ private
56
+ def pixel_rows(text, offsets, checkpoint)
57
+ # The template is never used for shaping. Each owner copies its complete
58
+ # typography without reading the rendering thread's mutable caches.
59
+ typesetter = @typesetters[Thread.current] ||= @layout_template.fork(capacity: 32)
60
+ rows, pending, positions, column = [], +"", [offsets.first], 0
61
+ # Bound each shaping input; keep grapheme clusters intact, including one
62
+ # unusually large cluster. The last row carries into the next batch.
63
+ pixel_graphemes(typesetter, text).each do |grapheme|
64
+ checkpoint&.call
65
+ pending << grapheme
66
+ grapheme.length.times { column += 1; positions << offsets[column] }
67
+ next if pending.bytesize < 4096
68
+ parts = split_pixels(typesetter, pending, positions, checkpoint)
69
+ pending, positions = parts.pop
70
+ rows.concat(parts)
71
+ pending, positions = pending.dup, positions.dup
72
+ end
73
+ rows.concat(split_pixels(typesetter, pending, positions, checkpoint))
74
+ rows
75
+ end
76
+
77
+ def split_pixels(typesetter, text, offsets, checkpoint)
78
+ layout = typesetter.layout_line(text, size: @font_size)
79
+ return [[text.freeze, offsets.freeze]] if layout.width <= @width
80
+ clusters, bytes, columns = pixel_graphemes(typesetter, text).to_a, [0], [0]
81
+ clusters.each { |value| bytes << bytes.last + value.bytesize; columns << columns.last + value.length }
82
+ rows, first = [], 0
83
+ while first < clusters.length
84
+ checkpoint&.call
85
+ origin = layout.x_for_index(bytes[first])
86
+ last = first + 1
87
+ last += 1 while last < clusters.length && layout.x_for_index(bytes[last + 1]) - origin <= @width
88
+ value = loop do
89
+ value = text.byteslice(bytes[first]...bytes[last])
90
+ break value if last == first + 1 || typesetter.layout_line(value, size: @font_size).width <= @width
91
+ last -= 1 # Re-shaping a row can change boundary kerning/ligatures.
92
+ end
93
+ rows << [value.freeze, offsets[columns[first]..columns[last]].freeze]
94
+ first = last
95
+ end
96
+ rows
97
+ end
98
+
99
+ def pixel_graphemes(typesetter, text)
100
+ return text.each_grapheme_cluster if typesetter.segmenter.equal?(Zaniah::Unicode)
101
+ parts = typesetter.segmenter.grapheme_clusters(text)
102
+ unless parts.is_a?(Array) && parts.all? { |part| part.is_a?(String) && !part.empty? && part.valid_encoding? } && parts.join == text
103
+ raise Zaniah::Error, "segmenter must partition the UTF-8 text into nonempty strings"
104
+ end
105
+ parts
106
+ end
107
+ end
108
+ end
data/lib/canopus.rb ADDED
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "canopus/version"
4
+ require_relative "canopus/data_compat"
5
+ require_relative "canopus/regexp_compat"
6
+ require_relative "canopus/match_data_compat"
7
+ require_relative "canopus/error"
8
+ require_relative "canopus/save_conflict"
9
+
10
+ require "denebola"
11
+ require "zaniah"
12
+ require_relative "canopus/patch"
13
+ require_relative "canopus/buffer"
14
+ require_relative "canopus/multi_buffer"
15
+ require_relative "canopus/display_map"
16
+ require_relative "canopus/editor"
17
+ require_relative "canopus/language"
18
+ require "spica"
19
+ require_relative "canopus/project"
20
+ require_relative "canopus/git"
21
+ require_relative "canopus/terminal"
22
+ require_relative "canopus/workspace"
23
+ require_relative "canopus/plugins"
24
+ require_relative "canopus/controller"