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.
Files changed (9) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +259 -0
  4. data/VERSION +1 -0
  5. data/bin/try +4 -0
  6. data/lib/fuzzy.rb +133 -0
  7. data/lib/tui.rb +892 -0
  8. data/try.rb +1281 -0
  9. 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