fatty 0.99.0 → 0.99.3

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 (120) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.org +11 -29
  3. data/README.md +2207 -18
  4. data/README.org +2066 -104
  5. data/exe/fatty +236 -93
  6. data/help/help.md +233 -0
  7. data/lib/fatty/action.rb +3 -7
  8. data/lib/fatty/actionable.rb +2 -0
  9. data/lib/fatty/alert.rb +65 -39
  10. data/lib/fatty/ansi/renderer.rb +26 -6
  11. data/lib/fatty/ansi.rb +1 -0
  12. data/lib/fatty/api/alert.rb +15 -0
  13. data/lib/fatty/api/choose.rb +145 -0
  14. data/lib/fatty/api/environment.rb +12 -0
  15. data/lib/fatty/api/keytest.rb +11 -0
  16. data/lib/fatty/api/menu.rb +104 -0
  17. data/lib/fatty/api/output.rb +55 -0
  18. data/lib/fatty/api/progress.rb +24 -0
  19. data/lib/fatty/api/prompt.rb +77 -0
  20. data/lib/fatty/api/status.rb +55 -0
  21. data/lib/fatty/api.rb +38 -0
  22. data/lib/fatty/callback_environment.rb +74 -0
  23. data/lib/fatty/colors/color.rb +366 -232
  24. data/lib/fatty/colors/pairs.rb +2 -0
  25. data/lib/fatty/command.rb +78 -0
  26. data/lib/fatty/config.rb +227 -39
  27. data/lib/fatty/config_files/config.yml +4 -12
  28. data/lib/fatty/config_files/keydefs.yml +13 -23
  29. data/lib/fatty/config_files/themes/capuchin_monk.yml +140 -0
  30. data/lib/fatty/config_files/themes/catppuccin_latte.yml +140 -0
  31. data/lib/fatty/config_files/themes/catppuccin_mocha.yml +140 -0
  32. data/lib/fatty/config_files/themes/cyberpunk.yml +141 -0
  33. data/lib/fatty/config_files/themes/dracula.yml +140 -0
  34. data/lib/fatty/config_files/themes/everforest_dark.yml +140 -0
  35. data/lib/fatty/config_files/themes/gruvbox_dark.yml +140 -0
  36. data/lib/fatty/config_files/themes/gruvbox_light.yml +140 -0
  37. data/lib/fatty/config_files/themes/mono.yml +119 -47
  38. data/lib/fatty/config_files/themes/monokai.yml +140 -0
  39. data/lib/fatty/config_files/themes/nordic.yml +1 -0
  40. data/lib/fatty/config_files/themes/onedark.yml +140 -0
  41. data/lib/fatty/config_files/themes/solarized_dark.yml +1 -0
  42. data/lib/fatty/config_files/themes/solarized_light.yml +140 -0
  43. data/lib/fatty/config_files/themes/terminal.yml +67 -0
  44. data/lib/fatty/config_files/themes/tokyo_night.yml +140 -0
  45. data/lib/fatty/config_files/themes/wordperfect.yml +80 -9
  46. data/lib/fatty/core_ext/hash.rb +19 -0
  47. data/lib/fatty/core_ext/string.rb +19 -12
  48. data/lib/fatty/core_ext.rb +1 -0
  49. data/lib/fatty/curses/context.rb +86 -84
  50. data/lib/fatty/curses/event_source.rb +128 -40
  51. data/lib/fatty/curses/key_decoder.rb +77 -11
  52. data/lib/fatty/env.rb +21 -20
  53. data/lib/fatty/help.rb +1 -29
  54. data/lib/fatty/history/entry.rb +2 -0
  55. data/lib/fatty/history.rb +17 -14
  56. data/lib/fatty/input_buffer.rb +71 -47
  57. data/lib/fatty/input_field.rb +216 -149
  58. data/lib/fatty/key_event.rb +67 -66
  59. data/lib/fatty/key_map.rb +54 -16
  60. data/lib/fatty/keymaps/emacs.rb +40 -6
  61. data/lib/fatty/log_formats/json.rb +2 -0
  62. data/lib/fatty/log_formats/text.rb +2 -0
  63. data/lib/fatty/logger.rb +7 -2
  64. data/lib/fatty/markdown/ansi_renderer.rb +75 -6
  65. data/lib/fatty/markdown/render.rb +2 -3
  66. data/lib/fatty/mouse_event.rb +142 -2
  67. data/lib/fatty/output_buffer.rb +2 -0
  68. data/lib/fatty/pager.rb +158 -47
  69. data/lib/fatty/progress.rb +417 -0
  70. data/lib/fatty/renderer/curses.rb +340 -184
  71. data/lib/fatty/renderer/truecolor.rb +266 -163
  72. data/lib/fatty/renderer.rb +115 -97
  73. data/lib/fatty/screen.rb +2 -0
  74. data/lib/fatty/session/alert_session.rb +39 -21
  75. data/lib/fatty/session/isearch_session.rb +92 -38
  76. data/lib/fatty/session/keytest_session.rb +105 -85
  77. data/lib/fatty/session/modal_session.rb +38 -1
  78. data/lib/fatty/session/output_session.rb +472 -21
  79. data/lib/fatty/session/popup_session.rb +251 -134
  80. data/lib/fatty/session/prompt_session.rb +68 -26
  81. data/lib/fatty/session/search_session.rb +79 -31
  82. data/lib/fatty/session/shell_session.rb +383 -368
  83. data/lib/fatty/session/status_session.rb +167 -0
  84. data/lib/fatty/session.rb +95 -95
  85. data/lib/fatty/terminal/popup_owner.rb +14 -11
  86. data/lib/fatty/terminal.rb +515 -813
  87. data/lib/fatty/themes/loader.rb +4 -2
  88. data/lib/fatty/themes/manager.rb +4 -0
  89. data/lib/fatty/themes/registry.rb +2 -0
  90. data/lib/fatty/themes/resolver.rb +175 -162
  91. data/lib/fatty/version.rb +1 -1
  92. data/lib/fatty/viewport.rb +8 -1
  93. data/lib/fatty.rb +35 -7
  94. metadata +42 -40
  95. data/.envrc +0 -2
  96. data/.simplecov +0 -23
  97. data/.yardopts +0 -4
  98. data/CHANGELOG.md +0 -34
  99. data/Rakefile +0 -15
  100. data/TODO.org +0 -163
  101. data/examples/markdown/native-markdown.md +0 -370
  102. data/examples/markdown/ox-gfm-markdown.md +0 -373
  103. data/examples/markdown/ox-gfm-markdown.org +0 -376
  104. data/fatty.gemspec +0 -42
  105. data/lib/fatty/accept_env.rb +0 -32
  106. data/lib/fatty/config_files/help.md +0 -120
  107. data/lib/fatty/config_files/help.org +0 -124
  108. data/lib/fatty/config_files/themes/wordperfect_light.yml +0 -77
  109. data/lib/fatty/menu_env.rb +0 -22
  110. data/lib/fatty/session/input_session.rb +0 -99
  111. data/lib/fatty/sessions.rb +0 -14
  112. data/lib/fatty/terminal/progress.rb +0 -374
  113. data/lib/fatty/view/alert_view.rb +0 -14
  114. data/lib/fatty/view/cursor_view.rb +0 -18
  115. data/lib/fatty/view/input_view.rb +0 -9
  116. data/lib/fatty/view/output_view.rb +0 -9
  117. data/lib/fatty/view/status_view.rb +0 -14
  118. data/lib/fatty/view.rb +0 -33
  119. data/lib/fatty/views.rb +0 -9
  120. data/sig/fatty.rbs +0 -4
@@ -3,21 +3,19 @@
3
3
  require "open3"
4
4
 
5
5
  module Fatty
6
- class ShellSession < OutputSession
6
+ class ShellSession < Session
7
7
  action_on :session
8
8
 
9
- attr_reader :field, :history
9
+ attr_reader :field, :history, :output_session, :on_accept
10
10
 
11
- def initialize(prompt: "sh> ", on_accept: nil, completion_proc: nil, history_ctx: nil, history_path: :default)
11
+ def initialize(id: nil, prompt: "sh> ", on_accept: nil, completion_proc: nil, history_ctx: nil, history_path: :default)
12
+ keymap = Keymaps.emacs
12
13
  super(
13
- keymap: Keymaps.emacs,
14
- views: [
15
- Fatty::OutputView.new(z: 0),
16
- Fatty::InputView.new(z: 10),
17
- Fatty::CursorView.new(z: 100),
18
- ]
14
+ id: :shell,
15
+ keymap: keymap,
19
16
  )
20
17
  @history = Fatty::History.for_path(history_path)
18
+ @output_session = OutputSession.new(keymap: keymap, history: @history)
21
19
  @field = Fatty::InputField.new(
22
20
  prompt: prompt,
23
21
  history: @history,
@@ -25,165 +23,170 @@ module Fatty
25
23
  history_kind: :command,
26
24
  history_ctx: history_ctx,
27
25
  )
28
-
29
26
  @on_accept = on_accept
30
27
  @completion_proc = completion_proc
28
+ @completion_range = nil
29
+ @completion_path = nil
30
+ @completion_popup_prefix = nil
31
31
  end
32
32
 
33
33
  #########################################################################################
34
- # Framework and Session Hooks
34
+ # Session Protocol
35
35
  #########################################################################################
36
36
 
37
37
  def init(terminal:)
38
38
  super
39
- resize_output!
40
- []
41
- end
42
-
43
- def keymap_contexts
44
- pager_active? ? [:paging, :terminal] : [:input, :text, :terminal]
45
- end
46
-
47
- def update_key(ev)
48
- return [] unless ev.is_a?(Fatty::KeyEvent)
49
-
50
- key_str = "key=#{ev} raw=#{ev.raw}"
51
- Fatty.debug("ShellSession.update_key: #{key_str}", tag: :session)
52
- case ev.key
53
- when :resize
54
- [[:terminal, :handle_resize]]
55
- when :enter, :return
56
- # safety: if somehow not bound, still accept
57
- submit_line
58
- else
59
- [alert_cmd(:warn, "Unbound key: #{ev} (edit config in 'keybindings.yml' to bind)", ev: ev)]
60
- end
39
+ output_session.init(terminal: terminal)
40
+ [
41
+ Command.terminal(:register_session, session: output_session),
42
+ Command.session(output_session.id, :resize),
43
+ ]
44
+ end
45
+
46
+ def update(command)
47
+ log_update(command)
48
+ commands =
49
+ case command.action
50
+ when :terminal_paste
51
+ field.act_on(:paste, command.payload.fetch(:text, ""), env: action_env(event: nil))
52
+ []
53
+ when :popup_result
54
+ apply_popup_result(command.payload)
55
+ when :paste
56
+ text = command.payload.fetch(:text, "").to_s
57
+ env = action_env(event: nil)
58
+ field.act_on(:paste, text, env: env)
59
+ []
60
+ when :popup_tab
61
+ apply_completion_popup_tab(command.payload)
62
+ when :popup_backtab
63
+ apply_completion_popup_backtab(command.payload)
64
+ when :key
65
+ ev = command.payload.fetch(:event)
66
+ if output_session.pager_active?
67
+ Command.session(output_session.id, :key, event: ev)
68
+ elsif ev.is_a?(Fatty::KeyEvent)
69
+ action, args = resolve_action(ev)
70
+ if action
71
+ apply_action(action, args, event: ev)
72
+ else
73
+ case ev.key
74
+ when :enter, :return
75
+ apply_action(:submit_line, [], event: ev)
76
+ else
77
+ unrecognized_key_alert(ev)
78
+ end
79
+ end
80
+ else
81
+ []
82
+ end
83
+ else
84
+ []
85
+ end
86
+ Array(commands)
61
87
  end
62
88
 
63
- def view(screen:, renderer:)
64
- if pager_active?
65
- ::Curses.curs_set(0)
66
- viewport = pager_status_viewport(screen)
67
- highlights = pager.search_visible_highlights(viewport: viewport)
68
-
69
- renderer.render_output(output, viewport: viewport, highlights: highlights)
70
- renderer.render_pager_field(
71
- pager_field,
72
- row: screen.output_rect.rows - 1,
73
- role: :pager_status,
74
- )
89
+ def view
90
+ Fatty.debug(
91
+ "ShellSession#view output_session=" \
92
+ "#{output_session.class}:#{output_session.object_id}",
93
+ tag: :render,
94
+ )
95
+ output_session.view
96
+ if input_suppressed?
97
+ renderer.clear_input_field
75
98
  else
76
- ::Curses.curs_set(1)
77
- renderer.render_output(output, viewport: pager_viewport, highlights: nil)
78
99
  renderer.render_input_field(field)
79
- renderer.restore_cursor(field)
80
100
  end
81
101
  end
82
102
 
83
- def pager_status_viewport(screen)
84
- vp = pager_viewport.dup
85
- vp.height = [screen.output_rect.rows - 1, 1].max
86
- vp
87
- end
88
-
89
- # Save any state we want saved on quit, error, etc.
90
- def persist!
91
- return unless @history.respond_to?(:save!)
92
-
93
- Fatty.debug("ShellSession#persist!: saving history", tag: :history)
94
- @history.save!
95
- rescue => e
96
- Fatty.error("ShellSession#persist!: failed to save history: #{e.class}: #{e.message}", tag: :history)
97
- end
98
-
99
- # Called by Terminal#go on every loop iteration.
100
- # Returns true if any visible state changed (dirty).
101
- def tick
102
- dirty = false
103
- # Animated autoscroll (e.g. after M-s in paging mode).
104
- if pager.autoscroll?
105
- step = [(viewport.height * 3) / 4, 1].max
106
- dirty ||= pager.autoscroll_step?(max_lines: step)
107
- end
108
- dirty
109
- end
110
-
111
- ############################################################################################
103
+ #########################################################################################
112
104
  # Actions
113
- ############################################################################################
114
-
115
- def action_env(event:)
116
- Fatty::ActionEnvironment.new(
117
- session: self,
118
- counter: counter,
119
- event: event,
120
- buffer: @field.buffer,
121
- field: @field,
122
- pager: pager,
123
- )
124
- end
105
+ #########################################################################################
125
106
 
126
- desc "Pass the current shell input line to the proc"
107
+ desc "Pass the current input line to the on_accept proc or a shell"
127
108
  action :submit_line do
128
- submit_line
129
- end
130
-
131
- # Perform the on_accept action if defined, but catch a few special
132
- # commands for quiting and clearing.
133
- def submit_line
134
- line = @field.accept_line.to_s.strip
135
- return [] if line.empty?
109
+ line = @field.accept_line.to_s
110
+ return [] if line.blank? && on_accept.nil?
136
111
 
137
112
  Fatty.info("ShellSession: accept_line: #{line}")
138
-
139
113
  case line
140
114
  when "exit", "quit"
141
- [[:terminal, :quit]]
115
+ Command.terminal(:quit)
142
116
  when "clear"
143
- reset_output!
144
- [[:send, :alert, :clear, {}]]
117
+ [
118
+ Command.session(output_session.id, :clear),
119
+ Command.session(:alert, :clear),
120
+ ]
145
121
  else
146
- reset_for_command!
147
- anchor = output.lines.length
148
- pager.begin_command!(anchor: anchor)
122
+ commands = []
123
+ env = accept_env
124
+ terminal.apply_command(Command.session(output_session.id, :begin_command))
125
+ result = nil
126
+ interrupted = false
127
+
128
+ begin
129
+ result =
130
+ if @on_accept
131
+ @on_accept.call(line, env)
132
+ else
133
+ run_default_command(line)
134
+ end
135
+ rescue Fatty::Interrupt
136
+ interrupted = true
137
+ end
149
138
 
150
- commands =
151
- if @on_accept
152
- @on_accept.call(line, accept_env)
153
- else
154
- run_default_command(line)
139
+ if interrupted
140
+ finish_interrupted_command(env)
141
+ else
142
+ commands.concat(env.commands)
143
+
144
+ if result.is_a?(String)
145
+ commands << Command.session(
146
+ output_session.id,
147
+ :append,
148
+ text: result,
149
+ follow: true,
150
+ )
151
+ elsif result.is_a?(Fatty::Command)
152
+ commands << result
153
+ elsif result.is_a?(Array)
154
+ commands.concat(result.grep(Fatty::Command))
155
+ elsif result
156
+ commands << Command.session(
157
+ output_session.id,
158
+ :append,
159
+ text: result.to_s,
160
+ follow: true,
161
+ )
155
162
  end
156
- normalize_accept_commands(commands)
163
+
164
+ commands << Command.session(output_session.id, :finish_command)
165
+ commands
166
+ end
157
167
  end
158
- rescue Errno::ENOENT
159
- [[:send, :alert, :show, { level: :error, message: "Command not found (#{line})" }]]
160
168
  end
161
169
 
162
170
  desc "Accept the current shell input line and switch output to scrolling"
163
171
  action :submit_and_scroll do
164
- before = output.lines.length
165
- cmds = submit_line
166
- pager.paging_to_scrolling if output.lines.length > before
167
- cmds
172
+ submit_line << Command.session(output_session.id, :paging_to_scrolling)
168
173
  end
169
174
 
170
175
  desc "Interrupt scrolling, otherwise quit Fatty"
171
176
  action :interrupt do
172
- if pager.mode == :scrolling
173
- pager.quit_paging
174
- []
177
+ if output_session.pager_active?
178
+ Command.session(output_session.id, :quit_paging)
175
179
  else
176
- [[:terminal, :quit]]
180
+ Command.terminal(:quit)
177
181
  end
178
182
  end
179
183
 
180
184
  desc "Quit if input is empty, otherwise delete forward"
181
185
  action :interrupt_if_empty do
182
- if pager.mode == :scrolling
183
- pager.quit_paging
184
- []
186
+ if output_session.pager_active?
187
+ Command.session(output_session.id, :quit_paging)
185
188
  elsif @field.empty?
186
- [[:terminal, :quit]]
189
+ Command.terminal(:quit)
187
190
  else
188
191
  @field.act_on(:delete_char_forward, env: action_env(event: nil))
189
192
  []
@@ -192,147 +195,248 @@ module Fatty
192
195
 
193
196
  desc "Clear shell output"
194
197
  action :clear_output do
195
- reset_output!
196
- []
198
+ Command.session(output_session.id, :clear)
197
199
  end
198
200
 
199
- desc "Cycle to the next theme"
200
- action :cycle_theme do
201
- [[:terminal, :cycle_theme]]
202
- end
203
-
204
- desc "Choose a theme from a popup"
205
- action :choose_theme do
206
- current = Fatty::Themes::Manager.current
207
- names = Fatty::Themes::Manager.theme_names
208
- ordered = [current] + (names - [current])
209
- @theme_popup_restore = current
210
-
211
- popup = Fatty::PopUpSession.new(
212
- source: ordered,
213
- kind: :theme_chooser,
214
- title: "Themes",
215
- prompt: "Theme: ",
216
- selection: :top,
217
- )
218
-
219
- [[:terminal, :push_modal, popup]]
220
- end
221
-
222
- desc "Add a digit to the current numeric prefix"
223
- action :prefix_digit do |digit|
224
- counter.push_digit(digit)
201
+ desc "Complete the current input prefix"
202
+ action :complete do
203
+ @field.complete!
225
204
  []
226
205
  end
227
206
 
228
- desc "Complete the current input prefix"
229
- action :complete do
230
- with_virtual_suffix_sync do
231
- @field.cycle_completion!
232
- []
233
- end
207
+ desc "Complete the previous input prefix"
208
+ action :complete_previous do
209
+ @field.complete!(direction: -1)
210
+ []
234
211
  end
235
212
 
236
213
  desc "Open completion popup"
237
214
  action :completion_popup do
238
215
  with_virtual_suffix_sync do
239
216
  candidates = @field.popup_completion_candidates
217
+ completion_path = @field.popup_completion_path?
240
218
 
241
219
  if candidates.empty?
242
220
  []
243
221
  elsif candidates.length == 1
244
- apply_completion(candidates.first, range: @field.popup_completion_range)
222
+ apply_completion(
223
+ candidates.first,
224
+ range: @field.popup_completion_range,
225
+ append_space: !completion_path,
226
+ )
245
227
  else
246
- @completion_range = @field.popup_completion_range
247
228
  # NOTE: we don't pass a matcher so that the default_matcher is used.
248
- popup = Fatty::PopUpSession.new(
249
- source: candidates,
250
- kind: :completion,
251
- title: "Completions",
252
- prompt: "Complete: ",
253
- order: :as_given,
254
- selection: :top,
255
- initial_query: @field.popup_completion_query.to_s,
256
- )
257
- [[:terminal, :push_modal, popup]]
229
+ @completion_range = @field.popup_completion_range
230
+ @completion_path = completion_path
231
+ @completion_popup_prefix = completion_path ? @field.popup_completion_query.to_s : nil
232
+
233
+ completion =
234
+ Fatty::PopUpSession.new(
235
+ source: candidates,
236
+ kind: :completion,
237
+ title: completion_popup_title(@completion_popup_prefix),
238
+ prompt: "Complete: ",
239
+ order: :as_given,
240
+ current: :top,
241
+ initial_query: @field.popup_completion_query.to_s,
242
+ history: @history,
243
+ history_kind: :popup_filter,
244
+ history_ctx: {
245
+ kind: :popup_filter,
246
+ popup: :completion,
247
+ prompt: "Complete: ",
248
+ },
249
+ )
250
+
251
+ Command.terminal(:push_modal, session: completion)
258
252
  end
259
253
  end
260
254
  end
261
255
 
262
- desc "Search pager forward"
263
- action :pager_search_forward do
264
- regex = consume_search_regex_flag
265
- [[
266
- :terminal,
267
- :push_modal,
268
- Fatty::SearchSession.new(direction: :forward, regex: regex, history: @history)
269
- ]]
256
+ desc "Open command history search"
257
+ action :history_search do
258
+ src = ->(_q = nil) { @history.entries.select(&:command?).last(500).map(&:text) }
259
+ hist_searcher =
260
+ Fatty::PopUpSession.new(
261
+ source: src,
262
+ kind: :history_search,
263
+ title: "History",
264
+ prompt: "Narrow: ",
265
+ order: :as_given,
266
+ current: :bottom,
267
+ initial_query: @field.buffer.text,
268
+ history: @history,
269
+ history_kind: :popup_filter,
270
+ history_ctx: {
271
+ kind: :popup_filter,
272
+ popup: :history_search,
273
+ prompt: "Narrow: ",
274
+ },
275
+ )
276
+ Command.terminal(:push_modal, session: hist_searcher)
270
277
  end
271
278
 
272
- desc "Search pager backward"
273
- action :pager_search_backward do
274
- regex = consume_search_regex_flag
275
- [[
276
- :terminal,
277
- :push_modal,
278
- Fatty::SearchSession.new(direction: :backward, regex: regex, history: @history)
279
- ]]
279
+ #########################################################################################
280
+ # Other public API methods
281
+ #########################################################################################
282
+
283
+ # Save any state we want saved on quit, error, etc.
284
+ def persist!
285
+ return unless @history.respond_to?(:save!)
286
+
287
+ Fatty.debug("ShellSession#persist!: saving history", tag: :history)
288
+ @history.save!
289
+ rescue => e
290
+ Fatty.error("ShellSession#persist!: failed to save history: #{e.class}: #{e.message}", tag: :history)
280
291
  end
281
292
 
282
- desc "Start incremental pager search forward"
283
- action :pager_isearch_forward do
284
- [[
285
- :terminal,
286
- :push_modal,
287
- Fatty::ISearchSession.new(direction: :forward, history: @history, last_pattern: pager.search_pattern)
288
- ]]
293
+ # Called by Terminal#go on every loop iteration. Returns true if any
294
+ # visible state changed (dirty).
295
+ def tick
296
+ output_session.tick
289
297
  end
290
298
 
291
- desc "Start incremental pager search backward"
292
- action :pager_isearch_backward do
293
- [[
294
- :terminal,
295
- :push_modal,
296
- Fatty::ISearchSession.new(direction: :backward, history: @history, last_pattern: pager.search_pattern)
297
- ]]
299
+ def pager_active?
300
+ output_session.pager_active?
298
301
  end
299
302
 
300
- desc "Repeat pager search forward"
301
- action :pager_search_next do
302
- handle_search_result(pager.search_repeat_next!)
303
+ def cursor_visible?
304
+ !input_suppressed?
303
305
  end
304
306
 
305
- desc "Repeat pager search backward"
306
- action :pager_search_prev do
307
- handle_search_result(pager.search_repeat_prev!)
307
+ private
308
+
309
+ # simplecov:disable
310
+
311
+ def input_suppressed?
312
+ terminal.modal_active? || output_session.pager_active?
308
313
  end
309
314
 
310
- desc "Open command history search"
311
- action :history_search do
312
- src = ->(_q = nil) { @history.entries.select(&:command?).last(500).map(&:text) }
315
+ def keymap_contexts
316
+ [:input, :text, :terminal]
317
+ end
313
318
 
314
- popup = Fatty::PopUpSession.new(
315
- source: src,
316
- kind: :history_search,
317
- title: "History",
318
- prompt: "I-search: ",
319
- order: :as_given,
320
- selection: :bottom,
321
- initial_query: @field.buffer.text,
319
+ def apply_action(action, args, event:)
320
+ which =
321
+ if event&.respond_to?(:key)
322
+ event.key.inspect
323
+ elsif event&.respond_to?(:mouse)
324
+ event.mouse.inspect
325
+ end
326
+
327
+ Fatty.debug("ShellSession#apply_action: #{which}", tag: :keymap)
328
+
329
+ unless completion_action?(action)
330
+ @field.reset_completion_state!
331
+ end
332
+
333
+ env = action_env(event: event)
334
+ defn = Fatty::Actions.lookup(action)
335
+ result = Fatty::Actions.call(action, env, *args)
336
+
337
+ @field.sync_virtual_suffix! if defn && [:field, :buffer].include?(defn[:on])
338
+ normalize_action_result(result)
339
+ rescue Fatty::ActionError => e
340
+ [alert_cmd(:error, e.message, ev: event)]
341
+ end
342
+
343
+ def apply_popup_result(payload)
344
+ commands = []
345
+ case payload[:kind]&.to_sym
346
+ when :history_search
347
+ item = payload.fetch(:item, "").to_s
348
+ env = action_env(event: nil)
349
+ with_virtual_suffix_sync do
350
+ Fatty::Actions.call(:replace, env, item)
351
+ end
352
+ when :completion
353
+ apply_completion(
354
+ payload.fetch(:item, "").to_s,
355
+ range: @completion_range,
356
+ append_space: !@completion_path,
357
+ )
358
+ @completion_range = nil
359
+ @completion_path = nil
360
+ @completion_popup_prefix = nil
361
+ end
362
+ commands
363
+ end
364
+
365
+ def action_env(event:)
366
+ Fatty::ActionEnvironment.new(
367
+ session: self,
368
+ counter: counter,
369
+ event: event,
370
+ buffer: @field.buffer,
371
+ field: @field,
372
+ pager: pager,
322
373
  )
374
+ end
375
+
376
+ #########################################################################################
377
+ # Utilities
378
+ #########################################################################################
379
+
380
+ def pager
381
+ output_session.pager
382
+ end
383
+
384
+ def accept_env
385
+ CallbackEnvironment.new(terminal: terminal, output_id: output_session.id)
386
+ end
323
387
 
324
- [[:terminal, :push_modal, popup]]
388
+ def run_default_command(line)
389
+ commands = [Command.session(output_session.id, :append, text: "$ #{line}\n", follow: true)]
390
+ out, status = Open3.capture2e(line)
391
+ if status&.exitstatus && status.exitstatus != 0
392
+ out << "\n[exit #{status.exitstatus}]\n"
393
+ end
394
+ commands << Command.session(output_session.id, :append, text: out, follow: true)
395
+ rescue Errno::ENOENT => ex
396
+ commands << Command.session(output_session.id, :append, text: ex.to_s, follow: true)
397
+ end
398
+
399
+ def unrecognized_key_alert(ev)
400
+ if ev.uncoded?
401
+ alert_cmd(
402
+ :error,
403
+ "Undefined key: #{ev.key} " \
404
+ "(edit config in 'keydefs.yml' to name)",
405
+ ev: ev,
406
+ )
407
+ else
408
+ alert_cmd(
409
+ :warn,
410
+ "Unbound key: #{ev} " \
411
+ "(edit config in 'keybindings.yml' to bind)",
412
+ ev: ev,
413
+ )
414
+ end
415
+ end
416
+
417
+ def finish_interrupted_command(env)
418
+ env.commands.each do |command|
419
+ terminal.apply_command(command)
420
+ end
421
+ terminal.apply_command(Command.session(output_session.id, :finish_command))
422
+ terminal.apply_command(Command.session(output_session.id, :quit_paging))
423
+ terminal.render_frame
424
+ []
325
425
  end
326
426
 
327
427
  #########################################################################################
328
428
  # Completion
329
429
  #########################################################################################
330
430
 
431
+ def completion_action?(action)
432
+ [:complete, :complete_previous].include?(action.to_sym)
433
+ end
434
+
331
435
  def completion_candidates
332
436
  path_candidates = @field.path_completion_candidates
333
437
  return path_candidates if path_candidates.any?
334
438
 
335
- return [] unless @completion_proc
439
+ return [] unless @completion_pr
336
440
 
337
441
  prefix = completion_prefix
338
442
  Array(@completion_proc.call(@field.buffer))
@@ -381,7 +485,25 @@ module Fatty
381
485
  commands
382
486
  end
383
487
 
384
- def apply_completion(candidate, range: nil)
488
+ def completion_path_item?(item)
489
+ item.to_s.end_with?("/")
490
+ end
491
+
492
+ def parent_completion_path(path)
493
+ s = path.to_s
494
+ return if s.empty?
495
+
496
+ s = s[0...-1] if s.end_with?("/") && s.length > 1
497
+ slash = s.rindex("/")
498
+ return unless slash
499
+
500
+ parent = s[0..slash]
501
+ return if parent == path
502
+
503
+ parent
504
+ end
505
+
506
+ def apply_completion(candidate, range: nil, append_space: true)
385
507
  candidate = candidate.to_s
386
508
  return [] if candidate.empty?
387
509
 
@@ -389,153 +511,63 @@ module Fatty
389
511
  target = range || buffer.completion_replace_range
390
512
  old_text = buffer.text.dup
391
513
  old_end = target.end
392
- append_space =
514
+
515
+ should_append_space =
516
+ append_space &&
393
517
  !candidate.match?(/\s\z/) &&
394
518
  (old_end >= old_text.length || old_text[old_end]&.match?(/\s/))
395
- inserted = append_space ? "#{candidate} " : candidate
519
+
520
+ inserted = should_append_space ? "#{candidate} " : candidate
396
521
  buffer.replace_range(target, inserted)
522
+ buffer.virtual_suffix = ""
397
523
  []
398
524
  end
399
525
 
400
- private
526
+ def apply_completion_popup_tab(payload)
527
+ return [] unless @completion_path
401
528
 
402
- def accept_env
403
- Fatty::AcceptEnv.new(session: self)
404
- end
529
+ popup = payload[:session]
530
+ item = payload[:item].to_s
531
+ return [] unless popup
532
+ return [] unless completion_path_item?(item)
405
533
 
406
- def normalize_accept_commands(commands)
407
- if commands.is_a?(Array) && commands.first.is_a?(Array) && commands.first.first.is_a?(Symbol)
408
- commands
409
- else
410
- [[:send, :alert, :clear, {}]]
411
- end
534
+ replace_completion_popup_path(popup, item)
412
535
  end
413
536
 
414
- def update_cmd(name, payload)
415
- cmds = []
416
- case name
417
- when :popup_result
418
- case payload[:kind]&.to_sym
419
- when :history_search
420
- item = payload.fetch(:item, "").to_s
421
- env = action_env(event: nil)
422
- with_virtual_suffix_sync do
423
- Fatty::Actions.call(:replace, env, item)
424
- end
425
- when :theme_chooser
426
- theme = payload.fetch(:item).to_sym
427
- @theme_popup_restore = nil
428
- cmds << [:terminal, :set_theme, theme]
429
- cmds << [:send, :alert, :show, { level: :info, message: "Theme: #{theme}" }]
430
- when :completion
431
- apply_completion(payload.fetch(:item, "").to_s, range: @completion_range)
432
- @completion_range = nil
433
- end
434
- when :popup_changed
435
- if payload[:kind]&.to_sym == :theme_chooser
436
- theme = payload.fetch(:item).to_sym
437
- cmds << [:terminal, :set_theme, theme]
438
- end
439
- when :popup_cancelled
440
- if payload[:kind]&.to_sym == :theme_chooser && @theme_popup_restore
441
- cmds << [:terminal, :set_theme, @theme_popup_restore]
442
- @theme_popup_restore = nil
443
- end
444
- when :pager_search_set
445
- pattern = payload.fetch(:pattern, "").to_s
446
- regex = !!payload[:regex]
447
- direction = (payload[:direction] || :forward).to_sym
448
- result = pager.search_set!(pattern: pattern, regex: regex, direction: direction)
449
- cmds.concat(handle_search_result(result))
450
- when :pager_search_step
451
- direction = (payload[:direction] || :forward).to_sym
452
- result = pager.search_step!(direction: direction)
453
- cmds.concat(handle_search_result(result))
454
- when :pager_isearch_update
455
- pattern = payload.fetch(:pattern, "").to_s
456
- direction = (payload[:direction] || :forward).to_sym
457
- result = pager.isearch_update!(pattern: pattern, direction: direction)
458
- cmds.concat(handle_search_result(result))
459
- cmds << [:send, :isearch, :isearch_set_failed, { failed: result[:status] != :moved }]
460
- when :pager_isearch_step
461
- direction = (payload[:direction] || :forward).to_sym
462
- result = pager.isearch_step!(direction: direction)
463
- cmds.concat(handle_search_result(result))
464
- cmds << [:send, :isearch, :isearch_set_failed, { failed: result[:status] != :moved }]
465
- when :pager_isearch_cancel
466
- pager.isearch_cancel!
467
- cmds << [:send, :isearch, :isearch_set_failed, { failed: false }]
468
- when :pager_isearch_commit
469
- pattern = payload.fetch(:pattern, "").to_s
470
- direction = (payload[:direction] || :forward).to_sym
471
- result = pager.isearch_commit!(pattern: pattern, direction: direction)
472
- cmds.concat(handle_search_result(result))
473
- cmds << [:send, :isearch, :isearch_set_failed, { failed: false }]
474
- when :paste
475
- text = payload.fetch(:text, "").to_s
476
- env = action_env(event: nil)
477
- field.act_on(:paste, text, env: env)
478
- end
479
- cmds
480
- end
537
+ def apply_completion_popup_backtab(payload)
538
+ return [] unless @completion_path
481
539
 
482
- def handle_action(action, args, event:)
483
- which =
484
- if event&.respond_to?(:key)
485
- event.key.inspect
486
- elsif event&.respond_to?(:mouse)
487
- event.mouse.inspect
488
- end
489
- Fatty.debug("ShellSession#handle_action: #{which}", tag: :keymap)
490
- env = action_env(event: event)
491
- apply_action(action, args, event, env: env)
492
- end
540
+ popup = payload[:session]
541
+ return [] unless popup
493
542
 
494
- # Centralized dispatch: actions declare their target (:buffer/:field/:pager)
495
- # and Fatty::Actions routes through ActionEnvironment.
496
- def apply_action(action, args, ev, env:)
497
- @field.reset_completion_cycle!
498
- defn = Fatty::Actions.lookup(action)
499
- result = Fatty::Actions.call(action, env, *args)
500
- if defn && [:field, :buffer].include?(defn[:on])
501
- @field.sync_virtual_suffix!
502
- end
503
- result.is_a?(Array) ? result : []
504
- rescue Fatty::ActionError => e
505
- [alert_cmd(:error, e.message, ev: ev)]
506
- end
543
+ path = @completion_popup_prefix || payload[:query].to_s
544
+ path = payload[:item].to_s if path.empty?
507
545
 
508
- def run_default_command(line)
509
- append_output("$ #{line}\n", follow: true)
510
- out, status = Open3.capture2e(line)
511
- # optional: include exit status line
512
- out << "\n[exit #{status.exitstatus}]\n" if status&.exitstatus && status.exitstatus != 0
513
- out
514
- rescue Errno::ENOENT
515
- "Command not found: #{line}\n"
516
- end
517
-
518
- def consume_search_regex_flag
519
- regex = false
520
- if counter.digits.to_s == "4"
521
- regex = true
522
- counter.clear!
523
- end
524
- regex
546
+ parent = parent_completion_path(path)
547
+ return [] unless parent
548
+
549
+ replace_completion_popup_path(popup, parent)
525
550
  end
526
551
 
527
- def handle_search_result(result)
528
- return unless result.is_a?(Hash)
552
+ def replace_completion_popup_path(popup, prefix)
553
+ @completion_popup_prefix = prefix.to_s
554
+ candidates = @field.path_completion_candidates_for(@completion_popup_prefix)
555
+
556
+ popup.replace_source(
557
+ source: candidates,
558
+ title: completion_popup_title(@completion_popup_prefix),
559
+ query: "",
560
+ current: :top,
561
+ )
562
+
563
+ []
564
+ end
529
565
 
530
- case result[:status]
531
- when :boundary
532
- msg = result[:message].to_s
533
- msg = "Search reached boundary" if msg.empty?
534
- [[:send, :alert, :show, { level: :info, message: msg }]]
535
- when :not_found
536
- [[:send, :alert, :show, { level: :info, message: "No matches" }]]
566
+ def completion_popup_title(prefix = nil)
567
+ if prefix && !prefix.empty?
568
+ "Completions: #{prefix}"
537
569
  else
538
- []
570
+ "Completions"
539
571
  end
540
572
  end
541
573
 
@@ -545,22 +577,5 @@ module Fatty
545
577
  @field.sync_virtual_suffix!
546
578
  result
547
579
  end
548
-
549
- def alert_cmd(level, message, ev: nil)
550
- details =
551
- if ev
552
- {
553
- key: ev.key,
554
- ctrl: ev.respond_to?(:ctrl?) ? ev.ctrl? : ev.ctrl,
555
- meta: ev.respond_to?(:meta?) ? ev.meta? : ev.meta,
556
- shift: ev.respond_to?(:shift?) ? ev.shift? : ev.shift,
557
- text: ev.text
558
- }
559
- else
560
- {}
561
- end
562
-
563
- [:send, :alert, :show, { level: level, message: message, details: details }]
564
- end
565
580
  end
566
581
  end