try-cli 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +259 -0
- data/VERSION +1 -0
- data/bin/try +4 -0
- data/lib/fuzzy.rb +133 -0
- data/lib/tui.rb +892 -0
- data/try.rb +1281 -0
- metadata +53 -0
data/try.rb
ADDED
|
@@ -0,0 +1,1281 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
require 'io/console'
|
|
4
|
+
require 'time'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
require_relative 'lib/tui'
|
|
7
|
+
require_relative 'lib/fuzzy'
|
|
8
|
+
|
|
9
|
+
class TrySelector
|
|
10
|
+
include Tui::Helpers
|
|
11
|
+
TRY_PATH = ENV['TRY_PATH'] || File.expand_path("~/src/tries")
|
|
12
|
+
|
|
13
|
+
def initialize(search_term = "", base_path: TRY_PATH, initial_input: nil, test_render_once: false, test_no_cls: false, test_keys: nil, test_confirm: nil)
|
|
14
|
+
@search_term = search_term.gsub(/\s+/, '-')
|
|
15
|
+
@cursor_pos = 0 # Navigation cursor (list position)
|
|
16
|
+
@input_cursor_pos = 0 # Text cursor (position within search buffer)
|
|
17
|
+
@scroll_offset = 0
|
|
18
|
+
@input_buffer = initial_input ? initial_input.gsub(/\s+/, '-') : @search_term
|
|
19
|
+
@input_cursor_pos = @input_buffer.length # Start at end of buffer
|
|
20
|
+
@selected = nil
|
|
21
|
+
@all_trials = nil # Memoized trials
|
|
22
|
+
@base_path = base_path
|
|
23
|
+
@delete_status = nil # Status message for deletions
|
|
24
|
+
@delete_mode = false # Whether we're in deletion mode
|
|
25
|
+
@marked_for_deletion = [] # Paths marked for deletion
|
|
26
|
+
@test_render_once = test_render_once
|
|
27
|
+
@test_no_cls = test_no_cls
|
|
28
|
+
@test_keys = test_keys
|
|
29
|
+
@test_had_keys = test_keys && !test_keys.empty?
|
|
30
|
+
@test_confirm = test_confirm
|
|
31
|
+
@old_winch_handler = nil # Store original SIGWINCH handler
|
|
32
|
+
@needs_redraw = false
|
|
33
|
+
|
|
34
|
+
FileUtils.mkdir_p(@base_path) unless Dir.exist?(@base_path)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def run
|
|
38
|
+
# Always use STDERR for rendering (it stays connected to TTY)
|
|
39
|
+
# This allows stdout to be captured for the shell commands
|
|
40
|
+
setup_terminal
|
|
41
|
+
|
|
42
|
+
# In test mode with no keys, render once and exit without TTY requirements
|
|
43
|
+
# If test_keys are provided, run the full loop
|
|
44
|
+
if @test_render_once && (@test_keys.nil? || @test_keys.empty?)
|
|
45
|
+
tries = get_tries
|
|
46
|
+
render(tries)
|
|
47
|
+
return nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Check if we have a TTY; allow tests with injected keys
|
|
51
|
+
if !STDIN.tty? || !STDERR.tty?
|
|
52
|
+
if @test_keys.nil? || @test_keys.empty?
|
|
53
|
+
STDERR.puts "Error: try requires an interactive terminal"
|
|
54
|
+
return nil
|
|
55
|
+
end
|
|
56
|
+
main_loop
|
|
57
|
+
else
|
|
58
|
+
STDERR.raw do
|
|
59
|
+
main_loop
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
ensure
|
|
63
|
+
restore_terminal
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def setup_terminal
|
|
69
|
+
unless @test_no_cls
|
|
70
|
+
# Switch to alternate screen buffer (like vim, less, etc.)
|
|
71
|
+
STDERR.print(Tui::ANSI::ALT_SCREEN_ON)
|
|
72
|
+
STDERR.print(Tui::ANSI::CLEAR_SCREEN)
|
|
73
|
+
STDERR.print(Tui::ANSI::HOME)
|
|
74
|
+
STDERR.print(Tui::ANSI::CURSOR_BLINK)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
@old_winch_handler = Signal.trap('WINCH') { @needs_redraw = true }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def restore_terminal
|
|
81
|
+
unless @test_no_cls
|
|
82
|
+
STDERR.print(Tui::ANSI::RESET)
|
|
83
|
+
STDERR.print(Tui::ANSI::CURSOR_DEFAULT)
|
|
84
|
+
# Return to main screen buffer
|
|
85
|
+
STDERR.print(Tui::ANSI::ALT_SCREEN_OFF)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
Signal.trap('WINCH', @old_winch_handler) if @old_winch_handler
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def load_all_tries
|
|
92
|
+
# Load trials only once - single pass through directory
|
|
93
|
+
@all_tries ||= begin
|
|
94
|
+
tries = []
|
|
95
|
+
now = Time.now
|
|
96
|
+
Dir.foreach(@base_path) do |entry|
|
|
97
|
+
# exclude . and .. but also .git, and any other hidden dirs.
|
|
98
|
+
next if entry.start_with?('.')
|
|
99
|
+
|
|
100
|
+
path = File.join(@base_path, entry)
|
|
101
|
+
stat = File.stat(path)
|
|
102
|
+
|
|
103
|
+
# Only include directories
|
|
104
|
+
next unless stat.directory?
|
|
105
|
+
|
|
106
|
+
# Compute base_score from recency + date prefix bonus
|
|
107
|
+
mtime = stat.mtime
|
|
108
|
+
hours_since_access = (now - mtime) / 3600.0
|
|
109
|
+
base_score = 3.0 / Math.sqrt(hours_since_access + 1)
|
|
110
|
+
|
|
111
|
+
# Bonus for date-prefixed directories
|
|
112
|
+
base_score += 2.0 if entry.match?(/^\d{4}-\d{2}-\d{2}-/)
|
|
113
|
+
|
|
114
|
+
tries << {
|
|
115
|
+
text: entry,
|
|
116
|
+
basename: entry,
|
|
117
|
+
path: path,
|
|
118
|
+
is_new: false,
|
|
119
|
+
ctime: stat.ctime,
|
|
120
|
+
mtime: mtime,
|
|
121
|
+
base_score: base_score
|
|
122
|
+
}
|
|
123
|
+
end
|
|
124
|
+
tries
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Result wrapper to avoid Hash#merge allocation per entry
|
|
129
|
+
TryEntry = Data.define(:data, :score, :highlight_positions) do
|
|
130
|
+
def [](key)
|
|
131
|
+
case key
|
|
132
|
+
when :score then score
|
|
133
|
+
when :highlight_positions then highlight_positions
|
|
134
|
+
else data[key]
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def method_missing(name, *)
|
|
139
|
+
data[name]
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def respond_to_missing?(name, include_private = false)
|
|
143
|
+
data.key?(name) || super
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def get_tries
|
|
148
|
+
load_all_tries
|
|
149
|
+
@fuzzy ||= Fuzzy.new(@all_tries)
|
|
150
|
+
|
|
151
|
+
results = []
|
|
152
|
+
@fuzzy.match(@input_buffer).each do |entry, positions, score|
|
|
153
|
+
results << TryEntry.new(entry, score, positions)
|
|
154
|
+
end
|
|
155
|
+
results
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def main_loop
|
|
159
|
+
loop do
|
|
160
|
+
tries = get_tries
|
|
161
|
+
show_create_new = !@input_buffer.empty?
|
|
162
|
+
total_items = tries.length + (show_create_new ? 1 : 0)
|
|
163
|
+
|
|
164
|
+
# Ensure cursor is within bounds
|
|
165
|
+
@cursor_pos = [[@cursor_pos, 0].max, [total_items - 1, 0].max].min
|
|
166
|
+
|
|
167
|
+
render(tries)
|
|
168
|
+
|
|
169
|
+
key = read_key
|
|
170
|
+
# nil means terminal resize - just re-render with new dimensions
|
|
171
|
+
next unless key
|
|
172
|
+
|
|
173
|
+
case key
|
|
174
|
+
when "\r" # Enter (carriage return)
|
|
175
|
+
if @delete_mode && !@marked_for_deletion.empty?
|
|
176
|
+
# Confirm deletion of marked items
|
|
177
|
+
confirm_batch_delete(tries)
|
|
178
|
+
break if @selected
|
|
179
|
+
elsif @cursor_pos < tries.length
|
|
180
|
+
handle_selection(tries[@cursor_pos])
|
|
181
|
+
break if @selected
|
|
182
|
+
elsif show_create_new
|
|
183
|
+
# Selected "Create new"
|
|
184
|
+
handle_create_new
|
|
185
|
+
break if @selected
|
|
186
|
+
end
|
|
187
|
+
when "\e[A", "\x10" # Up arrow or Ctrl-P
|
|
188
|
+
@cursor_pos = [@cursor_pos - 1, 0].max
|
|
189
|
+
when "\e[B", "\x0E" # Down arrow or Ctrl-N
|
|
190
|
+
@cursor_pos = [@cursor_pos + 1, total_items - 1].min
|
|
191
|
+
when "\e[C" # Right arrow - ignore
|
|
192
|
+
# Do nothing
|
|
193
|
+
when "\e[D" # Left arrow - ignore
|
|
194
|
+
# Do nothing
|
|
195
|
+
when "\x7F", "\b" # Backspace
|
|
196
|
+
if @input_cursor_pos > 0
|
|
197
|
+
@input_buffer = @input_buffer[0...(@input_cursor_pos-1)] + @input_buffer[@input_cursor_pos..-1]
|
|
198
|
+
@input_cursor_pos -= 1
|
|
199
|
+
end
|
|
200
|
+
@cursor_pos = 0 # Reset list selection when typing
|
|
201
|
+
when "\x01" # Ctrl-A - beginning of line
|
|
202
|
+
@input_cursor_pos = 0
|
|
203
|
+
when "\x05" # Ctrl-E - end of line
|
|
204
|
+
@input_cursor_pos = @input_buffer.length
|
|
205
|
+
when "\x02" # Ctrl-B - backward char
|
|
206
|
+
@input_cursor_pos = [@input_cursor_pos - 1, 0].max
|
|
207
|
+
when "\x06" # Ctrl-F - forward char
|
|
208
|
+
@input_cursor_pos = [@input_cursor_pos + 1, @input_buffer.length].min
|
|
209
|
+
when "\x08" # Ctrl-H - backward delete char (same as backspace)
|
|
210
|
+
if @input_cursor_pos > 0
|
|
211
|
+
@input_buffer = @input_buffer[0...(@input_cursor_pos-1)] + @input_buffer[@input_cursor_pos..-1]
|
|
212
|
+
@input_cursor_pos -= 1
|
|
213
|
+
end
|
|
214
|
+
@cursor_pos = 0
|
|
215
|
+
when "\x0B" # Ctrl-K - kill to end of line
|
|
216
|
+
@input_buffer = @input_buffer[0...@input_cursor_pos]
|
|
217
|
+
when "\x17" # Ctrl-W - delete word backward (alphanumeric)
|
|
218
|
+
if @input_cursor_pos > 0
|
|
219
|
+
# Start from cursor position and move backward
|
|
220
|
+
pos = @input_cursor_pos - 1
|
|
221
|
+
|
|
222
|
+
# Skip trailing non-alphanumeric
|
|
223
|
+
while pos >= 0 && @input_buffer[pos] !~ /[a-zA-Z0-9]/
|
|
224
|
+
pos -= 1
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Skip backward over alphanumeric chars
|
|
228
|
+
while pos >= 0 && @input_buffer[pos] =~ /[a-zA-Z0-9]/
|
|
229
|
+
pos -= 1
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Delete from pos+1 to cursor
|
|
233
|
+
new_pos = pos + 1
|
|
234
|
+
@input_buffer = @input_buffer[0...new_pos] + @input_buffer[@input_cursor_pos..-1]
|
|
235
|
+
@input_cursor_pos = new_pos
|
|
236
|
+
end
|
|
237
|
+
when "\x04" # Ctrl-D - toggle mark for deletion
|
|
238
|
+
if @cursor_pos < tries.length
|
|
239
|
+
path = tries[@cursor_pos][:path]
|
|
240
|
+
if @marked_for_deletion.include?(path)
|
|
241
|
+
@marked_for_deletion.delete(path)
|
|
242
|
+
else
|
|
243
|
+
@marked_for_deletion << path
|
|
244
|
+
@delete_mode = true
|
|
245
|
+
end
|
|
246
|
+
# Exit delete mode if no more marks
|
|
247
|
+
@delete_mode = false if @marked_for_deletion.empty?
|
|
248
|
+
end
|
|
249
|
+
when "\x14" # Ctrl-T - create new try (immediate)
|
|
250
|
+
handle_create_new
|
|
251
|
+
break if @selected
|
|
252
|
+
when "\x12" # Ctrl-R - rename selected entry
|
|
253
|
+
if @cursor_pos < tries.length
|
|
254
|
+
run_rename_dialog(tries[@cursor_pos])
|
|
255
|
+
break if @selected
|
|
256
|
+
end
|
|
257
|
+
when "\x03", "\e" # Ctrl-C or ESC
|
|
258
|
+
if @delete_mode
|
|
259
|
+
# Exit delete mode, clear marks
|
|
260
|
+
@marked_for_deletion.clear
|
|
261
|
+
@delete_mode = false
|
|
262
|
+
else
|
|
263
|
+
@selected = nil
|
|
264
|
+
break
|
|
265
|
+
end
|
|
266
|
+
when String
|
|
267
|
+
# Only accept printable characters, not escape sequences
|
|
268
|
+
if key.length == 1 && key =~ /[a-zA-Z0-9\-\_\. ]/
|
|
269
|
+
@input_buffer = @input_buffer[0...@input_cursor_pos] + key + @input_buffer[@input_cursor_pos..-1]
|
|
270
|
+
@input_cursor_pos += 1
|
|
271
|
+
@cursor_pos = 0 # Reset list selection when typing
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
@selected
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def read_key
|
|
280
|
+
if @test_keys && !@test_keys.empty?
|
|
281
|
+
return @test_keys.shift
|
|
282
|
+
end
|
|
283
|
+
# In test mode with no more keys, auto-exit by returning ESC
|
|
284
|
+
return "\e" if @test_had_keys && @test_keys && @test_keys.empty?
|
|
285
|
+
|
|
286
|
+
# Use IO.select with timeout to allow checking for resize
|
|
287
|
+
loop do
|
|
288
|
+
if @needs_redraw
|
|
289
|
+
@needs_redraw = false
|
|
290
|
+
clear_screen unless @test_no_cls
|
|
291
|
+
return nil
|
|
292
|
+
end
|
|
293
|
+
ready = IO.select([STDIN], nil, nil, 0.1)
|
|
294
|
+
return read_keypress if ready
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def read_keypress
|
|
299
|
+
input = STDIN.getc
|
|
300
|
+
return nil if input.nil?
|
|
301
|
+
|
|
302
|
+
if input == "\e"
|
|
303
|
+
input << STDIN.read_nonblock(3) rescue ""
|
|
304
|
+
input << STDIN.read_nonblock(2) rescue ""
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
input
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def clear_screen
|
|
311
|
+
STDERR.print("\e[2J\e[H")
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def hide_cursor
|
|
315
|
+
STDERR.print(Tui::ANSI::HIDE)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def show_cursor
|
|
319
|
+
STDERR.print(Tui::ANSI::SHOW)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def render(tries)
|
|
323
|
+
screen = Tui::Screen.new(io: STDERR)
|
|
324
|
+
width = screen.width
|
|
325
|
+
height = screen.height
|
|
326
|
+
|
|
327
|
+
screen.header.add_line { |line| line.write << emoji("🏠") << Tui::Text.accent(" Try Directory Selection") }
|
|
328
|
+
screen.header.add_line { |line| line.write.write_dim(fill("─")) }
|
|
329
|
+
screen.header.add_line do |line|
|
|
330
|
+
prefix = "Search: "
|
|
331
|
+
line.write.write_dim(prefix)
|
|
332
|
+
line.write << screen.input("", value: @input_buffer, cursor: @input_cursor_pos).to_s
|
|
333
|
+
line.mark_has_input(Tui::Metrics.visible_width(prefix))
|
|
334
|
+
end
|
|
335
|
+
screen.header.add_line { |line| line.write.write_dim(fill("─")) }
|
|
336
|
+
|
|
337
|
+
# Add footer first to get accurate line count
|
|
338
|
+
screen.footer.add_line { |line| line.write.write_dim(fill("─")) }
|
|
339
|
+
if @delete_status
|
|
340
|
+
screen.footer.add_line { |line| line.write.write_bold(@delete_status) }
|
|
341
|
+
@delete_status = nil
|
|
342
|
+
elsif @delete_mode
|
|
343
|
+
screen.footer.add_line(background: Tui::Palette::DANGER_BG) do |line|
|
|
344
|
+
line.write.write_bold(" DELETE MODE ")
|
|
345
|
+
line.write << " #{@marked_for_deletion.length} marked | Ctrl-D: Toggle Enter: Confirm Esc: Cancel"
|
|
346
|
+
end
|
|
347
|
+
else
|
|
348
|
+
screen.footer.add_line do |line|
|
|
349
|
+
line.center.write_dim("↑/↓: Navigate Enter: Select ^R: Rename ^D: Delete Esc: Cancel")
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# Calculate max visible from actual header/footer counts
|
|
354
|
+
header_lines = screen.header.lines.length
|
|
355
|
+
footer_lines = screen.footer.lines.length
|
|
356
|
+
max_visible = [height - header_lines - footer_lines, 3].max
|
|
357
|
+
show_create_new = !@input_buffer.empty?
|
|
358
|
+
total_items = tries.length + (show_create_new ? 1 : 0)
|
|
359
|
+
|
|
360
|
+
if @cursor_pos < @scroll_offset
|
|
361
|
+
@scroll_offset = @cursor_pos
|
|
362
|
+
elsif @cursor_pos >= @scroll_offset + max_visible
|
|
363
|
+
@scroll_offset = @cursor_pos - max_visible + 1
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
visible_end = [@scroll_offset + max_visible, total_items].min
|
|
367
|
+
|
|
368
|
+
(@scroll_offset...visible_end).each do |idx|
|
|
369
|
+
if idx == tries.length && tries.any? && idx >= @scroll_offset
|
|
370
|
+
screen.body.add_line
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
if idx < tries.length
|
|
374
|
+
render_entry_line(screen, tries[idx], idx == @cursor_pos, width)
|
|
375
|
+
else
|
|
376
|
+
render_create_line(screen, idx == @cursor_pos, width)
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
screen.flush
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def render_entry_line(screen, entry, is_selected, width)
|
|
384
|
+
is_marked = @marked_for_deletion.include?(entry[:path])
|
|
385
|
+
# Marked items always show red; selection shows via arrow only
|
|
386
|
+
background = if is_marked
|
|
387
|
+
Tui::Palette::DANGER_BG
|
|
388
|
+
elsif is_selected
|
|
389
|
+
Tui::Palette::SELECTED_BG
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
line = screen.body.add_line(background: background)
|
|
393
|
+
line.write << (is_selected ? Tui::Text.highlight("→ ") : " ")
|
|
394
|
+
line.write << (is_marked ? emoji("🗑️") : emoji("📁")) << " "
|
|
395
|
+
|
|
396
|
+
plain_name, rendered_name = formatted_entry_name(entry)
|
|
397
|
+
prefix_width = 5
|
|
398
|
+
meta_text = "#{format_relative_time(entry[:mtime])}, #{format('%.1f', entry[:score])}"
|
|
399
|
+
|
|
400
|
+
# Only truncate name if it exceeds total line width (not to make room for metadata)
|
|
401
|
+
max_name_width = width - prefix_width - 1
|
|
402
|
+
if plain_name.length > max_name_width && max_name_width > 2
|
|
403
|
+
display_rendered = truncate_with_ansi(rendered_name, max_name_width - 1) + "…"
|
|
404
|
+
else
|
|
405
|
+
display_rendered = rendered_name
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
line.write << display_rendered
|
|
409
|
+
|
|
410
|
+
# Right content is lower layer - will be overwritten by left if they overlap
|
|
411
|
+
line.right.write_dim(meta_text)
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def render_create_line(screen, is_selected, width)
|
|
415
|
+
background = is_selected ? Tui::Palette::SELECTED_BG : nil
|
|
416
|
+
line = screen.body.add_line(background: background)
|
|
417
|
+
line.write << (is_selected ? Tui::Text.highlight("→ ") : " ")
|
|
418
|
+
date_prefix = Time.now.strftime("%Y-%m-%d")
|
|
419
|
+
label = if @input_buffer.empty?
|
|
420
|
+
"📂 Create new: #{date_prefix}-"
|
|
421
|
+
else
|
|
422
|
+
"📂 Create new: #{date_prefix}-#{@input_buffer}"
|
|
423
|
+
end
|
|
424
|
+
line.write << label
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def formatted_entry_name(entry)
|
|
428
|
+
basename = entry[:basename]
|
|
429
|
+
positions = entry[:highlight_positions] || []
|
|
430
|
+
|
|
431
|
+
if basename =~ /^(\d{4}-\d{2}-\d{2})-(.+)$/
|
|
432
|
+
date_part = $1
|
|
433
|
+
name_part = $2
|
|
434
|
+
date_len = date_part.length + 1 # +1 for the hyphen
|
|
435
|
+
|
|
436
|
+
rendered = Tui::Text.dim(date_part)
|
|
437
|
+
# Highlight hyphen if it's in positions
|
|
438
|
+
rendered += positions.include?(10) ? Tui::Text.highlight('-') : Tui::Text.dim('-')
|
|
439
|
+
rendered += highlight_with_positions(name_part, positions, date_len)
|
|
440
|
+
["#{date_part}-#{name_part}", rendered]
|
|
441
|
+
else
|
|
442
|
+
[basename, highlight_with_positions(basename, positions, 0)]
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def highlight_with_positions(text, positions, offset)
|
|
447
|
+
result = ""
|
|
448
|
+
text.chars.each_with_index do |char, i|
|
|
449
|
+
if positions.include?(i + offset)
|
|
450
|
+
result += Tui::Text.highlight(char)
|
|
451
|
+
else
|
|
452
|
+
result += char
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
result
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def format_relative_time(time)
|
|
459
|
+
return "?" unless time
|
|
460
|
+
|
|
461
|
+
seconds = Time.now - time
|
|
462
|
+
minutes = seconds / 60
|
|
463
|
+
hours = minutes / 60
|
|
464
|
+
days = hours / 24
|
|
465
|
+
|
|
466
|
+
if seconds < 60
|
|
467
|
+
"just now"
|
|
468
|
+
elsif minutes < 60
|
|
469
|
+
"#{minutes.to_i}m ago"
|
|
470
|
+
elsif hours < 24
|
|
471
|
+
"#{hours.to_i}h ago"
|
|
472
|
+
elsif days < 7
|
|
473
|
+
"#{days.to_i}d ago"
|
|
474
|
+
else
|
|
475
|
+
"#{(days/7).to_i}w ago"
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def truncate_with_ansi(text, max_length)
|
|
480
|
+
# Simple truncation that preserves ANSI codes
|
|
481
|
+
visible_count = 0
|
|
482
|
+
result = ""
|
|
483
|
+
in_ansi = false
|
|
484
|
+
|
|
485
|
+
text.chars.each do |char|
|
|
486
|
+
if char == "\e"
|
|
487
|
+
in_ansi = true
|
|
488
|
+
result += char
|
|
489
|
+
elsif in_ansi
|
|
490
|
+
result += char
|
|
491
|
+
in_ansi = false if char == "m"
|
|
492
|
+
else
|
|
493
|
+
break if visible_count >= max_length
|
|
494
|
+
result += char
|
|
495
|
+
visible_count += 1
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
result
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# Rename dialog - dedicated screen similar to delete
|
|
503
|
+
def run_rename_dialog(entry)
|
|
504
|
+
@delete_mode = false
|
|
505
|
+
@marked_for_deletion.clear
|
|
506
|
+
|
|
507
|
+
current_name = entry[:basename]
|
|
508
|
+
rename_buffer = current_name.dup
|
|
509
|
+
rename_cursor = rename_buffer.length
|
|
510
|
+
rename_error = nil
|
|
511
|
+
|
|
512
|
+
loop do
|
|
513
|
+
render_rename_dialog(current_name, rename_buffer, rename_cursor, rename_error)
|
|
514
|
+
|
|
515
|
+
ch = read_key
|
|
516
|
+
case ch
|
|
517
|
+
when "\r" # Enter - confirm
|
|
518
|
+
result = finalize_rename(entry, rename_buffer)
|
|
519
|
+
if result == true
|
|
520
|
+
break
|
|
521
|
+
else
|
|
522
|
+
rename_error = result # Error message string
|
|
523
|
+
end
|
|
524
|
+
when "\e", "\x03" # ESC or Ctrl-C - cancel
|
|
525
|
+
break
|
|
526
|
+
when "\x7F", "\b" # Backspace
|
|
527
|
+
if rename_cursor > 0
|
|
528
|
+
rename_buffer = rename_buffer[0...(rename_cursor - 1)] + rename_buffer[rename_cursor..].to_s
|
|
529
|
+
rename_cursor -= 1
|
|
530
|
+
end
|
|
531
|
+
rename_error = nil
|
|
532
|
+
when "\x01" # Ctrl-A - start of line
|
|
533
|
+
rename_cursor = 0
|
|
534
|
+
when "\x05" # Ctrl-E - end of line
|
|
535
|
+
rename_cursor = rename_buffer.length
|
|
536
|
+
when "\x02" # Ctrl-B - back one char
|
|
537
|
+
rename_cursor = [rename_cursor - 1, 0].max
|
|
538
|
+
when "\x06" # Ctrl-F - forward one char
|
|
539
|
+
rename_cursor = [rename_cursor + 1, rename_buffer.length].min
|
|
540
|
+
when "\x0B" # Ctrl-K - kill to end
|
|
541
|
+
rename_buffer = rename_buffer[0...rename_cursor]
|
|
542
|
+
rename_error = nil
|
|
543
|
+
when "\x17" # Ctrl-W - delete word backward
|
|
544
|
+
if rename_cursor > 0
|
|
545
|
+
pos = rename_cursor - 1
|
|
546
|
+
pos -= 1 while pos > 0 && rename_buffer[pos] !~ /[a-zA-Z0-9]/
|
|
547
|
+
pos -= 1 while pos > 0 && rename_buffer[pos - 1] =~ /[a-zA-Z0-9]/
|
|
548
|
+
rename_buffer = rename_buffer[0...pos] + rename_buffer[rename_cursor..].to_s
|
|
549
|
+
rename_cursor = pos
|
|
550
|
+
end
|
|
551
|
+
rename_error = nil
|
|
552
|
+
when String
|
|
553
|
+
if ch.length == 1 && ch =~ /[a-zA-Z0-9\-_\.\s\/]/
|
|
554
|
+
rename_buffer = rename_buffer[0...rename_cursor] + ch + rename_buffer[rename_cursor..].to_s
|
|
555
|
+
rename_cursor += 1
|
|
556
|
+
rename_error = nil
|
|
557
|
+
end
|
|
558
|
+
end
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
@needs_redraw = true
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
def render_rename_dialog(current_name, rename_buffer, rename_cursor, rename_error)
|
|
565
|
+
screen = Tui::Screen.new(io: STDERR)
|
|
566
|
+
|
|
567
|
+
screen.header.add_line do |line|
|
|
568
|
+
line.center << emoji("✏️") << Tui::Text.accent(" Rename directory")
|
|
569
|
+
end
|
|
570
|
+
screen.header.add_line { |line| line.write.write_dim(fill("─")) }
|
|
571
|
+
|
|
572
|
+
screen.body.add_line do |line|
|
|
573
|
+
line.write << emoji("📁") << " #{current_name}"
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
# Add empty lines, then centered input prompt
|
|
577
|
+
2.times { screen.body.add_line }
|
|
578
|
+
screen.body.add_line do |line|
|
|
579
|
+
prefix = "New name: "
|
|
580
|
+
line.center.write_dim(prefix)
|
|
581
|
+
line.center << screen.input("", value: rename_buffer, cursor: rename_cursor).to_s
|
|
582
|
+
# Input displays buffer + trailing space when cursor at end
|
|
583
|
+
# Use (width - 1) to match Line.render's max_content calculation
|
|
584
|
+
input_width = [rename_buffer.length, rename_cursor + 1].max
|
|
585
|
+
prefix_width = Tui::Metrics.visible_width(prefix)
|
|
586
|
+
max_content = screen.width - 1
|
|
587
|
+
center_start = (max_content - prefix_width - input_width) / 2
|
|
588
|
+
line.mark_has_input(center_start + prefix_width)
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
if rename_error
|
|
592
|
+
screen.body.add_line
|
|
593
|
+
screen.body.add_line { |line| line.center.write_bold(rename_error) }
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
screen.footer.add_line { |line| line.write.write_dim(fill("─")) }
|
|
597
|
+
screen.footer.add_line { |line| line.center.write_dim("Enter: Confirm Esc: Cancel") }
|
|
598
|
+
|
|
599
|
+
screen.flush
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
def finalize_rename(entry, rename_buffer)
|
|
603
|
+
new_name = rename_buffer.strip.gsub(/\s+/, '-')
|
|
604
|
+
old_name = entry[:basename]
|
|
605
|
+
|
|
606
|
+
return "Name cannot be empty" if new_name.empty?
|
|
607
|
+
return "Name cannot contain /" if new_name.include?('/')
|
|
608
|
+
return true if new_name == old_name # No change, just exit
|
|
609
|
+
return "Directory exists: #{new_name}" if Dir.exist?(File.join(@base_path, new_name))
|
|
610
|
+
|
|
611
|
+
@selected = { type: :rename, old: old_name, new: new_name, base_path: @base_path }
|
|
612
|
+
true
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
def handle_selection(try_dir)
|
|
616
|
+
# Select existing try directory
|
|
617
|
+
@selected = { type: :cd, path: try_dir[:path] }
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
def handle_create_new
|
|
621
|
+
# Create new try directory
|
|
622
|
+
date_prefix = Time.now.strftime("%Y-%m-%d")
|
|
623
|
+
|
|
624
|
+
# If user already typed a name, use it directly
|
|
625
|
+
if !@input_buffer.empty?
|
|
626
|
+
final_name = "#{date_prefix}-#{@input_buffer}".gsub(/\s+/, '-')
|
|
627
|
+
full_path = File.join(@base_path, final_name)
|
|
628
|
+
@selected = { type: :mkdir, path: full_path }
|
|
629
|
+
else
|
|
630
|
+
# No name typed, prompt for one
|
|
631
|
+
entry = ""
|
|
632
|
+
begin
|
|
633
|
+
clear_screen unless @test_no_cls
|
|
634
|
+
show_cursor
|
|
635
|
+
STDERR.puts "Enter new try name"
|
|
636
|
+
STDERR.puts
|
|
637
|
+
STDERR.print("> #{date_prefix}-")
|
|
638
|
+
STDERR.flush
|
|
639
|
+
|
|
640
|
+
STDERR.cooked do
|
|
641
|
+
STDIN.iflush
|
|
642
|
+
entry = STDIN.gets&.chomp.to_s
|
|
643
|
+
end
|
|
644
|
+
ensure
|
|
645
|
+
hide_cursor unless @test_no_cls
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
return if entry.nil? || entry.empty?
|
|
649
|
+
|
|
650
|
+
final_name = "#{date_prefix}-#{entry}".gsub(/\s+/, '-')
|
|
651
|
+
full_path = File.join(@base_path, final_name)
|
|
652
|
+
|
|
653
|
+
@selected = { type: :mkdir, path: full_path }
|
|
654
|
+
end
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
def confirm_batch_delete(tries)
|
|
658
|
+
# Find marked items with their info
|
|
659
|
+
marked_items = tries.select { |t| @marked_for_deletion.include?(t[:path]) }
|
|
660
|
+
return if marked_items.empty?
|
|
661
|
+
|
|
662
|
+
confirmation_buffer = ""
|
|
663
|
+
confirmation_cursor = 0
|
|
664
|
+
|
|
665
|
+
# Handle test mode
|
|
666
|
+
if @test_keys && !@test_keys.empty?
|
|
667
|
+
while @test_keys && !@test_keys.empty?
|
|
668
|
+
ch = @test_keys.shift
|
|
669
|
+
break if ch == "\r" || ch == "\n"
|
|
670
|
+
confirmation_buffer << ch
|
|
671
|
+
confirmation_cursor = confirmation_buffer.length
|
|
672
|
+
end
|
|
673
|
+
process_delete_confirmation(marked_items, confirmation_buffer)
|
|
674
|
+
return
|
|
675
|
+
elsif @test_confirm || !STDERR.tty?
|
|
676
|
+
confirmation_buffer = (@test_confirm || STDIN.gets)&.chomp.to_s
|
|
677
|
+
process_delete_confirmation(marked_items, confirmation_buffer)
|
|
678
|
+
return
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
# Interactive delete confirmation dialog
|
|
682
|
+
# Clear screen once before dialog to ensure clean slate
|
|
683
|
+
clear_screen unless @test_no_cls
|
|
684
|
+
loop do
|
|
685
|
+
render_delete_dialog(marked_items, confirmation_buffer, confirmation_cursor)
|
|
686
|
+
|
|
687
|
+
ch = read_key
|
|
688
|
+
case ch
|
|
689
|
+
when "\r" # Enter - confirm
|
|
690
|
+
process_delete_confirmation(marked_items, confirmation_buffer)
|
|
691
|
+
break
|
|
692
|
+
when "\e" # Escape - cancel
|
|
693
|
+
@delete_status = "Delete cancelled"
|
|
694
|
+
@marked_for_deletion.clear
|
|
695
|
+
@delete_mode = false
|
|
696
|
+
break
|
|
697
|
+
when "\x7F", "\b" # Backspace
|
|
698
|
+
if confirmation_cursor > 0
|
|
699
|
+
confirmation_buffer = confirmation_buffer[0...confirmation_cursor-1] + confirmation_buffer[confirmation_cursor..]
|
|
700
|
+
confirmation_cursor -= 1
|
|
701
|
+
end
|
|
702
|
+
when "\x03" # Ctrl-C
|
|
703
|
+
@delete_status = "Delete cancelled"
|
|
704
|
+
@marked_for_deletion.clear
|
|
705
|
+
@delete_mode = false
|
|
706
|
+
break
|
|
707
|
+
when String
|
|
708
|
+
if ch.length == 1 && ch.ord >= 32
|
|
709
|
+
confirmation_buffer = confirmation_buffer[0...confirmation_cursor] + ch + confirmation_buffer[confirmation_cursor..]
|
|
710
|
+
confirmation_cursor += 1
|
|
711
|
+
end
|
|
712
|
+
end
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
@needs_redraw = true
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
def render_delete_dialog(marked_items, confirmation_buffer, confirmation_cursor)
|
|
719
|
+
screen = Tui::Screen.new(io: STDERR)
|
|
720
|
+
|
|
721
|
+
count = marked_items.length
|
|
722
|
+
screen.header.add_line do |line|
|
|
723
|
+
line.center << emoji("🗑️") << Tui::Text.accent(" Delete #{count} #{count == 1 ? 'directory' : 'directories'}?")
|
|
724
|
+
end
|
|
725
|
+
screen.header.add_line { |line| line.write.write_dim(fill("─")) }
|
|
726
|
+
|
|
727
|
+
marked_items.each do |item|
|
|
728
|
+
screen.body.add_line(background: Tui::Palette::DANGER_BG) do |line|
|
|
729
|
+
line.write << emoji("🗑️") << " #{item[:basename]}"
|
|
730
|
+
end
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
# Add empty lines, then centered confirmation prompt
|
|
734
|
+
2.times { screen.body.add_line }
|
|
735
|
+
screen.body.add_line do |line|
|
|
736
|
+
prefix = "Type YES to confirm: "
|
|
737
|
+
line.center.write_dim(prefix)
|
|
738
|
+
line.center << screen.input("", value: confirmation_buffer, cursor: confirmation_cursor).to_s
|
|
739
|
+
# Input displays buffer + trailing space when cursor at end
|
|
740
|
+
# Use (width - 1) to match Line.render's max_content calculation
|
|
741
|
+
input_width = [confirmation_buffer.length, confirmation_cursor + 1].max
|
|
742
|
+
prefix_width = Tui::Metrics.visible_width(prefix)
|
|
743
|
+
max_content = screen.width - 1
|
|
744
|
+
center_start = (max_content - prefix_width - input_width) / 2
|
|
745
|
+
line.mark_has_input(center_start + prefix_width)
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
screen.footer.add_line { |line| line.write.write_dim(fill("─")) }
|
|
749
|
+
screen.footer.add_line { |line| line.center.write_dim("Enter: Confirm Esc: Cancel") }
|
|
750
|
+
|
|
751
|
+
screen.flush
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
def process_delete_confirmation(marked_items, confirmation)
|
|
755
|
+
if confirmation == "YES"
|
|
756
|
+
begin
|
|
757
|
+
base_real = File.realpath(@base_path)
|
|
758
|
+
|
|
759
|
+
# Validate all paths first
|
|
760
|
+
validated_paths = []
|
|
761
|
+
marked_items.each do |item|
|
|
762
|
+
target_real = File.realpath(item[:path])
|
|
763
|
+
unless target_real.start_with?(base_real + "/")
|
|
764
|
+
raise "Safety check failed: #{target_real} is not inside #{base_real}"
|
|
765
|
+
end
|
|
766
|
+
validated_paths << { path: target_real, basename: item[:basename] }
|
|
767
|
+
end
|
|
768
|
+
|
|
769
|
+
# Return delete action with all paths
|
|
770
|
+
@selected = { type: :delete, paths: validated_paths, base_path: base_real }
|
|
771
|
+
names = validated_paths.map { |p| p[:basename] }.join(", ")
|
|
772
|
+
@delete_status = "Deleted: #{names}"
|
|
773
|
+
@all_tries = nil # Clear cache
|
|
774
|
+
@fuzzy = nil
|
|
775
|
+
@marked_for_deletion.clear
|
|
776
|
+
@delete_mode = false
|
|
777
|
+
rescue => e
|
|
778
|
+
@delete_status = "Error: #{e.message}"
|
|
779
|
+
end
|
|
780
|
+
else
|
|
781
|
+
@delete_status = "Delete cancelled"
|
|
782
|
+
@marked_for_deletion.clear
|
|
783
|
+
@delete_mode = false
|
|
784
|
+
end
|
|
785
|
+
end
|
|
786
|
+
end
|
|
787
|
+
|
|
788
|
+
# Main execution with OptionParser subcommands
|
|
789
|
+
if __FILE__ == $0
|
|
790
|
+
|
|
791
|
+
VERSION = "1.7.1"
|
|
792
|
+
|
|
793
|
+
def print_global_help
|
|
794
|
+
text = <<~HELP
|
|
795
|
+
try v#{VERSION} - ephemeral workspace manager
|
|
796
|
+
|
|
797
|
+
To use try, add to your shell config:
|
|
798
|
+
|
|
799
|
+
# bash/zsh (~/.bashrc or ~/.zshrc)
|
|
800
|
+
eval "$(try init ~/src/tries)"
|
|
801
|
+
|
|
802
|
+
# fish (~/.config/fish/config.fish)
|
|
803
|
+
eval (try init ~/src/tries | string collect)
|
|
804
|
+
|
|
805
|
+
Usage:
|
|
806
|
+
try [query] Interactive directory selector
|
|
807
|
+
try clone <url> Clone repo into dated directory
|
|
808
|
+
try worktree <name> Create worktree from current git repo
|
|
809
|
+
try --help Show this help
|
|
810
|
+
|
|
811
|
+
Commands:
|
|
812
|
+
init [path] Output shell function definition
|
|
813
|
+
clone <url> [name] Clone git repo into date-prefixed directory
|
|
814
|
+
worktree <name> Create worktree in dated directory
|
|
815
|
+
|
|
816
|
+
Examples:
|
|
817
|
+
try Open interactive selector
|
|
818
|
+
try project Selector with initial filter
|
|
819
|
+
try clone https://github.com/user/repo
|
|
820
|
+
try worktree feature-branch
|
|
821
|
+
|
|
822
|
+
Manual mode (without alias):
|
|
823
|
+
try exec [query] Output shell script to eval
|
|
824
|
+
|
|
825
|
+
Defaults:
|
|
826
|
+
Default path: ~/src/tries
|
|
827
|
+
Current: #{TrySelector::TRY_PATH}
|
|
828
|
+
HELP
|
|
829
|
+
STDOUT.print(text)
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
# Process color-related flags early
|
|
833
|
+
disable_colors = ARGV.delete('--no-colors')
|
|
834
|
+
disable_colors ||= ARGV.delete('--no-expand-tokens')
|
|
835
|
+
|
|
836
|
+
Tui.disable_colors! if disable_colors
|
|
837
|
+
Tui.disable_colors! if ENV['NO_COLOR'] && !ENV['NO_COLOR'].empty?
|
|
838
|
+
|
|
839
|
+
# Global help: show for --help/-h anywhere
|
|
840
|
+
if ARGV.include?("--help") || ARGV.include?("-h")
|
|
841
|
+
print_global_help
|
|
842
|
+
exit 0
|
|
843
|
+
end
|
|
844
|
+
|
|
845
|
+
# Version flag
|
|
846
|
+
if ARGV.include?("--version") || ARGV.include?("-v")
|
|
847
|
+
puts "try #{VERSION}"
|
|
848
|
+
exit 0
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
# Helper to extract a "--name VALUE" or "--name=VALUE" option from args (last one wins)
|
|
852
|
+
def extract_option_with_value!(args, opt_name)
|
|
853
|
+
i = args.rindex { |a| a == opt_name || a.start_with?("#{opt_name}=") }
|
|
854
|
+
return nil unless i
|
|
855
|
+
arg = args.delete_at(i)
|
|
856
|
+
if arg.include?('=')
|
|
857
|
+
arg.split('=', 2)[1]
|
|
858
|
+
else
|
|
859
|
+
args.delete_at(i)
|
|
860
|
+
end
|
|
861
|
+
end
|
|
862
|
+
|
|
863
|
+
def parse_git_uri(uri)
|
|
864
|
+
# Remove .git suffix if present
|
|
865
|
+
uri = uri.sub(/\.git$/, '')
|
|
866
|
+
|
|
867
|
+
# Handle different git URI formats
|
|
868
|
+
if uri.match(%r{^https?://github\.com/([^/]+)/([^/]+)})
|
|
869
|
+
# https://github.com/user/repo
|
|
870
|
+
user, repo = $1, $2
|
|
871
|
+
return { user: user, repo: repo, host: 'github.com' }
|
|
872
|
+
elsif uri.match(%r{^git@github\.com:([^/]+)/([^/]+)})
|
|
873
|
+
# git@github.com:user/repo
|
|
874
|
+
user, repo = $1, $2
|
|
875
|
+
return { user: user, repo: repo, host: 'github.com' }
|
|
876
|
+
elsif uri.match(%r{^https?://([^/]+)/([^/]+)/([^/]+)})
|
|
877
|
+
# https://gitlab.com/user/repo or other git hosts
|
|
878
|
+
host, user, repo = $1, $2, $3
|
|
879
|
+
return { user: user, repo: repo, host: host }
|
|
880
|
+
elsif uri.match(%r{^git@([^:]+):([^/]+)/([^/]+)})
|
|
881
|
+
# git@host:user/repo
|
|
882
|
+
host, user, repo = $1, $2, $3
|
|
883
|
+
return { user: user, repo: repo, host: host }
|
|
884
|
+
else
|
|
885
|
+
return nil
|
|
886
|
+
end
|
|
887
|
+
end
|
|
888
|
+
|
|
889
|
+
def generate_clone_directory_name(git_uri, custom_name = nil)
|
|
890
|
+
return custom_name if custom_name && !custom_name.empty?
|
|
891
|
+
|
|
892
|
+
parsed = parse_git_uri(git_uri)
|
|
893
|
+
return nil unless parsed
|
|
894
|
+
|
|
895
|
+
date_prefix = Time.now.strftime("%Y-%m-%d")
|
|
896
|
+
"#{date_prefix}-#{parsed[:user]}-#{parsed[:repo]}"
|
|
897
|
+
end
|
|
898
|
+
|
|
899
|
+
def is_git_uri?(arg)
|
|
900
|
+
return false unless arg
|
|
901
|
+
arg.match?(%r{^(https?://|git@)}) || arg.include?('github.com') || arg.include?('gitlab.com') || arg.end_with?('.git')
|
|
902
|
+
end
|
|
903
|
+
|
|
904
|
+
# Extract all options BEFORE getting command (they can appear anywhere)
|
|
905
|
+
tries_path = extract_option_with_value!(ARGV, '--path') || TrySelector::TRY_PATH
|
|
906
|
+
tries_path = File.expand_path(tries_path)
|
|
907
|
+
|
|
908
|
+
# Test-only flags (undocumented; aid acceptance tests)
|
|
909
|
+
# Must be extracted before command shift since they can come before command
|
|
910
|
+
and_type = extract_option_with_value!(ARGV, '--and-type')
|
|
911
|
+
and_exit = !!ARGV.delete('--and-exit')
|
|
912
|
+
and_keys_raw = extract_option_with_value!(ARGV, '--and-keys')
|
|
913
|
+
and_confirm = extract_option_with_value!(ARGV, '--and-confirm')
|
|
914
|
+
# Note: --no-expand-tokens and --no-colors are processed early (before --help check)
|
|
915
|
+
|
|
916
|
+
command = ARGV.shift
|
|
917
|
+
|
|
918
|
+
def parse_test_keys(spec)
|
|
919
|
+
return nil unless spec && !spec.empty?
|
|
920
|
+
|
|
921
|
+
# Detect mode: if contains comma OR is purely uppercase letters/hyphens, use token mode
|
|
922
|
+
# Otherwise use raw character mode (for spec tests that pass literal key sequences)
|
|
923
|
+
use_token_mode = spec.include?(',') || spec.match?(/^[A-Z\-]+$/)
|
|
924
|
+
|
|
925
|
+
if use_token_mode
|
|
926
|
+
tokens = spec.split(/,\s*/)
|
|
927
|
+
keys = []
|
|
928
|
+
tokens.each do |tok|
|
|
929
|
+
up = tok.upcase
|
|
930
|
+
case up
|
|
931
|
+
when 'UP' then keys << "\e[A"
|
|
932
|
+
when 'DOWN' then keys << "\e[B"
|
|
933
|
+
when 'LEFT' then keys << "\e[D"
|
|
934
|
+
when 'RIGHT' then keys << "\e[C"
|
|
935
|
+
when 'ENTER' then keys << "\r"
|
|
936
|
+
when 'ESC' then keys << "\e"
|
|
937
|
+
when 'BACKSPACE' then keys << "\x7F"
|
|
938
|
+
when 'CTRL-A', 'CTRLA' then keys << "\x01"
|
|
939
|
+
when 'CTRL-B', 'CTRLB' then keys << "\x02"
|
|
940
|
+
when 'CTRL-D', 'CTRLD' then keys << "\x04"
|
|
941
|
+
when 'CTRL-E', 'CTRLE' then keys << "\x05"
|
|
942
|
+
when 'CTRL-F', 'CTRLF' then keys << "\x06"
|
|
943
|
+
when 'CTRL-H', 'CTRLH' then keys << "\x08"
|
|
944
|
+
when 'CTRL-K', 'CTRLK' then keys << "\x0B"
|
|
945
|
+
when 'CTRL-N', 'CTRLN' then keys << "\x0E"
|
|
946
|
+
when 'CTRL-P', 'CTRLP' then keys << "\x10"
|
|
947
|
+
when 'CTRL-R', 'CTRLR' then keys << "\x12"
|
|
948
|
+
when 'CTRL-T', 'CTRLT' then keys << "\x14"
|
|
949
|
+
when 'CTRL-W', 'CTRLW' then keys << "\x17"
|
|
950
|
+
when /^TYPE=(.*)$/
|
|
951
|
+
$1.each_char { |ch| keys << ch }
|
|
952
|
+
else
|
|
953
|
+
keys << tok if tok.length == 1
|
|
954
|
+
end
|
|
955
|
+
end
|
|
956
|
+
keys
|
|
957
|
+
else
|
|
958
|
+
# Raw character mode: each character (including escape sequences) is a key
|
|
959
|
+
keys = []
|
|
960
|
+
i = 0
|
|
961
|
+
while i < spec.length
|
|
962
|
+
if spec[i] == "\e" && i + 2 < spec.length && spec[i + 1] == '['
|
|
963
|
+
# Escape sequence like \e[A for arrow keys
|
|
964
|
+
keys << spec[i, 3]
|
|
965
|
+
i += 3
|
|
966
|
+
else
|
|
967
|
+
keys << spec[i]
|
|
968
|
+
i += 1
|
|
969
|
+
end
|
|
970
|
+
end
|
|
971
|
+
keys
|
|
972
|
+
end
|
|
973
|
+
end
|
|
974
|
+
and_keys = parse_test_keys(and_keys_raw)
|
|
975
|
+
|
|
976
|
+
def cmd_clone!(args, tries_path)
|
|
977
|
+
git_uri = args.shift
|
|
978
|
+
custom_name = args.shift
|
|
979
|
+
|
|
980
|
+
unless git_uri
|
|
981
|
+
warn "Error: git URI required for clone command"
|
|
982
|
+
warn "Usage: try clone <git-uri> [name]"
|
|
983
|
+
exit 1
|
|
984
|
+
end
|
|
985
|
+
|
|
986
|
+
dir_name = generate_clone_directory_name(git_uri, custom_name)
|
|
987
|
+
unless dir_name
|
|
988
|
+
warn "Error: Unable to parse git URI: #{git_uri}"
|
|
989
|
+
exit 1
|
|
990
|
+
end
|
|
991
|
+
|
|
992
|
+
script_clone(File.join(tries_path, dir_name), git_uri)
|
|
993
|
+
end
|
|
994
|
+
|
|
995
|
+
def cmd_init!(args, tries_path)
|
|
996
|
+
script_path = File.expand_path($0)
|
|
997
|
+
|
|
998
|
+
if args[0] && args[0].start_with?('/')
|
|
999
|
+
tries_path = File.expand_path(args.shift)
|
|
1000
|
+
end
|
|
1001
|
+
|
|
1002
|
+
path_arg = tries_path ? " --path '#{tries_path}'" : ""
|
|
1003
|
+
bash_or_zsh_script = <<~SHELL
|
|
1004
|
+
try() {
|
|
1005
|
+
local out
|
|
1006
|
+
out=$(/usr/bin/env ruby '#{script_path}' exec#{path_arg} "$@" 2>/dev/tty)
|
|
1007
|
+
if [ $? -eq 0 ]; then
|
|
1008
|
+
eval "$out"
|
|
1009
|
+
else
|
|
1010
|
+
echo "$out"
|
|
1011
|
+
fi
|
|
1012
|
+
}
|
|
1013
|
+
SHELL
|
|
1014
|
+
|
|
1015
|
+
fish_script = <<~SHELL
|
|
1016
|
+
function try
|
|
1017
|
+
set -l out (/usr/bin/env ruby '#{script_path}' exec#{path_arg} $argv 2>/dev/tty | string collect)
|
|
1018
|
+
if test $status -eq 0
|
|
1019
|
+
eval $out
|
|
1020
|
+
else
|
|
1021
|
+
echo $out
|
|
1022
|
+
end
|
|
1023
|
+
end
|
|
1024
|
+
SHELL
|
|
1025
|
+
|
|
1026
|
+
puts fish? ? fish_script : bash_or_zsh_script
|
|
1027
|
+
exit 0
|
|
1028
|
+
end
|
|
1029
|
+
|
|
1030
|
+
def cmd_cd!(args, tries_path, and_type, and_exit, and_keys, and_confirm)
|
|
1031
|
+
if args.first == "clone"
|
|
1032
|
+
return cmd_clone!(args[1..-1] || [], tries_path)
|
|
1033
|
+
end
|
|
1034
|
+
|
|
1035
|
+
# Support: try . [name] and try ./path [name]
|
|
1036
|
+
if args.first && args.first.start_with?('.')
|
|
1037
|
+
path_arg = args.shift
|
|
1038
|
+
custom = args.join(' ')
|
|
1039
|
+
repo_dir = File.expand_path(path_arg)
|
|
1040
|
+
# Bare "try ." requires a name argument (too easy to invoke accidentally)
|
|
1041
|
+
if path_arg == '.' && (custom.nil? || custom.strip.empty?)
|
|
1042
|
+
STDERR.puts "Error: 'try .' requires a name argument"
|
|
1043
|
+
STDERR.puts "Usage: try . <name>"
|
|
1044
|
+
exit 1
|
|
1045
|
+
end
|
|
1046
|
+
base = if custom && !custom.strip.empty?
|
|
1047
|
+
custom.gsub(/\s+/, '-')
|
|
1048
|
+
else
|
|
1049
|
+
File.basename(repo_dir)
|
|
1050
|
+
end
|
|
1051
|
+
date_prefix = Time.now.strftime("%Y-%m-%d")
|
|
1052
|
+
base = resolve_unique_name_with_versioning(tries_path, date_prefix, base)
|
|
1053
|
+
full_path = File.join(tries_path, "#{date_prefix}-#{base}")
|
|
1054
|
+
# Use worktree if .git exists (file in worktrees, directory in regular repos)
|
|
1055
|
+
if File.exist?(File.join(repo_dir, '.git'))
|
|
1056
|
+
return script_worktree(full_path, repo_dir)
|
|
1057
|
+
else
|
|
1058
|
+
return script_mkdir_cd(full_path)
|
|
1059
|
+
end
|
|
1060
|
+
end
|
|
1061
|
+
|
|
1062
|
+
search_term = args.join(' ')
|
|
1063
|
+
|
|
1064
|
+
# Git URL shorthand → clone workflow
|
|
1065
|
+
if is_git_uri?(search_term.split.first)
|
|
1066
|
+
git_uri, custom_name = search_term.split(/\s+/, 2)
|
|
1067
|
+
dir_name = generate_clone_directory_name(git_uri, custom_name)
|
|
1068
|
+
unless dir_name
|
|
1069
|
+
warn "Error: Unable to parse git URI: #{git_uri}"
|
|
1070
|
+
exit 1
|
|
1071
|
+
end
|
|
1072
|
+
full_path = File.join(tries_path, dir_name)
|
|
1073
|
+
return script_clone(full_path, git_uri)
|
|
1074
|
+
end
|
|
1075
|
+
|
|
1076
|
+
# Regular interactive selector
|
|
1077
|
+
selector = TrySelector.new(
|
|
1078
|
+
search_term,
|
|
1079
|
+
base_path: tries_path,
|
|
1080
|
+
initial_input: and_type,
|
|
1081
|
+
test_render_once: and_exit,
|
|
1082
|
+
test_no_cls: (and_exit || (and_keys && !and_keys.empty?)),
|
|
1083
|
+
test_keys: and_keys,
|
|
1084
|
+
test_confirm: and_confirm
|
|
1085
|
+
)
|
|
1086
|
+
result = selector.run
|
|
1087
|
+
return nil unless result
|
|
1088
|
+
|
|
1089
|
+
case result[:type]
|
|
1090
|
+
when :delete
|
|
1091
|
+
script_delete(result[:paths], result[:base_path])
|
|
1092
|
+
when :mkdir
|
|
1093
|
+
script_mkdir_cd(result[:path])
|
|
1094
|
+
when :rename
|
|
1095
|
+
script_rename(result[:base_path], result[:old], result[:new])
|
|
1096
|
+
else
|
|
1097
|
+
script_cd(result[:path])
|
|
1098
|
+
end
|
|
1099
|
+
end
|
|
1100
|
+
|
|
1101
|
+
# --- Shell script helpers ---
|
|
1102
|
+
SCRIPT_WARNING = "# if you can read this, you didn't launch try from an alias. run try --help."
|
|
1103
|
+
|
|
1104
|
+
def q(str)
|
|
1105
|
+
"'" + str.gsub("'", %q('"'"')) + "'"
|
|
1106
|
+
end
|
|
1107
|
+
|
|
1108
|
+
def emit_script(cmds)
|
|
1109
|
+
puts SCRIPT_WARNING
|
|
1110
|
+
cmds.each_with_index do |cmd, i|
|
|
1111
|
+
if i == 0
|
|
1112
|
+
print cmd
|
|
1113
|
+
else
|
|
1114
|
+
print " #{cmd}"
|
|
1115
|
+
end
|
|
1116
|
+
if i < cmds.length - 1
|
|
1117
|
+
puts " && \\"
|
|
1118
|
+
else
|
|
1119
|
+
puts
|
|
1120
|
+
end
|
|
1121
|
+
end
|
|
1122
|
+
end
|
|
1123
|
+
|
|
1124
|
+
def script_cd(path)
|
|
1125
|
+
["touch #{q(path)}", "echo #{q(path)}", "cd #{q(path)}"]
|
|
1126
|
+
end
|
|
1127
|
+
|
|
1128
|
+
def script_mkdir_cd(path)
|
|
1129
|
+
["mkdir -p #{q(path)}"] + script_cd(path)
|
|
1130
|
+
end
|
|
1131
|
+
|
|
1132
|
+
def script_clone(path, uri)
|
|
1133
|
+
["mkdir -p #{q(path)}", "echo #{q("Using git clone to create this trial from #{uri}.")}", "git clone '#{uri}' #{q(path)}"] + script_cd(path)
|
|
1134
|
+
end
|
|
1135
|
+
|
|
1136
|
+
def script_worktree(path, repo = nil)
|
|
1137
|
+
r = repo ? q(repo) : nil
|
|
1138
|
+
worktree_cmd = if r
|
|
1139
|
+
"/usr/bin/env sh -c 'if git -C #{r} rev-parse --is-inside-work-tree >/dev/null 2>&1; then repo=$(git -C #{r} rev-parse --show-toplevel); git -C \"$repo\" worktree add --detach #{q(path)} >/dev/null 2>&1 || true; fi; exit 0'"
|
|
1140
|
+
else
|
|
1141
|
+
"/usr/bin/env sh -c 'if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then repo=$(git rev-parse --show-toplevel); git -C \"$repo\" worktree add --detach #{q(path)} >/dev/null 2>&1 || true; fi; exit 0'"
|
|
1142
|
+
end
|
|
1143
|
+
src = repo || Dir.pwd
|
|
1144
|
+
["mkdir -p #{q(path)}", "echo #{q("Using git worktree to create this trial from #{src}.")}", worktree_cmd] + script_cd(path)
|
|
1145
|
+
end
|
|
1146
|
+
|
|
1147
|
+
def script_delete(paths, base_path)
|
|
1148
|
+
cmds = ["cd #{q(base_path)}"]
|
|
1149
|
+
paths.each { |item| cmds << "test -d #{q(item[:basename])} && rm -rf #{q(item[:basename])}" }
|
|
1150
|
+
cmds << "( cd #{q(Dir.pwd)} 2>/dev/null || cd \"$HOME\" )"
|
|
1151
|
+
cmds
|
|
1152
|
+
end
|
|
1153
|
+
|
|
1154
|
+
def script_rename(base_path, old_name, new_name)
|
|
1155
|
+
new_path = File.join(base_path, new_name)
|
|
1156
|
+
[
|
|
1157
|
+
"cd #{q(base_path)}",
|
|
1158
|
+
"mv #{q(old_name)} #{q(new_name)}",
|
|
1159
|
+
"echo #{q(new_path)}",
|
|
1160
|
+
"cd #{q(new_path)}"
|
|
1161
|
+
]
|
|
1162
|
+
end
|
|
1163
|
+
|
|
1164
|
+
# Return a unique directory name under tries_path by appending -2, -3, ... if needed
|
|
1165
|
+
def unique_dir_name(tries_path, dir_name)
|
|
1166
|
+
candidate = dir_name
|
|
1167
|
+
i = 2
|
|
1168
|
+
while Dir.exist?(File.join(tries_path, candidate))
|
|
1169
|
+
candidate = "#{dir_name}-#{i}"
|
|
1170
|
+
i += 1
|
|
1171
|
+
end
|
|
1172
|
+
candidate
|
|
1173
|
+
end
|
|
1174
|
+
|
|
1175
|
+
# If the given base ends with digits and today's dir already exists,
|
|
1176
|
+
# bump the trailing number to the next available one for today.
|
|
1177
|
+
# Otherwise, fall back to unique_dir_name with -2, -3 suffixes.
|
|
1178
|
+
def resolve_unique_name_with_versioning(tries_path, date_prefix, base)
|
|
1179
|
+
initial = "#{date_prefix}-#{base}"
|
|
1180
|
+
return base unless Dir.exist?(File.join(tries_path, initial))
|
|
1181
|
+
|
|
1182
|
+
m = base.match(/^(.*?)(\d+)$/)
|
|
1183
|
+
if m
|
|
1184
|
+
stem, n = m[1], m[2].to_i
|
|
1185
|
+
candidate_num = n + 1
|
|
1186
|
+
loop do
|
|
1187
|
+
candidate_base = "#{stem}#{candidate_num}"
|
|
1188
|
+
candidate_full = File.join(tries_path, "#{date_prefix}-#{candidate_base}")
|
|
1189
|
+
return candidate_base unless Dir.exist?(candidate_full)
|
|
1190
|
+
candidate_num += 1
|
|
1191
|
+
end
|
|
1192
|
+
else
|
|
1193
|
+
# No numeric suffix; use -2 style uniqueness on full name
|
|
1194
|
+
return unique_dir_name(tries_path, "#{date_prefix}-#{base}").sub(/^#{Regexp.escape(date_prefix)}-/, '')
|
|
1195
|
+
end
|
|
1196
|
+
end
|
|
1197
|
+
|
|
1198
|
+
# shell detection for init wrapper
|
|
1199
|
+
# Check $SHELL first (user's configured shell), then parent process as fallback
|
|
1200
|
+
def fish?
|
|
1201
|
+
shell = ENV["SHELL"]
|
|
1202
|
+
shell = `ps c -p #{Process.ppid} -o 'ucomm='`.strip rescue nil if shell.to_s.empty?
|
|
1203
|
+
|
|
1204
|
+
shell&.include?('fish')
|
|
1205
|
+
end
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
# Helper to generate worktree path from repo
|
|
1209
|
+
def worktree_path(tries_path, repo_dir, custom_name)
|
|
1210
|
+
base = if custom_name && !custom_name.strip.empty?
|
|
1211
|
+
custom_name.gsub(/\s+/, '-')
|
|
1212
|
+
else
|
|
1213
|
+
begin; File.basename(File.realpath(repo_dir)); rescue; File.basename(repo_dir); end
|
|
1214
|
+
end
|
|
1215
|
+
date_prefix = Time.now.strftime("%Y-%m-%d")
|
|
1216
|
+
base = resolve_unique_name_with_versioning(tries_path, date_prefix, base)
|
|
1217
|
+
File.join(tries_path, "#{date_prefix}-#{base}")
|
|
1218
|
+
end
|
|
1219
|
+
|
|
1220
|
+
case command
|
|
1221
|
+
when nil
|
|
1222
|
+
print_global_help
|
|
1223
|
+
exit 2
|
|
1224
|
+
when 'clone'
|
|
1225
|
+
emit_script(cmd_clone!(ARGV, tries_path))
|
|
1226
|
+
exit 0
|
|
1227
|
+
when 'init'
|
|
1228
|
+
cmd_init!(ARGV, tries_path)
|
|
1229
|
+
exit 0
|
|
1230
|
+
when 'exec'
|
|
1231
|
+
sub = ARGV.first
|
|
1232
|
+
case sub
|
|
1233
|
+
when 'clone'
|
|
1234
|
+
ARGV.shift
|
|
1235
|
+
emit_script(cmd_clone!(ARGV, tries_path))
|
|
1236
|
+
when 'worktree'
|
|
1237
|
+
ARGV.shift
|
|
1238
|
+
repo = ARGV.shift
|
|
1239
|
+
repo_dir = repo && repo != 'dir' ? File.expand_path(repo) : Dir.pwd
|
|
1240
|
+
full_path = worktree_path(tries_path, repo_dir, ARGV.join(' '))
|
|
1241
|
+
emit_script(script_worktree(full_path, repo_dir == Dir.pwd ? nil : repo_dir))
|
|
1242
|
+
when 'cd'
|
|
1243
|
+
ARGV.shift
|
|
1244
|
+
script = cmd_cd!(ARGV, tries_path, and_type, and_exit, and_keys, and_confirm)
|
|
1245
|
+
if script
|
|
1246
|
+
emit_script(script)
|
|
1247
|
+
exit 0
|
|
1248
|
+
else
|
|
1249
|
+
puts "Cancelled."
|
|
1250
|
+
exit 1
|
|
1251
|
+
end
|
|
1252
|
+
else
|
|
1253
|
+
script = cmd_cd!(ARGV, tries_path, and_type, and_exit, and_keys, and_confirm)
|
|
1254
|
+
if script
|
|
1255
|
+
emit_script(script)
|
|
1256
|
+
exit 0
|
|
1257
|
+
else
|
|
1258
|
+
puts "Cancelled."
|
|
1259
|
+
exit 1
|
|
1260
|
+
end
|
|
1261
|
+
end
|
|
1262
|
+
when 'worktree'
|
|
1263
|
+
repo = ARGV.shift
|
|
1264
|
+
repo_dir = repo && repo != 'dir' ? File.expand_path(repo) : Dir.pwd
|
|
1265
|
+
full_path = worktree_path(tries_path, repo_dir, ARGV.join(' '))
|
|
1266
|
+
# Explicit worktree command always emits worktree script
|
|
1267
|
+
emit_script(script_worktree(full_path, repo_dir == Dir.pwd ? nil : repo_dir))
|
|
1268
|
+
exit 0
|
|
1269
|
+
else
|
|
1270
|
+
# Default: try [query] - same as try exec [query]
|
|
1271
|
+
script = cmd_cd!(ARGV.unshift(command), tries_path, and_type, and_exit, and_keys, and_confirm)
|
|
1272
|
+
if script
|
|
1273
|
+
emit_script(script)
|
|
1274
|
+
exit 0
|
|
1275
|
+
else
|
|
1276
|
+
puts "Cancelled."
|
|
1277
|
+
exit 1
|
|
1278
|
+
end
|
|
1279
|
+
end
|
|
1280
|
+
|
|
1281
|
+
end
|