try-cli 1.7.1 → 1.9.2
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 +4 -4
- data/README.md +26 -6
- data/VERSION +1 -1
- data/bin/try +2 -1
- data/lib/fuzzy.rb +13 -9
- data/lib/tui.rb +56 -113
- data/try.rb +262 -62
- metadata +9 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 00a24afe865c51ab7042ceb3800d665c9446524c48aca062463fad9aeb54a894
|
|
4
|
+
data.tar.gz: 3433f233d6191fdd578d37e09ac2f229ee7fe030a13c0ec5ab4a1bce60e5821e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 89ee4cecc2f8f7d05ba6ac56037db149b43b52efb111615753d975fea0a835e04adb9905c0864ccc3242a3f080510365d1a25b89fb077c04aa958a767a9b91b5
|
|
7
|
+
data.tar.gz: 1adc18aa91717d0984dfc468c7844ffe95357d50a1314bea7c2143b38666f7d7531cd933a3d8533e2b22a4f90359ee367580852c08229dfbb99a9dea629148ba
|
data/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# try - fresh directories for every vibe
|
|
2
2
|
|
|
3
|
+
**[Website](https://pages.tobi.lutke.com/try/)** · **[RubyGems](https://rubygems.org/gems/try-cli)** · **[GitHub](https://github.com/tobi/try)**
|
|
4
|
+
|
|
3
5
|
*Your experiments deserve a home.* 🏠
|
|
4
6
|
|
|
5
7
|
> For everyone who constantly creates new projects for little experiments, a one-file Ruby script to quickly manage and navigate to keep them somewhat organized
|
|
@@ -20,7 +22,25 @@ Instantly navigate through all your experiment directories with:
|
|
|
20
22
|
- **Auto-dating** - creates directories like `2025-08-17-redis-experiment`
|
|
21
23
|
- **Zero config** - just one Ruby file, no dependencies
|
|
22
24
|
|
|
23
|
-
##
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
### RubyGems (Recommended)
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
gem install try-cli
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Then add to your shell:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# Bash/Zsh - add to .zshrc or .bashrc
|
|
37
|
+
eval "$(try init)"
|
|
38
|
+
|
|
39
|
+
# Fish - add to config.fish
|
|
40
|
+
try init | source
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Quick Start (Manual)
|
|
24
44
|
|
|
25
45
|
```bash
|
|
26
46
|
curl -sL https://raw.githubusercontent.com/tobi/try/refs/heads/main/try.rb > ~/.local/try.rb
|
|
@@ -32,7 +52,7 @@ chmod +x ~/.local/try.rb
|
|
|
32
52
|
echo 'eval "$(ruby ~/.local/try.rb init ~/src/tries)"' >> ~/.zshrc
|
|
33
53
|
|
|
34
54
|
# for fish shell users
|
|
35
|
-
echo '
|
|
55
|
+
echo '~/.local/try.rb init ~/src/tries | source' >> ~/.config/fish/config.fish
|
|
36
56
|
```
|
|
37
57
|
|
|
38
58
|
## The Problem
|
|
@@ -92,9 +112,9 @@ Not just substring matching - it's smart:
|
|
|
92
112
|
- Fish:
|
|
93
113
|
|
|
94
114
|
```fish
|
|
95
|
-
|
|
115
|
+
~/.local/try.rb init | source
|
|
96
116
|
# or pick a path
|
|
97
|
-
|
|
117
|
+
~/.local/try.rb init ~/src/tries | source
|
|
98
118
|
```
|
|
99
119
|
|
|
100
120
|
Notes:
|
|
@@ -212,9 +232,9 @@ After installation, add to your shell:
|
|
|
212
232
|
- Fish:
|
|
213
233
|
|
|
214
234
|
```fish
|
|
215
|
-
|
|
235
|
+
try init | source
|
|
216
236
|
# or pick a path
|
|
217
|
-
|
|
237
|
+
try init ~/src/tries | source
|
|
218
238
|
```
|
|
219
239
|
|
|
220
240
|
## Why Ruby?
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.
|
|
1
|
+
1.9.2
|
data/bin/try
CHANGED
data/lib/fuzzy.rb
CHANGED
|
@@ -68,19 +68,23 @@ class Fuzzy
|
|
|
68
68
|
results << [entry.data, positions, score]
|
|
69
69
|
end
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
71
|
+
if @limit && @limit < results.length
|
|
72
|
+
# Partial sort: O(n log k) via heap selection instead of full O(n log n) sort
|
|
73
|
+
results = results.max_by(@limit) { |_, _, score| score }
|
|
74
|
+
else
|
|
75
|
+
results.sort_by! { |_, _, score| -score }
|
|
76
|
+
end
|
|
76
77
|
|
|
77
78
|
results.each(&block)
|
|
78
79
|
end
|
|
79
80
|
|
|
80
81
|
private
|
|
81
82
|
|
|
82
|
-
# Pre-
|
|
83
|
-
|
|
83
|
+
# Pre-compiled regex for word boundary detection
|
|
84
|
+
WORD_BOUNDARY_RE = /[^a-z0-9]/
|
|
85
|
+
|
|
86
|
+
# Pre-computed sqrt values for proximity bonus (gap 0-63)
|
|
87
|
+
SQRT_TABLE = (0..64).map { |n| 2.0 / Math.sqrt(n + 1) }.freeze
|
|
84
88
|
|
|
85
89
|
def calculate_match(entry)
|
|
86
90
|
positions = []
|
|
@@ -107,14 +111,14 @@ class Fuzzy
|
|
|
107
111
|
score += 1.0
|
|
108
112
|
|
|
109
113
|
# Word boundary bonus (start of string or after non-alphanumeric)
|
|
110
|
-
if found == 0 || text[found - 1].match?(
|
|
114
|
+
if found == 0 || text[found - 1].match?(WORD_BOUNDARY_RE)
|
|
111
115
|
score += 1.0
|
|
112
116
|
end
|
|
113
117
|
|
|
114
118
|
# Proximity bonus (consecutive chars score higher)
|
|
115
119
|
if last_pos >= 0
|
|
116
120
|
gap = found - last_pos - 1
|
|
117
|
-
score += gap <
|
|
121
|
+
score += gap < 64 ? SQRT_TABLE[gap] : (2.0 / Math.sqrt(gap + 1))
|
|
118
122
|
end
|
|
119
123
|
|
|
120
124
|
last_pos = found
|
data/lib/tui.rb
CHANGED
|
@@ -40,6 +40,10 @@ module Tui
|
|
|
40
40
|
end
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
+
# Precompiled regexes used in hot paths
|
|
44
|
+
ANSI_STRIP_RE = /\e\[[0-9;]*[A-Za-z]/
|
|
45
|
+
ESCAPE_TERMINATOR_RE = /[A-Za-z]/
|
|
46
|
+
|
|
43
47
|
module ANSI
|
|
44
48
|
CLEAR_EOL = "\e[K"
|
|
45
49
|
CLEAR_EOS = "\e[J"
|
|
@@ -77,6 +81,10 @@ module Tui
|
|
|
77
81
|
joined = codes.flatten.join(";")
|
|
78
82
|
"\e[#{joined}m"
|
|
79
83
|
end
|
|
84
|
+
|
|
85
|
+
def set_title(t)
|
|
86
|
+
"\e]2;#{t}\a"
|
|
87
|
+
end
|
|
80
88
|
end
|
|
81
89
|
|
|
82
90
|
module Palette
|
|
@@ -98,13 +106,15 @@ module Tui
|
|
|
98
106
|
|
|
99
107
|
# Optimized width calculation - avoids per-character method calls
|
|
100
108
|
def visible_width(text)
|
|
109
|
+
has_escape = text.include?("\e")
|
|
110
|
+
|
|
101
111
|
# Fast path: pure ASCII with no escapes
|
|
102
|
-
if text.bytesize == text.length
|
|
112
|
+
if !has_escape && text.bytesize == text.length
|
|
103
113
|
return text.length
|
|
104
114
|
end
|
|
105
115
|
|
|
106
116
|
# Strip ANSI escapes only if present
|
|
107
|
-
stripped =
|
|
117
|
+
stripped = has_escape ? text.gsub(ANSI_STRIP_RE, '') : text
|
|
108
118
|
|
|
109
119
|
# Fast path after stripping: pure ASCII
|
|
110
120
|
if stripped.bytesize == stripped.length
|
|
@@ -156,9 +166,9 @@ module Tui
|
|
|
156
166
|
text.each_char do |ch|
|
|
157
167
|
if in_escape
|
|
158
168
|
escape_buf << ch
|
|
159
|
-
if ch.match?(
|
|
169
|
+
if ch.match?(ESCAPE_TERMINATOR_RE)
|
|
160
170
|
truncated << escape_buf
|
|
161
|
-
escape_buf
|
|
171
|
+
escape_buf.clear
|
|
162
172
|
in_escape = false
|
|
163
173
|
end
|
|
164
174
|
next
|
|
@@ -166,7 +176,8 @@ module Tui
|
|
|
166
176
|
|
|
167
177
|
if ch == "\e"
|
|
168
178
|
in_escape = true
|
|
169
|
-
escape_buf
|
|
179
|
+
escape_buf.clear
|
|
180
|
+
escape_buf << ch
|
|
170
181
|
next
|
|
171
182
|
end
|
|
172
183
|
|
|
@@ -190,20 +201,19 @@ module Tui
|
|
|
190
201
|
leading_escapes = String.new
|
|
191
202
|
in_escape = false
|
|
192
203
|
escape_buf = String.new
|
|
193
|
-
text_start = 0
|
|
194
204
|
|
|
195
|
-
text.each_char
|
|
205
|
+
text.each_char do |ch|
|
|
196
206
|
if in_escape
|
|
197
207
|
escape_buf << ch
|
|
198
|
-
if ch.match?(
|
|
208
|
+
if ch.match?(ESCAPE_TERMINATOR_RE)
|
|
199
209
|
leading_escapes << escape_buf
|
|
200
|
-
escape_buf
|
|
210
|
+
escape_buf.clear
|
|
201
211
|
in_escape = false
|
|
202
|
-
text_start = i + 1
|
|
203
212
|
end
|
|
204
213
|
elsif ch == "\e"
|
|
205
214
|
in_escape = true
|
|
206
|
-
escape_buf
|
|
215
|
+
escape_buf.clear
|
|
216
|
+
escape_buf << ch
|
|
207
217
|
else
|
|
208
218
|
# First non-escape character, stop collecting leading escapes
|
|
209
219
|
break
|
|
@@ -219,7 +229,7 @@ module Tui
|
|
|
219
229
|
text.each_char do |ch|
|
|
220
230
|
if in_escape
|
|
221
231
|
result << ch if skipped >= chars_to_skip
|
|
222
|
-
in_escape = false if ch.match?(
|
|
232
|
+
in_escape = false if ch.match?(ESCAPE_TERMINATOR_RE)
|
|
223
233
|
next
|
|
224
234
|
end
|
|
225
235
|
|
|
@@ -375,10 +385,9 @@ module Tui
|
|
|
375
385
|
|
|
376
386
|
def flush
|
|
377
387
|
refresh_size
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
end
|
|
388
|
+
|
|
389
|
+
# Build entire frame in a single buffer to avoid flicker from partial writes
|
|
390
|
+
buf = String.new(ANSI::HOME)
|
|
382
391
|
|
|
383
392
|
cursor_row = nil
|
|
384
393
|
cursor_col = nil
|
|
@@ -390,7 +399,7 @@ module Tui
|
|
|
390
399
|
cursor_row = current_row + 1
|
|
391
400
|
cursor_col = line.cursor_column(@input_field, @width)
|
|
392
401
|
end
|
|
393
|
-
line.render(
|
|
402
|
+
line.render(buf, @width)
|
|
394
403
|
current_row += 1
|
|
395
404
|
end
|
|
396
405
|
|
|
@@ -406,7 +415,7 @@ module Tui
|
|
|
406
415
|
cursor_row = current_row + 1
|
|
407
416
|
cursor_col = line.cursor_column(@input_field, @width)
|
|
408
417
|
end
|
|
409
|
-
line.render(
|
|
418
|
+
line.render(buf, @width)
|
|
410
419
|
current_row += 1
|
|
411
420
|
body_rendered += 1
|
|
412
421
|
end
|
|
@@ -419,9 +428,9 @@ module Tui
|
|
|
419
428
|
gap.times do |i|
|
|
420
429
|
# Last gap line without newline if no footer follows
|
|
421
430
|
if i == gap - 1 && @footer.lines.empty?
|
|
422
|
-
|
|
431
|
+
buf << blank_line_no_newline
|
|
423
432
|
else
|
|
424
|
-
|
|
433
|
+
buf << blank_line
|
|
425
434
|
end
|
|
426
435
|
current_row += 1
|
|
427
436
|
end
|
|
@@ -434,23 +443,29 @@ module Tui
|
|
|
434
443
|
end
|
|
435
444
|
# Last line: don't write \n to avoid scrolling
|
|
436
445
|
if idx == footer_lines - 1
|
|
437
|
-
line.render_no_newline(
|
|
446
|
+
line.render_no_newline(buf, @width)
|
|
438
447
|
else
|
|
439
|
-
line.render(
|
|
448
|
+
line.render(buf, @width)
|
|
440
449
|
end
|
|
441
450
|
current_row += 1
|
|
442
451
|
end
|
|
443
452
|
|
|
444
453
|
# Position cursor at input field if present, otherwise hide cursor
|
|
445
454
|
if cursor_row && cursor_col && @input_field
|
|
446
|
-
|
|
447
|
-
|
|
455
|
+
buf << "\e[#{cursor_row};#{cursor_col}H"
|
|
456
|
+
buf << ANSI::SHOW
|
|
448
457
|
else
|
|
449
|
-
|
|
458
|
+
buf << ANSI::HIDE
|
|
450
459
|
end
|
|
451
460
|
|
|
452
|
-
|
|
453
|
-
|
|
461
|
+
buf << ANSI::RESET
|
|
462
|
+
|
|
463
|
+
# Single write for the entire frame - eliminates flicker
|
|
464
|
+
begin
|
|
465
|
+
@io.write(buf)
|
|
466
|
+
@io.flush
|
|
467
|
+
rescue IOError
|
|
468
|
+
end
|
|
454
469
|
ensure
|
|
455
470
|
clear
|
|
456
471
|
end
|
|
@@ -528,6 +543,16 @@ module Tui
|
|
|
528
543
|
end
|
|
529
544
|
|
|
530
545
|
def render(io, width)
|
|
546
|
+
render_line(io, width, trailing_newline: true)
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
def render_no_newline(io, width)
|
|
550
|
+
render_line(io, width, trailing_newline: false)
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
private
|
|
554
|
+
|
|
555
|
+
def render_line(io, width, trailing_newline:)
|
|
531
556
|
buffer = String.new
|
|
532
557
|
buffer << "\r"
|
|
533
558
|
buffer << ANSI::CLEAR_EOL # Clear line before rendering to remove stale content
|
|
@@ -604,84 +629,9 @@ module Tui
|
|
|
604
629
|
end
|
|
605
630
|
|
|
606
631
|
buffer << ANSI::RESET
|
|
607
|
-
buffer << "\n"
|
|
608
|
-
|
|
609
|
-
io.write(buffer)
|
|
610
|
-
end
|
|
611
|
-
|
|
612
|
-
def render_no_newline(io, width)
|
|
613
|
-
buffer = String.new
|
|
614
|
-
buffer << "\r"
|
|
615
|
-
buffer << ANSI::CLEAR_EOL
|
|
616
|
-
|
|
617
|
-
buffer << background if background && Tui.colors_enabled?
|
|
618
|
-
|
|
619
|
-
max_content = width - 1
|
|
620
|
-
content_width = [width, 1].max
|
|
621
|
-
|
|
622
|
-
left_text = @left.to_s(width: content_width)
|
|
623
|
-
center_text = @center ? @center.to_s(width: content_width) : ""
|
|
624
|
-
right_text = @right ? @right.to_s(width: content_width) : ""
|
|
625
|
-
|
|
626
|
-
# Truncate left to fit line
|
|
627
|
-
left_text = Metrics.truncate(left_text, max_content) if @truncate && !left_text.empty?
|
|
628
|
-
left_width = left_text.empty? ? 0 : Metrics.visible_width(left_text)
|
|
629
|
-
|
|
630
|
-
# Truncate center text to available space (never wrap)
|
|
631
|
-
unless center_text.empty?
|
|
632
|
-
max_center = max_content - left_width - 4
|
|
633
|
-
if max_center > 0
|
|
634
|
-
center_text = Metrics.truncate(center_text, max_center)
|
|
635
|
-
else
|
|
636
|
-
center_text = ""
|
|
637
|
-
end
|
|
638
|
-
end
|
|
639
|
-
center_width = center_text.empty? ? 0 : Metrics.visible_width(center_text)
|
|
640
|
-
|
|
641
|
-
# Calculate available space for right (need at least 1 space gap)
|
|
642
|
-
used_by_left_center = left_width + center_width + (center_width > 0 ? 2 : 0)
|
|
643
|
-
available_for_right = max_content - used_by_left_center - 1
|
|
644
|
-
|
|
645
|
-
# Truncate right from the LEFT if needed (show trailing portion)
|
|
646
|
-
right_width = 0
|
|
647
|
-
unless right_text.empty?
|
|
648
|
-
right_width = Metrics.visible_width(right_text)
|
|
649
|
-
if available_for_right <= 0
|
|
650
|
-
right_text = ""
|
|
651
|
-
right_width = 0
|
|
652
|
-
elsif right_width > available_for_right
|
|
653
|
-
right_text = Metrics.truncate_from_start(right_text, available_for_right)
|
|
654
|
-
right_width = Metrics.visible_width(right_text)
|
|
655
|
-
end
|
|
656
|
-
end
|
|
657
|
-
|
|
658
|
-
# Calculate positions
|
|
659
|
-
center_col = center_text.empty? ? 0 : [(max_content - center_width) / 2, left_width + 1].max
|
|
660
|
-
right_col = right_text.empty? ? max_content : (max_content - right_width)
|
|
661
|
-
|
|
662
|
-
buffer << left_text unless left_text.empty?
|
|
663
|
-
current_pos = left_width
|
|
664
|
-
|
|
665
|
-
unless center_text.empty?
|
|
666
|
-
gap_to_center = center_col - current_pos
|
|
667
|
-
buffer << (" " * gap_to_center) if gap_to_center > 0
|
|
668
|
-
buffer << center_text
|
|
669
|
-
current_pos = center_col + center_width
|
|
670
|
-
end
|
|
671
|
-
|
|
672
|
-
fill_end = right_text.empty? ? max_content : right_col
|
|
673
|
-
gap = fill_end - current_pos
|
|
674
|
-
buffer << (" " * gap) if gap > 0
|
|
675
|
-
|
|
676
|
-
unless right_text.empty?
|
|
677
|
-
buffer << right_text
|
|
678
|
-
buffer << ANSI::RESET_FG
|
|
679
|
-
end
|
|
680
|
-
|
|
681
|
-
buffer << ANSI::RESET
|
|
682
|
-
# No newline at end
|
|
632
|
+
buffer << "\n" if trailing_newline
|
|
683
633
|
|
|
684
|
-
io
|
|
634
|
+
io << buffer
|
|
685
635
|
end
|
|
686
636
|
end
|
|
687
637
|
|
|
@@ -792,15 +742,8 @@ module Tui
|
|
|
792
742
|
|
|
793
743
|
# Fast width calculation using precomputed emoji widths
|
|
794
744
|
def visible_width(rendered_str)
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
stripped = rendered_str.include?("\e") ? rendered_str.gsub(/\e\[[0-9;]*[A-Za-z]/, '') : rendered_str
|
|
798
|
-
stripped.length + @width_delta
|
|
799
|
-
else
|
|
800
|
-
# Pure ASCII - just string length
|
|
801
|
-
stripped = rendered_str.include?("\e") ? rendered_str.gsub(/\e\[[0-9;]*[A-Za-z]/, '') : rendered_str
|
|
802
|
-
stripped.length
|
|
803
|
-
end
|
|
745
|
+
stripped = rendered_str.include?("\e") ? rendered_str.gsub(ANSI_STRIP_RE, '') : rendered_str
|
|
746
|
+
@has_wide ? stripped.length + @width_delta : stripped.length
|
|
804
747
|
end
|
|
805
748
|
|
|
806
749
|
def empty?
|
data/try.rb
CHANGED
|
@@ -3,12 +3,18 @@
|
|
|
3
3
|
require 'io/console'
|
|
4
4
|
require 'time'
|
|
5
5
|
require 'fileutils'
|
|
6
|
+
require 'set'
|
|
6
7
|
require_relative 'lib/tui'
|
|
7
8
|
require_relative 'lib/fuzzy'
|
|
8
9
|
|
|
9
10
|
class TrySelector
|
|
10
11
|
include Tui::Helpers
|
|
11
12
|
TRY_PATH = ENV['TRY_PATH'] || File.expand_path("~/src/tries")
|
|
13
|
+
TRY_PROJECTS = ENV['TRY_PROJECTS']
|
|
14
|
+
|
|
15
|
+
# Precompiled regex constants
|
|
16
|
+
INPUT_CHAR_RE = /[a-zA-Z0-9\-\_\. ]/
|
|
17
|
+
WORD_CHAR_RE = /[a-zA-Z0-9]/
|
|
12
18
|
|
|
13
19
|
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
20
|
@search_term = search_term.gsub(/\s+/, '-')
|
|
@@ -68,10 +74,7 @@ class TrySelector
|
|
|
68
74
|
def setup_terminal
|
|
69
75
|
unless @test_no_cls
|
|
70
76
|
# 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)
|
|
77
|
+
STDERR.print("#{Tui::ANSI::ALT_SCREEN_ON}#{Tui::ANSI.set_title("try")}#{Tui::ANSI::CURSOR_BLINK}")
|
|
75
78
|
end
|
|
76
79
|
|
|
77
80
|
@old_winch_handler = Signal.trap('WINCH') { @needs_redraw = true }
|
|
@@ -98,7 +101,11 @@ class TrySelector
|
|
|
98
101
|
next if entry.start_with?('.')
|
|
99
102
|
|
|
100
103
|
path = File.join(@base_path, entry)
|
|
101
|
-
|
|
104
|
+
begin
|
|
105
|
+
stat = File.stat(path)
|
|
106
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
107
|
+
next
|
|
108
|
+
end
|
|
102
109
|
|
|
103
110
|
# Only include directories
|
|
104
111
|
next unless stat.directory?
|
|
@@ -111,11 +118,14 @@ class TrySelector
|
|
|
111
118
|
# Bonus for date-prefixed directories
|
|
112
119
|
base_score += 2.0 if entry.match?(/^\d{4}-\d{2}-\d{2}-/)
|
|
113
120
|
|
|
121
|
+
is_symlink = File.symlink?(path)
|
|
122
|
+
|
|
114
123
|
tries << {
|
|
115
124
|
text: entry,
|
|
116
125
|
basename: entry,
|
|
117
|
-
path: path,
|
|
126
|
+
path: is_symlink ? File.realpath(path) : path,
|
|
118
127
|
is_new: false,
|
|
128
|
+
is_symlink: is_symlink,
|
|
119
129
|
ctime: stat.ctime,
|
|
120
130
|
mtime: mtime,
|
|
121
131
|
base_score: base_score
|
|
@@ -148,11 +158,19 @@ class TrySelector
|
|
|
148
158
|
load_all_tries
|
|
149
159
|
@fuzzy ||= Fuzzy.new(@all_tries)
|
|
150
160
|
|
|
161
|
+
# Cache results - only re-match when query changes
|
|
162
|
+
if @last_query == @input_buffer && @cached_results
|
|
163
|
+
return @cached_results
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
@last_query = @input_buffer
|
|
167
|
+
height = IO.console&.winsize&.first || 24
|
|
168
|
+
max_results = [height - 6, 3].max
|
|
151
169
|
results = []
|
|
152
|
-
@fuzzy.match(@input_buffer).each do |entry, positions, score|
|
|
170
|
+
@fuzzy.match(@input_buffer).limit(max_results).each do |entry, positions, score|
|
|
153
171
|
results << TryEntry.new(entry, score, positions)
|
|
154
172
|
end
|
|
155
|
-
results
|
|
173
|
+
@cached_results = results
|
|
156
174
|
end
|
|
157
175
|
|
|
158
176
|
def main_loop
|
|
@@ -192,9 +210,9 @@ class TrySelector
|
|
|
192
210
|
# Do nothing
|
|
193
211
|
when "\e[D" # Left arrow - ignore
|
|
194
212
|
# Do nothing
|
|
195
|
-
when "\x7F", "\b" # Backspace
|
|
213
|
+
when "\x7F", "\b" # Backspace (DEL and BS)
|
|
196
214
|
if @input_cursor_pos > 0
|
|
197
|
-
@input_buffer = @input_buffer[0...(@input_cursor_pos-1)] + @input_buffer[@input_cursor_pos
|
|
215
|
+
@input_buffer = @input_buffer[0...(@input_cursor_pos-1)] + @input_buffer[@input_cursor_pos..]
|
|
198
216
|
@input_cursor_pos -= 1
|
|
199
217
|
end
|
|
200
218
|
@cursor_pos = 0 # Reset list selection when typing
|
|
@@ -206,32 +224,12 @@ class TrySelector
|
|
|
206
224
|
@input_cursor_pos = [@input_cursor_pos - 1, 0].max
|
|
207
225
|
when "\x06" # Ctrl-F - forward char
|
|
208
226
|
@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
227
|
when "\x0B" # Ctrl-K - kill to end of line
|
|
216
228
|
@input_buffer = @input_buffer[0...@input_cursor_pos]
|
|
217
229
|
when "\x17" # Ctrl-W - delete word backward (alphanumeric)
|
|
218
230
|
if @input_cursor_pos > 0
|
|
219
|
-
|
|
220
|
-
|
|
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]
|
|
231
|
+
new_pos = word_boundary_backward(@input_buffer, @input_cursor_pos)
|
|
232
|
+
@input_buffer = @input_buffer[0...new_pos] + @input_buffer[@input_cursor_pos..]
|
|
235
233
|
@input_cursor_pos = new_pos
|
|
236
234
|
end
|
|
237
235
|
when "\x04" # Ctrl-D - toggle mark for deletion
|
|
@@ -254,6 +252,11 @@ class TrySelector
|
|
|
254
252
|
run_rename_dialog(tries[@cursor_pos])
|
|
255
253
|
break if @selected
|
|
256
254
|
end
|
|
255
|
+
when "\x07" # Ctrl-G - graduate/ascend selected entry
|
|
256
|
+
if @cursor_pos < tries.length
|
|
257
|
+
run_ascend_dialog(tries[@cursor_pos])
|
|
258
|
+
break if @selected
|
|
259
|
+
end
|
|
257
260
|
when "\x03", "\e" # Ctrl-C or ESC
|
|
258
261
|
if @delete_mode
|
|
259
262
|
# Exit delete mode, clear marks
|
|
@@ -265,8 +268,8 @@ class TrySelector
|
|
|
265
268
|
end
|
|
266
269
|
when String
|
|
267
270
|
# Only accept printable characters, not escape sequences
|
|
268
|
-
if key.length == 1 && key
|
|
269
|
-
@input_buffer = @input_buffer[0...@input_cursor_pos] + key + @input_buffer[@input_cursor_pos
|
|
271
|
+
if key.length == 1 && key.match?(INPUT_CHAR_RE)
|
|
272
|
+
@input_buffer = @input_buffer[0...@input_cursor_pos] + key + @input_buffer[@input_cursor_pos..]
|
|
270
273
|
@input_cursor_pos += 1
|
|
271
274
|
@cursor_pos = 0 # Reset list selection when typing
|
|
272
275
|
end
|
|
@@ -300,8 +303,12 @@ class TrySelector
|
|
|
300
303
|
return nil if input.nil?
|
|
301
304
|
|
|
302
305
|
if input == "\e"
|
|
303
|
-
|
|
304
|
-
|
|
306
|
+
begin
|
|
307
|
+
input << STDIN.read_nonblock(3)
|
|
308
|
+
input << STDIN.read_nonblock(2)
|
|
309
|
+
rescue IO::WaitReadable, EOFError
|
|
310
|
+
# No more escape sequence data available
|
|
311
|
+
end
|
|
305
312
|
end
|
|
306
313
|
|
|
307
314
|
input
|
|
@@ -346,7 +353,7 @@ class TrySelector
|
|
|
346
353
|
end
|
|
347
354
|
else
|
|
348
355
|
screen.footer.add_line do |line|
|
|
349
|
-
line.center.write_dim("↑/↓: Navigate Enter: Select ^R: Rename ^D: Delete Esc: Cancel")
|
|
356
|
+
line.center.write_dim("↑/↓: Navigate Enter: Select ^R: Rename ^G: Graduate ^D: Delete Esc: Cancel")
|
|
350
357
|
end
|
|
351
358
|
end
|
|
352
359
|
|
|
@@ -391,7 +398,14 @@ class TrySelector
|
|
|
391
398
|
|
|
392
399
|
line = screen.body.add_line(background: background)
|
|
393
400
|
line.write << (is_selected ? Tui::Text.highlight("→ ") : " ")
|
|
394
|
-
|
|
401
|
+
icon = if is_marked
|
|
402
|
+
emoji("🗑️")
|
|
403
|
+
elsif entry[:is_symlink]
|
|
404
|
+
emoji("🔗")
|
|
405
|
+
else
|
|
406
|
+
emoji("📁")
|
|
407
|
+
end
|
|
408
|
+
line.write << icon << " "
|
|
395
409
|
|
|
396
410
|
plain_name, rendered_name = formatted_entry_name(entry)
|
|
397
411
|
prefix_width = 5
|
|
@@ -444,17 +458,34 @@ class TrySelector
|
|
|
444
458
|
end
|
|
445
459
|
|
|
446
460
|
def highlight_with_positions(text, positions, offset)
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
461
|
+
pos_set = positions.is_a?(Set) ? positions : positions.to_set
|
|
462
|
+
result = String.new
|
|
463
|
+
chars = text.chars
|
|
464
|
+
i = 0
|
|
465
|
+
while i < chars.length
|
|
466
|
+
if pos_set.include?(i + offset)
|
|
467
|
+
# Batch consecutive highlighted characters
|
|
468
|
+
batch_start = i
|
|
469
|
+
i += 1
|
|
470
|
+
i += 1 while i < chars.length && pos_set.include?(i + offset)
|
|
471
|
+
result << Tui::Text.highlight(chars[batch_start...i].join)
|
|
451
472
|
else
|
|
452
|
-
result
|
|
473
|
+
result << chars[i]
|
|
474
|
+
i += 1
|
|
453
475
|
end
|
|
454
476
|
end
|
|
455
477
|
result
|
|
456
478
|
end
|
|
457
479
|
|
|
480
|
+
# Find the position of the previous word boundary for Ctrl-W deletion.
|
|
481
|
+
# Skips non-alphanumeric chars, then skips alphanumeric chars.
|
|
482
|
+
def word_boundary_backward(buffer, cursor)
|
|
483
|
+
pos = cursor - 1
|
|
484
|
+
pos -= 1 while pos >= 0 && !buffer[pos].match?(WORD_CHAR_RE)
|
|
485
|
+
pos -= 1 while pos >= 0 && buffer[pos].match?(WORD_CHAR_RE)
|
|
486
|
+
pos + 1
|
|
487
|
+
end
|
|
488
|
+
|
|
458
489
|
def format_relative_time(time)
|
|
459
490
|
return "?" unless time
|
|
460
491
|
|
|
@@ -542,11 +573,9 @@ class TrySelector
|
|
|
542
573
|
rename_error = nil
|
|
543
574
|
when "\x17" # Ctrl-W - delete word backward
|
|
544
575
|
if rename_cursor > 0
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
rename_buffer = rename_buffer[0...pos] + rename_buffer[rename_cursor..].to_s
|
|
549
|
-
rename_cursor = pos
|
|
576
|
+
new_pos = word_boundary_backward(rename_buffer, rename_cursor)
|
|
577
|
+
rename_buffer = rename_buffer[0...new_pos] + rename_buffer[rename_cursor..].to_s
|
|
578
|
+
rename_cursor = new_pos
|
|
550
579
|
end
|
|
551
580
|
rename_error = nil
|
|
552
581
|
when String
|
|
@@ -612,6 +641,142 @@ class TrySelector
|
|
|
612
641
|
true
|
|
613
642
|
end
|
|
614
643
|
|
|
644
|
+
# Ascend dialog - promote a try to a permanent project directory
|
|
645
|
+
def run_ascend_dialog(entry)
|
|
646
|
+
@delete_mode = false
|
|
647
|
+
@marked_for_deletion.clear
|
|
648
|
+
|
|
649
|
+
current_name = entry[:basename]
|
|
650
|
+
|
|
651
|
+
# Strip date prefix for the default project name
|
|
652
|
+
project_name = current_name.sub(/^\d{4}-\d{2}-\d{2}-/, '')
|
|
653
|
+
|
|
654
|
+
# Compute default destination directory
|
|
655
|
+
projects_dir = if TRY_PROJECTS
|
|
656
|
+
File.expand_path(TRY_PROJECTS)
|
|
657
|
+
else
|
|
658
|
+
File.dirname(@base_path)
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
ascend_buffer = File.join(projects_dir, project_name)
|
|
662
|
+
ascend_cursor = ascend_buffer.length
|
|
663
|
+
ascend_error = nil
|
|
664
|
+
|
|
665
|
+
loop do
|
|
666
|
+
render_ascend_dialog(current_name, ascend_buffer, ascend_cursor, ascend_error, projects_dir)
|
|
667
|
+
|
|
668
|
+
ch = read_key
|
|
669
|
+
case ch
|
|
670
|
+
when "\r" # Enter - confirm
|
|
671
|
+
result = finalize_ascend(entry, ascend_buffer)
|
|
672
|
+
if result == true
|
|
673
|
+
break
|
|
674
|
+
else
|
|
675
|
+
ascend_error = result
|
|
676
|
+
end
|
|
677
|
+
when "\e", "\x03" # ESC or Ctrl-C - cancel
|
|
678
|
+
break
|
|
679
|
+
when "\x7F", "\b" # Backspace
|
|
680
|
+
if ascend_cursor > 0
|
|
681
|
+
ascend_buffer = ascend_buffer[0...(ascend_cursor - 1)] + ascend_buffer[ascend_cursor..].to_s
|
|
682
|
+
ascend_cursor -= 1
|
|
683
|
+
end
|
|
684
|
+
ascend_error = nil
|
|
685
|
+
when "\x01" # Ctrl-A - start of line
|
|
686
|
+
ascend_cursor = 0
|
|
687
|
+
when "\x05" # Ctrl-E - end of line
|
|
688
|
+
ascend_cursor = ascend_buffer.length
|
|
689
|
+
when "\x02" # Ctrl-B - back one char
|
|
690
|
+
ascend_cursor = [ascend_cursor - 1, 0].max
|
|
691
|
+
when "\x06" # Ctrl-F - forward one char
|
|
692
|
+
ascend_cursor = [ascend_cursor + 1, ascend_buffer.length].min
|
|
693
|
+
when "\x0B" # Ctrl-K - kill to end
|
|
694
|
+
ascend_buffer = ascend_buffer[0...ascend_cursor]
|
|
695
|
+
ascend_error = nil
|
|
696
|
+
when "\x17" # Ctrl-W - delete word backward
|
|
697
|
+
if ascend_cursor > 0
|
|
698
|
+
new_pos = word_boundary_backward(ascend_buffer, ascend_cursor)
|
|
699
|
+
ascend_buffer = ascend_buffer[0...new_pos] + ascend_buffer[ascend_cursor..].to_s
|
|
700
|
+
ascend_cursor = new_pos
|
|
701
|
+
end
|
|
702
|
+
ascend_error = nil
|
|
703
|
+
when String
|
|
704
|
+
if ch.length == 1 && ch =~ /[a-zA-Z0-9\-_\.\s\/~]/
|
|
705
|
+
ascend_buffer = ascend_buffer[0...ascend_cursor] + ch + ascend_buffer[ascend_cursor..].to_s
|
|
706
|
+
ascend_cursor += 1
|
|
707
|
+
ascend_error = nil
|
|
708
|
+
end
|
|
709
|
+
end
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
@needs_redraw = true
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
def render_ascend_dialog(current_name, ascend_buffer, ascend_cursor, ascend_error, projects_dir)
|
|
716
|
+
screen = Tui::Screen.new(io: STDERR)
|
|
717
|
+
|
|
718
|
+
screen.header.add_line do |line|
|
|
719
|
+
line.center << emoji("🚀") << Tui::Text.accent(" Graduate try to project")
|
|
720
|
+
end
|
|
721
|
+
screen.header.add_line { |line| line.write.write_dim(fill("─")) }
|
|
722
|
+
|
|
723
|
+
screen.body.add_line do |line|
|
|
724
|
+
line.write << emoji("📁") << " #{current_name}"
|
|
725
|
+
end
|
|
726
|
+
screen.body.add_line
|
|
727
|
+
|
|
728
|
+
env_hint = TRY_PROJECTS ? "$TRY_PROJECTS" : "parent of $TRY_PATH"
|
|
729
|
+
screen.body.add_line do |line|
|
|
730
|
+
line.center.write_dim("Destination (#{env_hint}: #{projects_dir})")
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
screen.body.add_line do |line|
|
|
734
|
+
prefix = "Move to: "
|
|
735
|
+
line.center.write_dim(prefix)
|
|
736
|
+
line.center << screen.input("", value: ascend_buffer, cursor: ascend_cursor).to_s
|
|
737
|
+
input_width = [ascend_buffer.length, ascend_cursor + 1].max
|
|
738
|
+
prefix_width = Tui::Metrics.visible_width(prefix)
|
|
739
|
+
max_content = screen.width - 1
|
|
740
|
+
center_start = (max_content - prefix_width - input_width) / 2
|
|
741
|
+
line.mark_has_input(center_start + prefix_width)
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
screen.body.add_line
|
|
745
|
+
screen.body.add_line do |line|
|
|
746
|
+
line.center.write_dim("A symlink will be left in the tries directory")
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
if ascend_error
|
|
750
|
+
screen.body.add_line
|
|
751
|
+
screen.body.add_line { |line| line.center.write_bold(ascend_error) }
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
screen.footer.add_line { |line| line.write.write_dim(fill("─")) }
|
|
755
|
+
screen.footer.add_line { |line| line.center.write_dim("Enter: Confirm Esc: Cancel") }
|
|
756
|
+
|
|
757
|
+
screen.flush
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
def finalize_ascend(entry, ascend_buffer)
|
|
761
|
+
dest = ascend_buffer.strip
|
|
762
|
+
dest = File.expand_path(dest)
|
|
763
|
+
|
|
764
|
+
return "Destination cannot be empty" if dest.empty?
|
|
765
|
+
return "Destination already exists: #{dest}" if File.exist?(dest)
|
|
766
|
+
|
|
767
|
+
parent = File.dirname(dest)
|
|
768
|
+
return "Parent directory does not exist: #{parent}" unless Dir.exist?(parent)
|
|
769
|
+
|
|
770
|
+
@selected = {
|
|
771
|
+
type: :ascend,
|
|
772
|
+
source: entry[:path],
|
|
773
|
+
dest: dest,
|
|
774
|
+
basename: entry[:basename],
|
|
775
|
+
base_path: @base_path
|
|
776
|
+
}
|
|
777
|
+
true
|
|
778
|
+
end
|
|
779
|
+
|
|
615
780
|
def handle_selection(try_dir)
|
|
616
781
|
# Select existing try directory
|
|
617
782
|
@selected = { type: :cd, path: try_dir[:path] }
|
|
@@ -772,6 +937,8 @@ class TrySelector
|
|
|
772
937
|
@delete_status = "Deleted: #{names}"
|
|
773
938
|
@all_tries = nil # Clear cache
|
|
774
939
|
@fuzzy = nil
|
|
940
|
+
@cached_results = nil
|
|
941
|
+
@last_query = nil
|
|
775
942
|
@marked_for_deletion.clear
|
|
776
943
|
@delete_mode = false
|
|
777
944
|
rescue => e
|
|
@@ -788,7 +955,7 @@ end
|
|
|
788
955
|
# Main execution with OptionParser subcommands
|
|
789
956
|
if __FILE__ == $0
|
|
790
957
|
|
|
791
|
-
VERSION = "1.
|
|
958
|
+
VERSION = "1.9.2"
|
|
792
959
|
|
|
793
960
|
def print_global_help
|
|
794
961
|
text = <<~HELP
|
|
@@ -822,11 +989,20 @@ if __FILE__ == $0
|
|
|
822
989
|
Manual mode (without alias):
|
|
823
990
|
try exec [query] Output shell script to eval
|
|
824
991
|
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
992
|
+
Environment:
|
|
993
|
+
TRY_PATH Tries directory (default: ~/src/tries)
|
|
994
|
+
TRY_PROJECTS Graduate destination (default: parent of TRY_PATH)
|
|
995
|
+
|
|
996
|
+
Keyboard:
|
|
997
|
+
↑/↓, Ctrl-P/N Navigate
|
|
998
|
+
Enter Select / Create new
|
|
999
|
+
Ctrl-R Rename
|
|
1000
|
+
Ctrl-G Graduate (promote try to project)
|
|
1001
|
+
Ctrl-D Mark for deletion
|
|
1002
|
+
Ctrl-T Create new try
|
|
1003
|
+
Esc Cancel
|
|
828
1004
|
HELP
|
|
829
|
-
|
|
1005
|
+
STDERR.print(text)
|
|
830
1006
|
end
|
|
831
1007
|
|
|
832
1008
|
# Process color-related flags early
|
|
@@ -844,7 +1020,7 @@ if __FILE__ == $0
|
|
|
844
1020
|
|
|
845
1021
|
# Version flag
|
|
846
1022
|
if ARGV.include?("--version") || ARGV.include?("-v")
|
|
847
|
-
puts "try #{VERSION}"
|
|
1023
|
+
STDERR.puts "try #{VERSION}"
|
|
848
1024
|
exit 0
|
|
849
1025
|
end
|
|
850
1026
|
|
|
@@ -940,6 +1116,7 @@ if __FILE__ == $0
|
|
|
940
1116
|
when 'CTRL-D', 'CTRLD' then keys << "\x04"
|
|
941
1117
|
when 'CTRL-E', 'CTRLE' then keys << "\x05"
|
|
942
1118
|
when 'CTRL-F', 'CTRLF' then keys << "\x06"
|
|
1119
|
+
when 'CTRL-G', 'CTRLG' then keys << "\x07"
|
|
943
1120
|
when 'CTRL-H', 'CTRLH' then keys << "\x08"
|
|
944
1121
|
when 'CTRL-K', 'CTRLK' then keys << "\x0B"
|
|
945
1122
|
when 'CTRL-N', 'CTRLN' then keys << "\x0E"
|
|
@@ -947,8 +1124,8 @@ if __FILE__ == $0
|
|
|
947
1124
|
when 'CTRL-R', 'CTRLR' then keys << "\x12"
|
|
948
1125
|
when 'CTRL-T', 'CTRLT' then keys << "\x14"
|
|
949
1126
|
when 'CTRL-W', 'CTRLW' then keys << "\x17"
|
|
950
|
-
when /^TYPE
|
|
951
|
-
|
|
1127
|
+
when /^TYPE=/i
|
|
1128
|
+
tok.sub(/^TYPE=/i, '').each_char { |ch| keys << ch }
|
|
952
1129
|
else
|
|
953
1130
|
keys << tok if tok.length == 1
|
|
954
1131
|
end
|
|
@@ -995,11 +1172,13 @@ if __FILE__ == $0
|
|
|
995
1172
|
def cmd_init!(args, tries_path)
|
|
996
1173
|
script_path = File.expand_path($0)
|
|
997
1174
|
|
|
998
|
-
if args[0] && args[0].start_with?('/')
|
|
999
|
-
|
|
1175
|
+
explicit_path = if args[0] && args[0].start_with?('/')
|
|
1176
|
+
File.expand_path(args.shift)
|
|
1000
1177
|
end
|
|
1001
1178
|
|
|
1002
|
-
|
|
1179
|
+
# Priority: explicit init argument > $TRY_PATH (runtime) > default
|
|
1180
|
+
default_path = tries_path || File.expand_path("~/src/tries")
|
|
1181
|
+
path_arg = explicit_path ? " --path '#{explicit_path}'" : " --path \"${TRY_PATH:-#{default_path}}\""
|
|
1003
1182
|
bash_or_zsh_script = <<~SHELL
|
|
1004
1183
|
try() {
|
|
1005
1184
|
local out
|
|
@@ -1012,10 +1191,11 @@ if __FILE__ == $0
|
|
|
1012
1191
|
}
|
|
1013
1192
|
SHELL
|
|
1014
1193
|
|
|
1194
|
+
fish_path_arg = explicit_path ? " --path '#{explicit_path}'" : " --path (if set -q TRY_PATH; echo \"$TRY_PATH\"; else; echo '#{default_path}'; end)"
|
|
1015
1195
|
fish_script = <<~SHELL
|
|
1016
1196
|
function try
|
|
1017
|
-
set -l out (/usr/bin/env ruby '#{script_path}' exec#{
|
|
1018
|
-
if test $
|
|
1197
|
+
set -l out (/usr/bin/env ruby '#{script_path}' exec#{fish_path_arg} $argv 2>/dev/tty | string collect)
|
|
1198
|
+
if test $pipestatus[1] -eq 0
|
|
1019
1199
|
eval $out
|
|
1020
1200
|
else
|
|
1021
1201
|
echo $out
|
|
@@ -1093,6 +1273,8 @@ if __FILE__ == $0
|
|
|
1093
1273
|
script_mkdir_cd(result[:path])
|
|
1094
1274
|
when :rename
|
|
1095
1275
|
script_rename(result[:base_path], result[:old], result[:new])
|
|
1276
|
+
when :ascend
|
|
1277
|
+
script_ascend(result[:source], result[:dest], result[:basename], result[:base_path])
|
|
1096
1278
|
else
|
|
1097
1279
|
script_cd(result[:path])
|
|
1098
1280
|
end
|
|
@@ -1147,10 +1329,28 @@ if __FILE__ == $0
|
|
|
1147
1329
|
def script_delete(paths, base_path)
|
|
1148
1330
|
cmds = ["cd #{q(base_path)}"]
|
|
1149
1331
|
paths.each { |item| cmds << "test -d #{q(item[:basename])} && rm -rf #{q(item[:basename])}" }
|
|
1150
|
-
cmds << "
|
|
1332
|
+
cmds << "cd #{q(Dir.pwd)} 2>/dev/null || cd #{q(base_path)}"
|
|
1151
1333
|
cmds
|
|
1152
1334
|
end
|
|
1153
1335
|
|
|
1336
|
+
def script_ascend(source, dest, basename, base_path)
|
|
1337
|
+
symlink_path = File.join(base_path, basename)
|
|
1338
|
+
# Check if source is a git worktree (has .git file, not directory)
|
|
1339
|
+
git_file = File.join(source, '.git')
|
|
1340
|
+
is_worktree = File.file?(git_file)
|
|
1341
|
+
|
|
1342
|
+
cmds = []
|
|
1343
|
+
if is_worktree
|
|
1344
|
+
# Use git worktree move for proper bookkeeping
|
|
1345
|
+
cmds << "git worktree move #{q(source)} #{q(dest)}"
|
|
1346
|
+
else
|
|
1347
|
+
cmds << "mv #{q(source)} #{q(dest)}"
|
|
1348
|
+
end
|
|
1349
|
+
cmds << "ln -s #{q(dest)} #{q(symlink_path)}"
|
|
1350
|
+
cmds << "echo #{q("Graduated: #{basename} → #{dest}")}"
|
|
1351
|
+
cmds + script_cd(dest)
|
|
1352
|
+
end
|
|
1353
|
+
|
|
1154
1354
|
def script_rename(base_path, old_name, new_name)
|
|
1155
1355
|
new_path = File.join(base_path, new_name)
|
|
1156
1356
|
[
|
metadata
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: try-cli
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.9.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tobi Lutke
|
|
8
|
+
autorequire:
|
|
8
9
|
bindir: bin
|
|
9
10
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
11
|
+
date: 2026-03-10 00:00:00.000000000 Z
|
|
11
12
|
dependencies: []
|
|
12
13
|
description: A CLI tool for managing experimental projects. Creates dated directories
|
|
13
14
|
for your tries, with fuzzy search and easy navigation.
|
|
@@ -25,13 +26,15 @@ files:
|
|
|
25
26
|
- lib/fuzzy.rb
|
|
26
27
|
- lib/tui.rb
|
|
27
28
|
- try.rb
|
|
28
|
-
homepage: https://
|
|
29
|
+
homepage: https://pages.tobi.lutke.com/try/
|
|
29
30
|
licenses:
|
|
30
31
|
- MIT
|
|
31
32
|
metadata:
|
|
32
|
-
homepage_uri: https://
|
|
33
|
+
homepage_uri: https://pages.tobi.lutke.com/try/
|
|
33
34
|
source_code_uri: https://github.com/tobi/try
|
|
35
|
+
documentation_uri: https://pages.tobi.lutke.com/try/
|
|
34
36
|
changelog_uri: https://github.com/tobi/try/releases
|
|
37
|
+
post_install_message:
|
|
35
38
|
rdoc_options: []
|
|
36
39
|
require_paths:
|
|
37
40
|
- lib
|
|
@@ -47,7 +50,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
47
50
|
- !ruby/object:Gem::Version
|
|
48
51
|
version: '0'
|
|
49
52
|
requirements: []
|
|
50
|
-
rubygems_version: 3.
|
|
53
|
+
rubygems_version: 3.5.22
|
|
54
|
+
signing_key:
|
|
51
55
|
specification_version: 4
|
|
52
56
|
summary: Experiments deserve a home
|
|
53
57
|
test_files: []
|