From b031dc2e03916afafab842744278a702c46f9049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20L=C3=BCtke?= Date: Thu, 13 Aug 2026 19:09:02 +0000 Subject: [PATCH] feat: compile try to a native binary with Spinel --- .gitignore | 1 + AGENTS.md | 1 + Makefile | 16 ++- README.md | 14 +++ lib/fuzzy.rb | 25 +++- lib/tui.rb | 74 ++++++------ try.rb | 316 +++++++++++++++++++++++++++++++++------------------ 7 files changed, 300 insertions(+), 147 deletions(-) diff --git a/.gitignore b/.gitignore index c9d482a..cfc1d0b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ *.callgrind* *.gem .claude/settings.local.json +dist/ diff --git a/AGENTS.md b/AGENTS.md index edb1e50..887df17 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,7 @@ ## Testing Guidelines - Primary testing is via the spec system: `./spec/tests/runner.sh ./try.rb` +- Native binary (optional, Spinel): `make native-test` / `bash spec/tests/runner.sh dist/try` - Manual flows for exploratory testing: - `TRY_PATH=$(mktemp -d) ./try.rb cd` then create/select directories. - Validate delete confirmation and scoring by changing `mtime`/`ctime`. diff --git a/Makefile b/Makefile index 0abbc2b..864ce9f 100644 --- a/Makefile +++ b/Makefile @@ -115,4 +115,18 @@ t: test ## Shortcut for test l: lint ## Shortcut for lint .PHONY: i -i: install ## Shortcut for install \ No newline at end of file +i: install ## Shortcut for install + +# Native binary via Spinel (optional) +SPINEL ?= spinel +NATIVE = dist/try + +.PHONY: native native-test +native: $(NATIVE) + +$(NATIVE): try.rb lib/tui.rb lib/fuzzy.rb + mkdir -p dist + $(SPINEL) try.rb -o $(NATIVE) + +native-test: $(NATIVE) + bash spec/tests/runner.sh $(NATIVE) diff --git a/README.md b/README.md index 8ccda2e..aa06a44 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,20 @@ echo 'eval "$(ruby ~/.local/try.rb init ~/src/tries)"' >> ~/.zshrc echo '~/.local/try.rb init ~/src/tries | source' >> ~/.config/fish/config.fish ``` + +### Native binary (optional) + +Compile a native `try` with [Spinel](https://github.com/matz/spinel). Build Spinel from source; [PR 3906](https://github.com/matz/spinel/pull/3906) is required so `IO#tty?` / `#winsize` work on handles that are not statically typed IO. + +```bash +make native SPINEL=/path/to/spinel +./dist/try --help +eval "$(./dist/try init)" # wires the shell function to the binary, not MRI +make native-test SPINEL=/path/to/spinel +``` + +MRI `ruby try.rb` and the gem keep working. `dist/try init` emits the binary path; `ruby try.rb init` still emits `/usr/bin/env ruby '…/try.rb'`. + ## The Problem You're learning Redis. You create `/tmp/redis-test`. Then `~/Desktop/redis-actually`. Then `~/projects/testing-redis-again`. Three weeks later you can't find that brilliant connection pooling solution you wrote at 2am. diff --git a/lib/fuzzy.rb b/lib/fuzzy.rb index 05bad98..2e26248 100644 --- a/lib/fuzzy.rb +++ b/lib/fuzzy.rb @@ -57,7 +57,7 @@ def limit(n) # Iterate over matches: yields (entry_data, highlight_positions, score) def each(&block) - return enum_for(:each) unless block_given? + return enum_for(:each) unless block results = [] @@ -68,11 +68,16 @@ def each(&block) results << [entry.data, positions, score] end + # Spinel has no Array#max_by(n); full sort is fine at try-directory scale. + results.sort_by! { |_, _, score| -score } if @limit && @limit < results.length - # Partial sort: O(n log k) via heap selection instead of full O(n log n) sort - results = results.max_by(@limit) { |_, _, score| score } - else - results.sort_by! { |_, _, score| -score } + limited = [] + i = 0 + while i < @limit + limited << results[i] + i += 1 + end + results = limited end results.each(&block) @@ -102,7 +107,15 @@ def calculate_match(entry) query_chars.each do |qc| # Find next occurrence of query char starting from pos - found = text.index(qc, pos) + found = nil + i = pos + while i < text.length + if text[i] == qc + found = i + break + end + i += 1 + end return nil unless found # No match positions << found diff --git a/lib/tui.rb b/lib/tui.rb index 9b95583..3cec6d2 100644 --- a/lib/tui.rb +++ b/lib/tui.rb @@ -7,14 +7,16 @@ # Usage pattern: # include Tui::Helpers # screen = Tui::Screen.new -# screen.header.add_line { |line| line.write << Tui::Text.bold("πŸ“ Try Selector") } +# line = screen.header.add_line +# line.write << Tui::Text.bold("πŸ“ Try Selector") # search_line = screen.body.add_line # search_line.write_dim("Search:").write(" ") # search_line.write << screen.input("Type to filter…", value: query, cursor: cursor) # list_line = screen.body.add_line(background: Tui::Palette::SELECTED_BG) # list_line.write << Tui::Text.highlight("β†’ ") << name # list_line.right.write_dim(metadata) -# screen.footer.add_line { |line| line.write_dim("↑↓ navigate Enter select Esc cancel") } +# line = screen.footer.add_line +# line.write_dim("↑↓ navigate Enter select Esc cancel") # screen.flush # # The screen owns a single InputField (enforced by #input). Lines support @@ -124,8 +126,8 @@ def visible_width(text) # Slow path: calculate width per codepoint (avoids each_char + ord) width = 0 - stripped.each_codepoint do |code| - width += char_width(code) + stripped.each_char do |ch| + width += char_width(ch.ord) end width end @@ -322,24 +324,17 @@ def size(io = $stderr) begin s_rows, s_cols = stream.winsize - rows ||= s_rows - cols ||= s_cols + # Spinel returns [0,0] for non-ttys (no exception). 0 is truthy in + # Ruby, so treat non-positive sizes as missing. + rows ||= s_rows if s_rows.to_i > 0 + cols ||= s_cols if s_cols.to_i > 0 rescue IOError, Errno::ENOTTY, Errno::EOPNOTSUPP, Errno::ENODEV next end end - if (!rows || !cols) - begin - console = IO.console - if console - c_rows, c_cols = console.winsize - rows ||= c_rows - cols ||= c_cols - end - rescue IOError, Errno::ENOTTY, Errno::EOPNOTSUPP, Errno::ENODEV - end - end + # IO.console is not available under Spinel; STDERR/STDIN#winsize + # (io/console) already covers the TTY case above. rows ||= 24 cols ||= 80 @@ -400,7 +395,7 @@ def flush cursor_row = current_row + 1 cursor_col = line.cursor_column(@input_field, @width) end - line.render(buf, @width) + buf << line.render(nil, @width) current_row += 1 end @@ -416,7 +411,7 @@ def flush cursor_row = current_row + 1 cursor_col = line.cursor_column(@input_field, @width) end - line.render(buf, @width) + buf << line.render(nil, @width) current_row += 1 body_rendered += 1 end @@ -426,7 +421,8 @@ def flush gap = body_space - body_rendered blank_line = "\r#{ANSI::CLEAR_EOL}#{' ' * (@width - 1)}\n" blank_line_no_newline = "\r#{ANSI::CLEAR_EOL}#{' ' * (@width - 1)}" - gap.times do |i| + i = 0 + while i < gap # Last gap line without newline if no footer follows if i == gap - 1 && @footer.lines.empty? buf << blank_line_no_newline @@ -434,6 +430,7 @@ def flush buf << blank_line end current_row += 1 + i += 1 end # Render footer at the bottom (sticky) @@ -444,9 +441,9 @@ def flush end # Last line: don't write \n to avoid scrolling if idx == footer_lines - 1 - line.render_no_newline(buf, @width) + buf << line.render_no_newline(nil, @width) else - line.render(buf, @width) + buf << line.render(nil, @width) end current_row += 1 end @@ -480,7 +477,7 @@ def initialize(screen) @lines = [] end - def add_line(background: nil, truncate: true) + def add_line(background = nil, truncate = true) line = Line.new(@screen, background: background, truncate: truncate) @lines << line yield line if block_given? @@ -490,7 +487,7 @@ def add_line(background: nil, truncate: true) def divider(char: '─') add_line do |line| span = [@screen.width - 1, 1].max - line.write << char * span + line.write.write(char * span) end end @@ -632,7 +629,8 @@ def render_line(io, width, trailing_newline:) buffer << ANSI::RESET buffer << "\n" if trailing_newline - io << buffer + io << buffer if io + buffer end end @@ -642,13 +640,13 @@ class SegmentWriter class FillSegment attr_reader :char, :style - def initialize(char, style: nil) + def initialize(char, style = nil) @char = char.to_s @style = style end def with_style(style) - self.class.new(char, style: style) + FillSegment.new(char, style) end end @@ -661,8 +659,8 @@ def initialize(char) # Precompute: emoji = 2, variation selectors = 0 @width = 0 @char_count = 0 - @char.each_codepoint do |code| - w = Metrics.char_width(code) + @char.each_char do |ch| + w = Metrics.char_width(ch.ord) @width += w @char_count += 1 if w > 0 # Don't count zero-width chars end @@ -714,15 +712,27 @@ def has_wide? alias << write def write_dim(text) - write(style_segment(text, :dim) { |value| dim(value) }) + if text.is_a?(FillSegment) + write(text.with_style(:dim)) + else + write(dim(text)) + end end def write_bold(text) - write(style_segment(text, :bold) { |value| bold(value) }) + if text.is_a?(FillSegment) + write(text.with_style(:bold)) + else + write(bold(text)) + end end def write_highlight(text) - write(style_segment(text, :highlight) { |value| highlight(value) }) + if text.is_a?(FillSegment) + write(text.with_style(:highlight)) + else + write(highlight(text)) + end end def to_s(width: nil) diff --git a/try.rb b/try.rb index 375d055..c3b3c0a 100755 --- a/try.rb +++ b/try.rb @@ -2,11 +2,94 @@ require 'io/console' require 'time' -require 'fileutils' require 'set' require_relative 'lib/tui' require_relative 'lib/fuzzy' +# Spinel AOT has no FileUtils, IO#raw/#cooked/#iflush, IO.console, or Gem. +# These helpers are MRI-compatible stand-ins so the same source runs both ways. +module TryCompat + def self.mkdir_p(path) + path = File.expand_path(path.to_s) + return path if Dir.exist?(path) + stack = [] + dir = path + while dir && dir != "/" && dir != "." && !Dir.exist?(dir) + stack.unshift(dir) + parent = File.dirname(dir) + break if parent == dir + dir = parent + end + stack.each do |d| + begin + Dir.mkdir(d.to_s) + rescue Errno::EEXIST + end + end + path + end + + def self.stty_save + `stty -g 2>/dev/null`.to_s.chomp + rescue + "" + end + + def self.stty_set(state) + return if state.nil? || state.empty? + system("stty #{state} 2>/dev/null") + end + + def self.with_raw_tty + saved = nil + if STDIN.tty? + saved = stty_save + system("stty raw -echo 2>/dev/null") + end + yield + ensure + stty_set(saved) if saved && !saved.empty? + end + + def self.with_cooked_tty + saved = nil + if STDIN.tty? + saved = stty_save + system("stty cooked echo 2>/dev/null") + end + yield + ensure + stty_set(saved) if saved && !saved.empty? + end + + def self.stdin_iflush + begin + loop { STDIN.read_nonblock(4096) } + rescue IO::WaitReadable, EOFError, Errno::EAGAIN, Errno::EWOULDBLOCK, Errno::EINVAL + end + end + + def self.win_platform? + !!(RUBY_PLATFORM.to_s =~ /mswin|mingw|cygwin/i) + end + + # true when this process is a Spinel-compiled native binary (or any + # non-.rb executable), so init snippets should invoke $0 directly. + def self.compiled_binary? + name = File.basename($0.to_s) + !name.end_with?(".rb") + end + + def self.self_exec_prefix(script_path) + quoted = "'" + script_path.to_s.gsub("'", %q('"'"')) + "'" + if compiled_binary? + quoted + else + "/usr/bin/env ruby #{quoted}" + end + end +end + # Emergency restore if the process exits while the TUI alt-screen is active. $try_tui_active = false at_exit do @@ -36,7 +119,7 @@ def initialize(search_term = "", base_path: TRY_PATH, initial_input: nil, test_r ) @selected = nil @all_trials = nil # Memoized trials - @base_path = base_path + @base_path = base_path.to_s @delete_status = nil # Status message for deletions @delete_mode = false # Whether we're in deletion mode @marked_for_deletion = [] # Paths marked for deletion @@ -48,7 +131,7 @@ def initialize(search_term = "", base_path: TRY_PATH, initial_input: nil, test_r @old_winch_handler = nil # Store original SIGWINCH handler @needs_redraw = false - FileUtils.mkdir_p(@base_path) unless Dir.exist?(@base_path) + TryCompat.mkdir_p(@base_path) unless Dir.exist?(@base_path.to_s) end def run @@ -72,7 +155,7 @@ def run end main_loop else - STDERR.raw do + TryCompat.with_raw_tty do main_loop end end @@ -90,7 +173,10 @@ def setup_terminal $try_tui_active = true end - @old_winch_handler = Signal.trap('WINCH') { @needs_redraw = true } if Signal.list.key?('WINCH') + # Spinel can SIGSEGV restoring a previous WINCH handler on process exit. + unless TryCompat.compiled_binary? + @old_winch_handler = Signal.trap('WINCH') { @needs_redraw = true } if Signal.list.key?('WINCH') + end end def restore_terminal @@ -106,7 +192,7 @@ def restore_terminal Signal.trap('WINCH', @old_winch_handler) if @old_winch_handler begin - STDIN.iflush if STDIN.respond_to?(:iflush) + TryCompat.stdin_iflush rescue end end @@ -116,7 +202,7 @@ def load_all_tries @all_tries ||= begin tries = [] now = Time.now - Dir.foreach(@base_path) do |entry| + Dir.foreach(@base_path.to_s) do |entry| # exclude . and .. but also .git, and any other hidden dirs. next if entry.start_with?('.') @@ -174,13 +260,6 @@ def ctime; data[:ctime]; end def mtime; data[:mtime]; end def base_score; data[:base_score]; end - def method_missing(name, *) - data[name] - end - - def respond_to_missing?(name, include_private = false) - data.key?(name) || super - end end def get_tries @@ -193,7 +272,7 @@ def get_tries end @last_query = @search.text - height = IO.console&.winsize&.first || 24 + height = Tui::Terminal.size(STDERR)[0] || 24 max_results = [height - 6, 3].max results = [] @fuzzy.match(@search.text).limit(max_results).each do |entry, positions, score| @@ -266,13 +345,15 @@ def main_loop run_ascend_dialog(tries[@cursor_pos]) break if @selected end - when "\x03", "\e" # Ctrl-C or ESC + when "\x03", "\x1b" # Ctrl-C or ESC if @delete_mode # Exit delete mode, clear marks @marked_for_deletion.clear @delete_mode = false else - @selected = nil + # Return a Hash (not nil): Spinel can SIGSEGV if this method's + # inferred return type is Hash and we break with nil. + @selected = { type: :cancel } break end end @@ -353,30 +434,32 @@ def render(tries) width = screen.width height = screen.height - screen.header.add_line { |line| line.write << emoji("🏠") << Tui::Text.accent(" Try Directory Selection") } - screen.header.add_line { |line| line.write.write_dim(fill("─")) } - screen.header.add_line do |line| + line = screen.header.add_line + line.write.write(emoji("🏠")).write(Tui::Text.accent(" Try Directory Selection") ) + line = screen.header.add_line + line.write.write_dim(fill("─")) + line = screen.header.add_line prefix = "Search: " line.write.write_dim(prefix) - line.write << screen.input("", value: @search.text, cursor: @search.cursor).to_s + line.write.write(screen.input("", value: @search.text, cursor: @search.cursor).to_s) line.mark_has_input(Tui::Metrics.visible_width(prefix)) - end - screen.header.add_line { |line| line.write.write_dim(fill("─")) } + line = screen.header.add_line + line.write.write_dim(fill("─")) # Add footer first to get accurate line count - screen.footer.add_line { |line| line.write.write_dim(fill("─")) } + line = screen.footer.add_line + line.write.write_dim(fill("─")) if @delete_status - screen.footer.add_line { |line| line.write.write_bold(@delete_status) } + line = screen.footer.add_line + line.write.write_bold(@delete_status) @delete_status = nil elsif @delete_mode - screen.footer.add_line(background: Tui::Palette::DANGER_BG) do |line| + line = screen.footer.add_line(Tui::Palette::DANGER_BG) line.write.write_bold(" DELETE MODE ") - line.write << " #{@marked_for_deletion.length} marked | Ctrl-D: Toggle Enter: Confirm Esc: Cancel" - end + line.write.write(" #{@marked_for_deletion.length} marked | Ctrl-D: Toggle Enter: Confirm Esc: Cancel") else - screen.footer.add_line do |line| + line = screen.footer.add_line line.center.write_dim("↑/↓: Navigate Enter: Select ^R: Rename ^G: Graduate ^D: Delete Esc: Cancel") - end end # Calculate max visible from actual header/footer counts @@ -395,7 +478,7 @@ def render(tries) visible_end = [@scroll_offset + max_visible, total_items].min (@scroll_offset...visible_end).each do |idx| - if idx == tries.length && tries.any? && idx >= @scroll_offset + if idx == tries.length && !tries.empty? && idx >= @scroll_offset screen.body.add_line end @@ -418,8 +501,8 @@ def render_entry_line(screen, entry, is_selected, width) Tui::Palette::SELECTED_BG + Tui::Palette::SELECTED_FG end - line = screen.body.add_line(background: background) - line.write << (is_selected ? Tui::Text.highlight("β†’ ") + selected_foreground : " ") + line = screen.body.add_line(background) + line.write.write((is_selected ? Tui::Text.highlight("β†’ ") + selected_foreground : " ")) icon = if is_marked emoji("πŸ—‘οΈ") elsif entry.is_symlink @@ -427,7 +510,7 @@ def render_entry_line(screen, entry, is_selected, width) else emoji("πŸ“") end - line.write << icon << " " + line.write.write(icon).write(" ") plain_name, rendered_name = formatted_entry_name(entry, selected: is_selected) prefix_width = 5 @@ -441,7 +524,7 @@ def render_entry_line(screen, entry, is_selected, width) display_rendered = rendered_name end - line.write << display_rendered + line.write.write(display_rendered) # Right content is lower layer - will be overwritten by left if they overlap line.right.write(is_selected ? meta_text : Tui::Text.dim(meta_text)) @@ -451,15 +534,15 @@ def render_create_line(screen, is_selected, width) background = if is_selected Tui::Palette::SELECTED_BG + Tui::Palette::SELECTED_FG end - line = screen.body.add_line(background: background) - line.write << (is_selected ? Tui::Text.highlight("β†’ ") + selected_foreground : " ") + line = screen.body.add_line(background) + line.write.write((is_selected ? Tui::Text.highlight("β†’ ") + selected_foreground : " ")) date_prefix = Time.now.strftime("%Y-%m-%d") label = if @search.text.empty? "πŸ“‚ Create new: #{date_prefix}-" else "πŸ“‚ Create new: #{date_prefix}-#{@search.text}" end - line.write << label + line.write.write(label) end def formatted_entry_name(entry, selected: false) @@ -591,7 +674,7 @@ def run_rename_dialog(entry) else rename_error = result # Error message string end - when "\e", "\x03" # ESC or Ctrl-C - cancel + when "\x1b", "\x03" # ESC or Ctrl-C - cancel break end end @@ -602,21 +685,21 @@ def run_rename_dialog(entry) def render_rename_dialog(current_name, rename_buffer, rename_cursor, rename_error) screen = Tui::Screen.new(io: STDERR) - screen.header.add_line do |line| - line.center << emoji("✏️") << Tui::Text.accent(" Rename directory") - end - screen.header.add_line { |line| line.write.write_dim(fill("─")) } + line = screen.header.add_line + line.center.write(emoji("✏️")).write(Tui::Text.accent(" Rename directory")) + line = screen.header.add_line + line.write.write_dim(fill("─")) - screen.body.add_line do |line| - line.write << emoji("πŸ“") << " #{current_name}" - end + line = screen.body.add_line + line.write.write(emoji("πŸ“")).write(" #{current_name}") # Add empty lines, then centered input prompt - 2.times { screen.body.add_line } - screen.body.add_line do |line| + screen.body.add_line + screen.body.add_line + line = screen.body.add_line prefix = "New name: " line.center.write_dim(prefix) - line.center << screen.input("", value: rename_buffer, cursor: rename_cursor).to_s + line.center.write(screen.input("", value: rename_buffer, cursor: rename_cursor).to_s) # Input displays buffer + trailing space when cursor at end # Use (width - 1) to match Line.render's max_content calculation input_width = [rename_buffer.length, rename_cursor + 1].max @@ -624,15 +707,17 @@ def render_rename_dialog(current_name, rename_buffer, rename_cursor, rename_erro max_content = screen.width - 1 center_start = (max_content - prefix_width - input_width) / 2 line.mark_has_input(center_start + prefix_width) - end if rename_error screen.body.add_line - screen.body.add_line { |line| line.center.write_bold(rename_error) } + line = screen.body.add_line + line.center.write_bold(rename_error) end - screen.footer.add_line { |line| line.write.write_dim(fill("─")) } - screen.footer.add_line { |line| line.center.write_dim("Enter: Confirm Esc: Cancel") } + line = screen.footer.add_line + line.write.write_dim(fill("─")) + line = screen.footer.add_line + line.center.write_dim("Enter: Confirm Esc: Cancel") screen.flush end @@ -688,7 +773,7 @@ def run_ascend_dialog(entry) else ascend_error = result end - when "\e", "\x03" # ESC or Ctrl-C - cancel + when "\x1b", "\x03" # ESC or Ctrl-C - cancel break end end @@ -699,44 +784,43 @@ def run_ascend_dialog(entry) def render_ascend_dialog(current_name, ascend_buffer, ascend_cursor, ascend_error, projects_dir) screen = Tui::Screen.new(io: STDERR) - screen.header.add_line do |line| - line.center << emoji("πŸš€") << Tui::Text.accent(" Graduate try to project") - end - screen.header.add_line { |line| line.write.write_dim(fill("─")) } + line = screen.header.add_line + line.center.write(emoji("πŸš€")).write(Tui::Text.accent(" Graduate try to project")) + line = screen.header.add_line + line.write.write_dim(fill("─")) - screen.body.add_line do |line| - line.write << emoji("πŸ“") << " #{current_name}" - end + line = screen.body.add_line + line.write.write(emoji("πŸ“")).write(" #{current_name}") screen.body.add_line env_hint = TRY_PROJECTS ? "$TRY_PROJECTS" : "parent of $TRY_PATH" - screen.body.add_line do |line| + line = screen.body.add_line line.center.write_dim("Destination (#{env_hint}: #{projects_dir})") - end - screen.body.add_line do |line| + line = screen.body.add_line prefix = "Move to: " line.center.write_dim(prefix) - line.center << screen.input("", value: ascend_buffer, cursor: ascend_cursor).to_s + line.center.write(screen.input("", value: ascend_buffer, cursor: ascend_cursor).to_s) input_width = [ascend_buffer.length, ascend_cursor + 1].max prefix_width = Tui::Metrics.visible_width(prefix) max_content = screen.width - 1 center_start = (max_content - prefix_width - input_width) / 2 line.mark_has_input(center_start + prefix_width) - end screen.body.add_line - screen.body.add_line do |line| + line = screen.body.add_line line.center.write_dim("A symlink will be left in the tries directory") - end if ascend_error screen.body.add_line - screen.body.add_line { |line| line.center.write_bold(ascend_error) } + line = screen.body.add_line + line.center.write_bold(ascend_error) end - screen.footer.add_line { |line| line.write.write_dim(fill("─")) } - screen.footer.add_line { |line| line.center.write_dim("Enter: Confirm Esc: Cancel") } + line = screen.footer.add_line + line.write.write_dim(fill("─")) + line = screen.footer.add_line + line.center.write_dim("Enter: Confirm Esc: Cancel") screen.flush end @@ -786,8 +870,8 @@ def handle_create_new STDERR.print("> #{date_prefix}-") STDERR.flush - STDERR.cooked do - STDIN.iflush + TryCompat.with_cooked_tty do + TryCompat.stdin_iflush entry = STDIN.gets&.chomp.to_s end ensure @@ -855,23 +939,23 @@ def render_delete_dialog(marked_items, confirmation_buffer, confirmation_cursor) screen = Tui::Screen.new(io: STDERR) count = marked_items.length - screen.header.add_line do |line| - line.center << emoji("πŸ—‘οΈ") << Tui::Text.accent(" Delete #{count} #{count == 1 ? 'directory' : 'directories'}?") - end - screen.header.add_line { |line| line.write.write_dim(fill("─")) } + line = screen.header.add_line + line.center.write(emoji("πŸ—‘οΈ")).write(Tui::Text.accent(" Delete #{count} #{count == 1 ? 'directory' : 'directories'}?")) + line = screen.header.add_line + line.write.write_dim(fill("─")) marked_items.each do |item| - screen.body.add_line(background: Tui::Palette::DANGER_BG) do |line| - line.write << emoji("πŸ—‘οΈ") << " #{item.basename}" - end + line = screen.body.add_line(Tui::Palette::DANGER_BG) + line.write.write(emoji("πŸ—‘οΈ")).write(" #{item.basename}") end # Add empty lines, then centered confirmation prompt - 2.times { screen.body.add_line } - screen.body.add_line do |line| + screen.body.add_line + screen.body.add_line + line = screen.body.add_line prefix = "Type YES to confirm: " line.center.write_dim(prefix) - line.center << screen.input("", value: confirmation_buffer, cursor: confirmation_cursor).to_s + line.center.write(screen.input("", value: confirmation_buffer, cursor: confirmation_cursor).to_s) # Input displays buffer + trailing space when cursor at end # Use (width - 1) to match Line.render's max_content calculation input_width = [confirmation_buffer.length, confirmation_cursor + 1].max @@ -879,10 +963,11 @@ def render_delete_dialog(marked_items, confirmation_buffer, confirmation_cursor) max_content = screen.width - 1 center_start = (max_content - prefix_width - input_width) / 2 line.mark_has_input(center_start + prefix_width) - end - screen.footer.add_line { |line| line.write.write_dim(fill("─")) } - screen.footer.add_line { |line| line.center.write_dim("Enter: Confirm Esc: Cancel") } + line = screen.footer.add_line + line.write.write_dim(fill("─")) + line = screen.footer.add_line + line.center.write_dim("Enter: Confirm Esc: Cancel") screen.flush end @@ -924,7 +1009,10 @@ def process_delete_confirmation(marked_items, confirmation) end # Main execution with OptionParser subcommands -if __FILE__ == $0 +# Spinel AOT: $0 is the native binary, __FILE__ is this source path, so the +# usual `$0 == __FILE__` guard would skip the CLI. try.rb is the program +# entrypoint (tests load lib/* not this file). +if $0 == __FILE__ || TryCompat.compiled_binary? VERSION = "1.10.1" @@ -998,13 +1086,22 @@ def print_global_help # Helper to extract a "--name VALUE" or "--name=VALUE" option from args (last one wins) def extract_option_with_value!(args, opt_name) - i = args.rindex { |a| a == opt_name || a.start_with?("#{opt_name}=") } - return nil unless i - arg = args.delete_at(i) + found = -1 + i = args.length - 1 + while i >= 0 + a = args[i] + if a == opt_name || a.start_with?("#{opt_name}=") + found = i + break + end + i -= 1 + end + return nil if found < 0 + arg = args.delete_at(found) if arg.include?('=') arg.split('=', 2)[1] else - args.delete_at(i) + args.delete_at(found) end end @@ -1223,7 +1320,7 @@ def cmd_install!(args, tries_path) exit 1 end - FileUtils.mkdir_p(File.dirname(rc_path)) + TryCompat.mkdir_p(File.dirname(rc_path)) File.open(rc_path, 'a') { |f| f.write(block) } STDERR.puts "Added try shell integration to #{rc_path}" STDERR.puts "Restart your shell or run: source #{rc_path}" unless shell == 'pwsh' @@ -1242,11 +1339,11 @@ def detect_shell return 'pwsh' if ENV["PSModulePath"] && !ENV["PSModulePath"].empty? # Fallback: check parent process name - parent = (`ps c -p #{Process.ppid} -o 'ucomm='`.strip rescue nil) - return 'fish' if parent&.include?('fish') - return 'zsh' if parent&.include?('zsh') - return 'bash' if parent&.include?('bash') - return 'pwsh' if parent&.match?(/pwsh|powershell/i) + parent = (`ps c -p #{Process.ppid} -o 'ucomm='`.strip rescue "").to_s + return 'fish' if parent.include?('fish') + return 'zsh' if parent.include?('zsh') + return 'bash' if parent.include?('bash') + return 'pwsh' if parent.match?(/pwsh|powershell/i) nil end @@ -1260,7 +1357,7 @@ def shell_rc_file(shell) File.exist?(File.expand_path('~/.bashrc')) ? '~/.bashrc' : '~/.bash_profile' when 'pwsh' # PowerShell profile path from $PROFILE, or the standard location - ENV["PROFILE"] || (Gem.win_platform? ? + ENV["PROFILE"] || (TryCompat.win_platform? ? File.join(ENV["USERPROFILE"] || Dir.home, "Documents", "PowerShell", "Microsoft.PowerShell_profile.ps1") : File.join(Dir.home, ".config", "powershell", "Microsoft.PowerShell_profile.ps1")) end @@ -1272,7 +1369,7 @@ def init_snippet(shell, script_path, explicit_path, default_path) fish_path_arg = explicit_path ? " --path '#{explicit_path}'" : " --path (if set -q TRY_PATH; echo \"$TRY_PATH\"; else; echo '#{default_path}'; end)" <<~FISH function try - set -l out (/usr/bin/env ruby '#{script_path}' exec#{fish_path_arg} $argv 2>/dev/tty | string collect) + set -l out (#{TryCompat.self_exec_prefix(script_path)} exec#{fish_path_arg} $argv 2>/dev/tty | string collect) if test $pipestatus[1] -eq 0 eval $out else @@ -1290,7 +1387,7 @@ def init_snippet(shell, script_path, explicit_path, default_path) function try { $tryPath = #{ps_path_expr} $tempErr = [System.IO.Path]::GetTempFileName() - $out = & ruby '#{script_path}' exec --path $tryPath @args 2>$tempErr + $out = & #{TryCompat.compiled_binary? ? q(script_path) : "ruby '#{script_path}'"} exec --path $tryPath @args 2>$tempErr if ($LASTEXITCODE -eq 0) { $out | Invoke-Expression } else { @@ -1305,7 +1402,7 @@ def init_snippet(shell, script_path, explicit_path, default_path) <<~SH try() { local out - out=$(/usr/bin/env ruby '#{script_path}' exec#{path_arg} "$@" 2>/dev/tty) + out=$(#{TryCompat.self_exec_prefix(script_path)} exec#{path_arg} "$@" 2>/dev/tty) if [ $? -eq 0 ]; then eval "$out" else @@ -1388,6 +1485,8 @@ def cmd_cd!(args, tries_path, and_type, and_exit, and_keys, and_confirm) script_rename(result[:base_path], result[:old], result[:new]) when :ascend script_ascend(result[:source], result[:dest], result[:basename], result[:base_path]) + when :cancel + nil else script_cd(result[:path]) end @@ -1526,9 +1625,9 @@ def resolve_unique_name_with_versioning(tries_path, date_prefix, base) initial = "#{date_prefix}-#{base}" return base unless Dir.exist?(File.join(tries_path, initial)) - m = base.match(/^(.*?)(\d+)$/) - if m - stem, n = m[1], m[2].to_i + if base =~ /^(.*?)(\d+)$/ + stem = $1.to_s + n = $2.to_i candidate_num = n + 1 loop do candidate_base = "#{stem}#{candidate_num}" @@ -1545,10 +1644,11 @@ def resolve_unique_name_with_versioning(tries_path, date_prefix, base) # shell detection for init wrapper # Check $SHELL first (user's configured shell), then parent process as fallback def fish? - shell = ENV["SHELL"] - shell = `ps c -p #{Process.ppid} -o 'ucomm='`.strip rescue nil if shell.to_s.empty? - - shell&.include?('fish') + shell = ENV["SHELL"].to_s + if shell.empty? + shell = (`ps c -p #{Process.ppid} -o 'ucomm='`.strip rescue "").to_s + end + shell.include?('fish') end