diff --git a/spec/command_line.md b/spec/command_line.md index 52066c2..afa8858 100644 --- a/spec/command_line.md +++ b/spec/command_line.md @@ -60,17 +60,24 @@ try [name] # URL shorthand (same as clone) - `name` (optional): Custom name suffix (default: extracted from URL) **Behavior:** -- Creates directory named `YYYY-MM-DD--` (extracted from URL) -- Clones repository into that directory +- **Default**: Creates directory named `YYYY-MM-DD--` (extracted from URL) under tries path +- **With `GH_PATH` set**: For `github.com` URLs, creates directory at `$GH_PATH//` (or `$GH_PATH//` if custom name provided). If the directory already exists, just `cd`s into it without cloning. +- Clones repository into that directory (unless it already exists when using `GH_PATH`) - Returns shell script to cd into cloned directory **Examples:** ``` try clone https://github.com/tobi/try.git -# Creates: 2025-11-30-tobi-try +# Creates: 2025-11-30-tobi-try (default behavior) + +GH_PATH=~/github try clone https://github.com/tobi/try.git +# Creates: ~/github/tobi/try (GitHub-specific path) + +GH_PATH=~/github try clone https://github.com/user/repo myproject +# Creates: ~/github/user/myproject (custom name) try clone https://github.com/user/repo myproject -# Creates: 2025-11-30-myproject (custom name overrides) +# Creates: 2025-11-30-myproject (custom name overrides, default path) try https://github.com/tobi/try.git # URL shorthand (same as first example) @@ -180,6 +187,7 @@ Commands are chained with `&& \` for readability, with 2-space indent on continu | `HOME` | Used to resolve default tries path (`$HOME/src/tries`) | | `SHELL` | Used by `init` to detect shell type | | `NO_COLOR` | If set, disables colors (equivalent to `--no-colors`) | +| `GH_PATH` | If set, GitHub repositories are cloned to `$GH_PATH//` instead of date-prefixed directories under tries path | ## Defaults diff --git a/spec/delete_spec.md b/spec/delete_spec.md index 861af7a..db417c1 100644 --- a/spec/delete_spec.md +++ b/spec/delete_spec.md @@ -57,9 +57,8 @@ In exec mode, delete outputs a shell script that is evaluated by the shell wrapp ### Script Structure ```sh -cd '/path/to/tries' && \ - [[ -d 'dir-name-1' ]] && rm -rf 'dir-name-1' && \ - [[ -d 'dir-name-2' ]] && rm -rf 'dir-name-2' && \ +[[ -d '/full/path/to/dir-name-1' ]] && rm -rf '/full/path/to/dir-name-1' && \ + [[ -d '/full/path/to/dir-name-2' ]] && rm -rf '/full/path/to/dir-name-2' && \ ( cd '/original/pwd' 2>/dev/null || cd "$HOME" ) ``` @@ -67,23 +66,18 @@ Each command is on its own line, chained with `&& \` for readability, with 2-spa ### Script Components -1. **Change to tries base directory** +1. **Per-item delete commands** ```sh - cd '/path/to/tries' && \ - ``` - All deletions happen relative to the tries base path. - -2. **Per-item delete commands** - ```sh - [[ -d 'name' ]] && rm -rf 'name' && \ + [[ -d '/full/path/to/name' ]] && rm -rf '/full/path/to/name' && \ ``` - Check directory exists before deletion - - Use basename only (not full path) + - Use absolute paths (supports both tries and GitHub sources) - Each on its own line with continuation + - Paths are validated before script generation to ensure they're within allowed roots -3. **PWD restoration** +2. **PWD restoration** ```sh - ( cd '/original/pwd' 2>/dev/null || cd "$HOME" ) + ( cd '/original/pwd' 2>/dev/null || cd "$HOME" ) ``` - Attempt to return to original working directory - Fall back to $HOME if original no longer exists @@ -101,9 +95,16 @@ For deleting two directories from `/home/user/tries`: ```sh # if you can read this, you didn't launch try from an alias. run try --help. -cd '/home/user/tries' && \ - [[ -d '2025-11-29-old-project' ]] && rm -rf '2025-11-29-old-project' && \ - [[ -d '2025-11-28-abandoned' ]] && rm -rf '2025-11-28-abandoned' && \ +[[ -d '/home/user/tries/2025-11-29-old-project' ]] && rm -rf '/home/user/tries/2025-11-29-old-project' && \ + [[ -d '/home/user/tries/2025-11-28-abandoned' ]] && rm -rf '/home/user/tries/2025-11-28-abandoned' && \ + ( cd '/home/user/code' 2>/dev/null || cd "$HOME" ) +``` + +For deleting a GitHub repository (when `GH_PATH` is set): + +```sh +# if you can read this, you didn't launch try from an alias. run try --help. +[[ -d '/home/user/github/owner/repo' ]] && rm -rf '/home/user/github/owner/repo' && \ ( cd '/home/user/code' 2>/dev/null || cd "$HOME" ) ``` @@ -111,16 +112,18 @@ cd '/home/user/tries' && \ ### Path Containment -- Deletions only happen within the tries base directory -- The `cd` to tries base ensures relative paths stay contained -- No symlink traversal outside tries directory +- Deletions only happen within allowed root directories: + - Items from tries source must be within `TRY_PATH` + - Items from GitHub source must be within `GH_PATH` (when enabled) +- Path validation occurs before script generation using `File.realpath` to resolve symlinks +- Each item is validated against its appropriate root based on its source +- No symlink traversal outside allowed directories ### PWD Handling - If shell's PWD is inside a directory being deleted: - - Script changes to tries base first - - Then performs deletion - - Attempts to restore PWD (which will fail gracefully) + - Script performs deletion using absolute paths + - Attempts to restore PWD (which will fail gracefully if PWD was deleted) - Falls back to $HOME ### Existence Check diff --git a/spec/tui_spec.md b/spec/tui_spec.md index 8919a3a..bb850ed 100644 --- a/spec/tui_spec.md +++ b/spec/tui_spec.md @@ -212,6 +212,7 @@ Tokens are preserved intact - never split a `{b}...{/b}` pair. | Enter | Select current entry | | Esc / Ctrl-C | Cancel selection | | Ctrl-D | Delete selected directory | +| Ctrl-G | Toggle source between tries and GitHub (only when `GH_PATH` is set) | ### Line Editing (in search input) | Key | Action | @@ -249,6 +250,22 @@ When query doesn't match any existing directory: - Show "[new] query-text" as first option - Selecting creates `YYYY-MM-DD-query-text` directory - New directory is created in tries base path +- **Note**: "Create new" is disabled when viewing GitHub source (Ctrl-G) + +## Source Switching + +When `GH_PATH` environment variable is set, the TUI supports switching between two sources: + +- **Tries source** (default): Lists immediate subdirectories under the tries path +- **GitHub source**: Lists repositories under `$GH_PATH//` (2-level traversal) + +**Behavior:** +- Press `Ctrl-G` to toggle between sources +- When switching sources, the list cache is cleared, cursor resets to top, and scroll resets +- In GitHub source mode: + - Entries are displayed as `owner/repo` (e.g., `toby/try`) + - "Create new" option is disabled + - Navigation and selection work the same as in tries source ## Directory Deletion diff --git a/try.rb b/try.rb index a927a7c..27056ee 100755 --- a/try.rb +++ b/try.rb @@ -178,6 +178,16 @@ def self.expand_tokens(str) class TrySelector TRY_PATH = ENV['TRY_PATH'] || File.expand_path("~/src/tries") + def gh_path_enabled? + env_val = ENV['GH_PATH'] + env_val && !env_val.strip.empty? + end + + def gh_path_root + return nil unless gh_path_enabled? + File.expand_path(ENV['GH_PATH']) + end + 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) @search_term = search_term.gsub(/\s+/, '-') @cursor_pos = 0 # Navigation cursor (list position) @@ -198,6 +208,7 @@ def initialize(search_term = "", base_path: TRY_PATH, initial_input: nil, test_r @test_confirm = test_confirm @old_winch_handler = nil # Store original SIGWINCH handler @needs_redraw = false + @source = :all # :all, :tries, or :github (when GH_PATH enabled) FileUtils.mkdir_p(@base_path) unless Dir.exist?(@base_path) end @@ -257,28 +268,72 @@ def restore_terminal def load_all_tries # Load trials only once - single pass through directory + # Clear cache when source changes + @all_tries = nil if @source_changed + @source_changed = false + @all_tries ||= begin tries = [] - Dir.foreach(@base_path) do |entry| - # exclude . and .. but also .git, and any other hidden dirs. - next if entry.start_with?('.') - - path = File.join(@base_path, entry) - stat = File.stat(path) - - # Only include directories - next unless stat.directory? - - tries << { - name: "📁 #{entry}", - basename: entry, - basename_down: entry.downcase, - path: path, - is_new: false, - ctime: stat.ctime, - mtime: stat.mtime - } + + # Load tries source (if not filtering to github only) + if @source != :github || !gh_path_enabled? + Dir.foreach(@base_path) do |entry| + # exclude . and .. but also .git, and any other hidden dirs. + next if entry.start_with?('.') + + path = File.join(@base_path, entry) + stat = File.stat(path) + + # Only include directories + next unless stat.directory? + + tries << { + name: "📁 #{entry}", + basename: entry, + basename_down: entry.downcase, + path: path, + is_new: false, + ctime: stat.ctime, + mtime: stat.mtime, + source: :tries + } + end + end + + # Load GitHub source (if GH_PATH is set and not filtering to tries only) + if gh_path_enabled? && @source != :tries + gh_root = gh_path_root + if gh_root && Dir.exist?(gh_root) + Dir.foreach(gh_root) do |owner| + next if owner.start_with?('.') + + owner_path = File.join(gh_root, owner) + next unless File.directory?(owner_path) + + Dir.foreach(owner_path) do |repo| + next if repo.start_with?('.') + + repo_path = File.join(owner_path, repo) + next unless File.directory?(repo_path) + + stat = File.stat(repo_path) + display_name = "#{owner}/#{repo}" + + tries << { + name: "📁 #{display_name}", + basename: display_name, + basename_down: display_name.downcase, + path: repo_path, + is_new: false, + ctime: stat.ctime, + mtime: stat.mtime, + source: :github + } + end + end + end end + tries end end @@ -374,7 +429,8 @@ def calculate_score(try_dir, query_down, query_chars, ctime = nil, mtime = nil) def main_loop loop do tries = get_tries - show_create_new = !@input_buffer.empty? + # Disable "Create new" when filtering to GitHub only + show_create_new = !@input_buffer.empty? && @source != :github total_items = tries.length + (show_create_new ? 1 : 0) # Ensure cursor is within bounds @@ -448,6 +504,20 @@ def main_loop @input_buffer = @input_buffer[0...new_pos] + @input_buffer[@input_cursor_pos..-1] @input_cursor_pos = new_pos end + when "\x07" # Ctrl-G - cycle filter (all ↔ tries only ↔ github only) + if gh_path_enabled? + # Cycle through: :all -> :tries -> :github -> :all + @source = case @source + when :all then :tries + when :tries then :github + when :github then :all + else :all # default to :all + end + @source_changed = true + @all_tries = nil # Clear cache + @cursor_pos = 0 # Reset cursor + @scroll_offset = 0 # Reset scroll + end when "\x04" # Ctrl-D - toggle mark for deletion if @cursor_pos < tries.length path = tries[@cursor_pos][:path] @@ -513,7 +583,16 @@ def render(tries) separator = "─" * (term_width - 1) # Header - UI.puts "{h1}📁 Try Selector{reset}" + source_indicator = if gh_path_enabled? + case @source + when :github then " {dim}(GitHub only){/fg}" + when :tries then " {dim}(Tries only){/fg}" + else " {dim}(Tries + GitHub){/fg}" + end + else + " {dim}(Tries){/fg}" + end + UI.puts "{h1}📁 Try Selector#{source_indicator}{reset}" UI.puts "{dim}#{separator}{/fg}" # Search input with cursor at correct position @@ -703,50 +782,6 @@ def format_relative_time(time) end end - def truncate_with_ansi(text, max_length) - # Simple truncation that preserves ANSI codes - visible_count = 0 - result = "" - in_ansi = false - - text.chars.each do |char| - if char == "\e" - in_ansi = true - result += char - elsif in_ansi - result += char - in_ansi = false if char == "m" - else - break if visible_count >= max_length - result += char - visible_count += 1 - end - end - - result - end - - def highlight_matches(text, query) - return text if query.empty? - - result = "" - text_lower = text.downcase - query_lower = query.downcase - query_chars = query_lower.chars - query_index = 0 - - text.chars.each_with_index do |char, i| - if query_index < query_chars.length && text_lower[i] == query_chars[query_index] - result += "{b}#{char}{/b}" # Bold yellow for matches - query_index += 1 - else - result += char - end - end - - result - end - def highlight_matches_for_selection(text, query, is_selected) return text if query.empty? @@ -784,8 +819,6 @@ def handle_create_new @selected = { type: :mkdir, path: full_path } else # No name typed, prompt for one - suggested_name = "" - UI.cls # Clear screen using UI system UI.puts "{h2}Enter new try name" UI.puts @@ -801,7 +834,7 @@ def handle_create_new end if entry.empty? - return { type: :cancel, path: nil } + return end final_name = "#{date_prefix}-#{entry}".gsub(/\s+/, '-') @@ -850,20 +883,30 @@ def confirm_batch_delete(tries) if confirmation == "YES" begin - base_real = File.realpath(@base_path) - - # Validate all paths first + # Validate all paths first - each item validates against its own root validated_paths = [] marked_items.each do |item| target_real = File.realpath(item[:path]) - unless target_real.start_with?(base_real + "/") - raise "Safety check failed: #{target_real} is not inside #{base_real}" + + # Determine the correct root path based on item source + if item[:source] == :github && gh_path_enabled? + root_real = File.realpath(gh_path_root) + unless target_real.start_with?(root_real + "/") + raise "Safety check failed: #{target_real} is not inside #{root_real}" + end + else + # Default to tries path for :tries source or when GH_PATH not enabled + base_real = File.realpath(@base_path) + unless target_real.start_with?(base_real + "/") + raise "Safety check failed: #{target_real} is not inside #{base_real}" + end end + validated_paths << { path: target_real, basename: item[:basename] } end - # Return delete action with all paths - @selected = { type: :delete, paths: validated_paths, base_path: base_real } + # Return delete action with all paths (base_path kept for compatibility but not used) + @selected = { type: :delete, paths: validated_paths, base_path: File.realpath(@base_path) } names = validated_paths.map { |p| p[:basename] }.join(", ") @delete_status = "Deleted: {strike}#{names}{/strike}" @all_tries = nil # Clear cache @@ -1001,6 +1044,16 @@ def parse_git_uri(uri) end end + def gh_path_enabled? + env_val = ENV['GH_PATH'] + env_val && !env_val.strip.empty? + end + + def gh_path_root + return nil unless gh_path_enabled? + File.expand_path(ENV['GH_PATH']) + end + def generate_clone_directory_name(git_uri, custom_name = nil) return custom_name if custom_name && !custom_name.empty? @@ -1011,6 +1064,23 @@ def generate_clone_directory_name(git_uri, custom_name = nil) "#{date_prefix}-#{parsed[:user]}-#{parsed[:repo]}" end + def resolve_clone_destination(git_uri, custom_name, tries_path) + parsed = parse_git_uri(git_uri) + return nil unless parsed + + # If GH_PATH is set and this is a github.com URL, use GH_PATH structure + if gh_path_enabled? && parsed[:host] == 'github.com' + gh_root = gh_path_root + owner = parsed[:user] + repo_name = custom_name && !custom_name.strip.empty? ? custom_name : parsed[:repo] + File.join(gh_root, owner, repo_name) + else + # Default behavior: date-prefixed directory under tries_path + dir_name = generate_clone_directory_name(git_uri, custom_name) + dir_name ? File.join(tries_path, dir_name) : nil + end + end + def is_git_uri?(arg) return false unless arg arg.match?(%r{^(https?://|git@)}) || arg.include?('github.com') || arg.include?('gitlab.com') || arg.end_with?('.git') @@ -1061,6 +1131,7 @@ def parse_test_keys(spec) when 'CTRL-E', 'CTRLE' then keys << "\x05" when 'CTRL-F', 'CTRLF' then keys << "\x06" when 'CTRL-H', 'CTRLH' then keys << "\x08" + when 'CTRL-G', 'CTRLG' then keys << "\x07" when 'CTRL-K', 'CTRLK' then keys << "\x0B" when 'CTRL-N', 'CTRLN' then keys << "\x0E" when 'CTRL-P', 'CTRLP' then keys << "\x10" @@ -1102,13 +1173,13 @@ def cmd_clone!(args, tries_path) exit 1 end - dir_name = generate_clone_directory_name(git_uri, custom_name) - unless dir_name + full_path = resolve_clone_destination(git_uri, custom_name, tries_path) + unless full_path warn "Error: Unable to parse git URI: #{git_uri}" exit 1 end - script_clone(File.join(tries_path, dir_name), git_uri) + script_clone(full_path, git_uri) end def cmd_init!(args, tries_path) @@ -1183,12 +1254,11 @@ def cmd_cd!(args, tries_path, and_type, and_exit, and_keys, and_confirm) # Git URL shorthand → clone workflow if is_git_uri?(search_term.split.first) git_uri, custom_name = search_term.split(/\s+/, 2) - dir_name = generate_clone_directory_name(git_uri, custom_name) - unless dir_name + full_path = resolve_clone_destination(git_uri, custom_name, tries_path) + unless full_path warn "Error: Unable to parse git URI: #{git_uri}" exit 1 end - full_path = File.join(tries_path, dir_name) return script_clone(full_path, git_uri) end @@ -1247,7 +1317,16 @@ def script_mkdir_cd(path) end def script_clone(path, uri) - ["mkdir -p #{q(path)}", "echo #{q(UI.expand_tokens("Using {b}git clone{/b} to create this trial from #{uri}."))}", "git clone '#{uri}' #{q(path)}"] + script_cd(path) + parsed = parse_git_uri(uri) + # If GH_PATH is enabled and this is a github.com URL, use conditional clone + if gh_path_enabled? && parsed && parsed[:host] == 'github.com' + # Check if repo already exists, clone only if needed + git_path = File.join(path, '.git') + ["mkdir -p #{q(path)}", "echo #{q(UI.expand_tokens("Using {b}git clone{/b} to create this trial from #{uri}."))}", "[ -d #{q(git_path)} ] || git clone '#{uri}' #{q(path)}"] + script_cd(path) + else + # Default behavior: always clone + ["mkdir -p #{q(path)}", "echo #{q(UI.expand_tokens("Using {b}git clone{/b} to create this trial from #{uri}."))}", "git clone '#{uri}' #{q(path)}"] + script_cd(path) + end end def script_worktree(path, repo = nil) @@ -1261,9 +1340,9 @@ def script_worktree(path, repo = nil) ["mkdir -p #{q(path)}", "echo #{q(UI.expand_tokens("Using {b}git worktree{/b} to create this trial from #{src}."))}", worktree_cmd] + script_cd(path) end - def script_delete(paths, base_path) - cmds = ["cd #{q(base_path)}"] - paths.each { |item| cmds << "[[ -d #{q(item[:basename])} ]] && rm -rf #{q(item[:basename])}" } + def script_delete(paths, _base_path) + cmds = [] + paths.each { |item| cmds << "[[ -d #{q(item[:path])} ]] && rm -rf #{q(item[:path])}" } cmds << "( cd #{q(Dir.pwd)} 2>/dev/null || cd \"$HOME\" )" cmds end