diff --git a/README.md b/README.md index 64263b7..c6f1a9d 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,8 @@ try redis # Jump to redis experiment or create ne try new api # Start with "2025-01-21-new-api" try github.com/user/repo # Shows clone option in TUI try --clone https://github.com/user/repo # Clone directly without TUI +try --select-only # Output selected path (for shell integration) +try -s redis # Search and output path without launching shell try --help # See all options ``` @@ -148,6 +150,8 @@ try --help # See all options ## Configuration +### Environment Variables + Set `TRY_PATH` to change where experiments are stored: ```bash @@ -156,6 +160,20 @@ export TRY_PATH=~/code/sketches Default: `~/src/tries` +### Configuration File + +On first run, `try` creates a configuration file at `~/.config/try/config` where you can set: +- **Path**: Base directory for experiments +- **Shell**: Override which shell to use (instead of `$SHELL`) + +Example config: +```json +{ + "path": "/home/user/experiments", + "shell": "fish" // Optional: force a specific shell +} +``` + ## Comparison with Original | Feature | Original (Ruby) | This Fork (Go) | @@ -168,9 +186,82 @@ Default: `~/src/tries` | **Performance** | Good | Excellent | | **Binary Size** | ~20KB + Ruby | ~4-5MB standalone | | **Cross-platform** | Ruby dependent | Native binaries | -| **Shell Integration** | ✅ Via eval | ❌ Spawns subshell | +| **Shell Integration** | ✅ Via eval | ✅ Both modes | | **Dependencies** | Ruby runtime | None | +## Shell Integration + +### Why Subprocess by Default? + +`try` launches a new shell subprocess by default for several reasons: + +1. **Clean Environment** - Each experiment gets a fresh shell environment without inheriting temporary variables or functions +2. **Easy Exit** - Simply `exit` to return to your original directory and environment +3. **Project Isolation** - Different experiments can have different environment setups without conflicts +4. **Safety** - Can't accidentally modify your parent shell's state +5. **Cross-Shell Compatibility** - Works identically across bash, zsh, fish, etc. + +### Select-Only Mode (cd in current shell) + +If you prefer to `cd` in your current shell instead of launching a subprocess, use the `--select-only` (`-s`) flag with shell functions: + +#### Bash/Zsh + +Add to your `.bashrc` or `.zshrc`: + +```bash +# Function to cd to selected try directory +trycd() { + local dir + dir=$(try --select-only "$@") + if [[ -n "$dir" ]]; then + cd "$dir" + echo "Changed to: $(basename "$dir")" + fi +} +alias tc=trycd # Short alias + +# Or simpler one-liner +alias trycd='cd $(try -s)' +``` + +#### Fish + +Add to your `~/.config/fish/config.fish`: + +```fish +# Function to cd to selected try directory +function trycd + set -l dir (try --select-only $argv) + if test -n "$dir" + cd $dir + echo "Changed to: "(basename $dir) + end +end +alias tc=trycd # Short alias +``` + +#### Usage Examples + +```bash +# Using shell functions +trycd # Browse and cd to selection +trycd redis # Search for 'redis' and cd +tc neural # Short alias + +# Direct usage +cd $(try -s) # Browse and cd +cd $(try -s tensorflow) # Search and cd +``` + +### How it Works + +In select-only mode: +- The TUI interface appears on stderr (so you can see it) +- The selected path is output to stdout (so it can be captured) +- Colors are preserved using ANSI256 profile +- Works with command substitution: `$(try -s)` + ## Acknowledgements This project is a fork of [tobi/try](https://github.com/tobi/try), originally created by Tobi Lütke. The original Ruby implementation provided the excellent foundation and user experience that this Go version builds upon. @@ -195,6 +286,9 @@ A: Because you have 200 directories and can't remember if you called it `test-re **Q: Why not use `fzf`?** A: fzf is great for files. This is specifically for project directories, with time-awareness and auto-creation built in. +**Q: Why does it launch a new shell instead of just cd?** +A: Launching a subprocess provides a clean, isolated environment for each experiment. You can always use `--select-only` mode with shell functions if you prefer cd behavior. + **Q: Can I use this for real projects?** A: You can, but it's designed for experiments. Real projects deserve real names in real locations. diff --git a/go.mod b/go.mod index 5cadfff..62c8a60 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.24.2 require ( github.com/charmbracelet/bubbletea v1.3.6 github.com/charmbracelet/lipgloss v1.1.0 + github.com/muesli/termenv v0.16.0 ) require ( @@ -20,7 +21,6 @@ require ( github.com/mattn/go-runewidth v0.0.16 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sync v0.15.0 // indirect diff --git a/main.go b/main.go index 953b446..ca6c2a0 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "bufio" + "encoding/json" "fmt" "io/fs" "math" @@ -15,8 +16,22 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" ) +// Configuration constants +const ( + defaultShell = "/bin/bash" + defaultTriesDir = "src/tries" + configFileName = "config" + configDirName = ".config/try" +) + +type Config struct { + Path string `json:"path"` + Shell string `json:"shell,omitempty"` +} + type tryEntry struct { Name string Basename string @@ -103,29 +118,65 @@ var ( ) func getConfigPath() string { - home, _ := os.UserHomeDir() - return filepath.Join(home, ".config", "try", "config") + // Use os.UserConfigDir() for platform-appropriate config location: + // - Linux: ~/.config + // - macOS: ~/Library/Application Support + // - Windows: %AppData% + configHome, err := os.UserConfigDir() + if err != nil { + // Fallback to the legacy method if os.UserConfigDir() fails + home, _ := os.UserHomeDir() + return filepath.Join(home, configDirName, configFileName) + } + + // Use "try" as the app-specific directory name + return filepath.Join(configHome, "try", configFileName) } -func loadStoredPath() string { +func loadConfig() (*Config, error) { configPath := getConfigPath() data, err := os.ReadFile(configPath) - if err == nil { - return strings.TrimSpace(string(data)) + if err != nil { + if os.IsNotExist(err) { + return &Config{}, nil + } + return nil, fmt.Errorf("failed to read config file: %w", err) } - return "" + + // Try to parse as JSON first + var config Config + if err := json.Unmarshal(data, &config); err != nil { + // Might be old format (plain text path) + path := strings.TrimSpace(string(data)) + if path != "" { + fmt.Fprintf(os.Stderr, "Note: Migrating config from old format to new JSON format\n") + return &Config{Path: path}, nil + } + return &Config{}, nil + } + + return &config, nil } -func storePath(path string) error { +func saveConfig(config *Config) error { configPath := getConfigPath() configDir := filepath.Dir(configPath) // Create config directory if it doesn't exist if err := os.MkdirAll(configDir, 0755); err != nil { - return err + return fmt.Errorf("failed to create config directory: %w", err) } - return os.WriteFile(configPath, []byte(path), 0644) + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + if err := os.WriteFile(configPath, data, 0644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + return nil } func getDefaultPath() string { @@ -135,17 +186,36 @@ func getDefaultPath() string { } // Then check stored config - if basePath := loadStoredPath(); basePath != "" { - return basePath + config, err := loadConfig() + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: %v\n", err) + } + if config != nil && config.Path != "" { + return config.Path } // No default - will need to prompt return "" } +func getShell(config *Config) string { + // First check config override + if config != nil && config.Shell != "" { + return config.Shell + } + + // Fall back to SHELL environment variable + if shell := os.Getenv("SHELL"); shell != "" { + return shell + } + + // Final fallback + return defaultShell +} + func promptForPath() string { home, _ := os.UserHomeDir() - defaultPath := filepath.Join(home, "src", "tries") + defaultPath := filepath.Join(home, defaultTriesDir) fmt.Println(titleStyle.Render("🎉 Welcome to Try!")) fmt.Println() @@ -182,15 +252,47 @@ func promptForPath() string { os.Exit(1) } - // Store for future use - if err := storePath(absPath); err != nil { - fmt.Fprintf(os.Stderr, "Warning: couldn't save config: %v\n", err) + config := &Config{Path: absPath} + + // Now prompt for shell configuration + fmt.Println() + fmt.Println(promptStyle.Render("Shell Configuration (optional)")) + currentShell := os.Getenv("SHELL") + if currentShell == "" { + currentShell = defaultShell + } + fmt.Printf("Current SHELL: %s\n", dimStyle.Render(currentShell)) + fmt.Print("Override shell (press Enter to use $SHELL): ") + + shellInput, err := reader.ReadString('\n') + if err != nil { + fmt.Fprintf(os.Stderr, "\nError reading input: %v\n", err) + // Don't exit, just use default + } else { + shellInput = strings.TrimSpace(shellInput) + if shellInput != "" { + // Validate the shell exists + if _, err := exec.LookPath(shellInput); err == nil { + config.Shell = shellInput + fmt.Printf("✅ Shell set to: %s\n", createNewStyle.Render(shellInput)) + } else { + fmt.Printf("⚠️ Shell '%s' not found, using $SHELL\n", shellInput) + } + } + } + + // Store config + if err := saveConfig(config); err != nil { + fmt.Fprintf(os.Stderr, "Warning: %v\n", err) } // Show success message fmt.Println() fmt.Printf("✅ Experiments will be stored in: %s\n", createNewStyle.Render(absPath)) - fmt.Println(dimStyle.Render("(You can change this by setting TRY_PATH environment variable)")) + if config.Shell != "" { + fmt.Printf("✅ Shell override: %s\n", createNewStyle.Render(config.Shell)) + } + fmt.Printf("%s\n", dimStyle.Render(fmt.Sprintf("(You can change these settings by editing %s)", getConfigPath()))) fmt.Println() // Wait for user to acknowledge @@ -991,10 +1093,8 @@ func handleDirectClone(url string) { } // Launch a new shell - shell := os.Getenv("SHELL") - if shell == "" { - shell = "/bin/bash" - } + config, _ := loadConfig() + shell := getShell(config) fmt.Printf("\n✨ Successfully cloned and entering %s\n\n", filepath.Base(fullPath)) @@ -1015,6 +1115,7 @@ func main() { searchTerm := "" showHelp := false cloneURL := "" + selectOnly := false args := os.Args[1:] for i := 0; i < len(args); i++ { @@ -1022,6 +1123,8 @@ func main() { switch arg { case "--help", "-h", "help": showHelp = true + case "--select-only", "-s": + selectOnly = true case "--clone", "-c": // Get the next argument as the URL if i+1 < len(args) { @@ -1052,14 +1155,22 @@ func main() { searchTerm = strings.TrimSpace(searchTerm) // Check if we have a TTY - if !isatty(os.Stdin.Fd()) || !isatty(os.Stdout.Fd()) { + if !checkTTYRequirements(selectOnly) { fmt.Fprintln(os.Stderr, "Error: try requires an interactive terminal") os.Exit(1) } // Run the TUI m := initialModel(searchTerm) - p := tea.NewProgram(m, tea.WithAltScreen()) + var p *tea.Program + if selectOnly { + // Output TUI to stderr so stdout can be piped + // Force colors by setting the color profile globally + lipgloss.SetColorProfile(termenv.ANSI256) + p = tea.NewProgram(m, tea.WithAltScreen(), tea.WithOutput(os.Stderr)) + } else { + p = tea.NewProgram(m, tea.WithAltScreen()) + } finalModel, err := p.Run() if err != nil { @@ -1081,7 +1192,15 @@ func main() { // Touch the directory to update access time if err := os.Chtimes(m.selected.Path, time.Now(), time.Now()); err != nil { // Non-fatal, just log it - fmt.Fprintf(os.Stderr, "Warning: couldn't update access time: %v\n", err) + if !selectOnly { + fmt.Fprintf(os.Stderr, "Warning: couldn't update access time: %v\n", err) + } + } + + if selectOnly { + // Just output the path and exit + fmt.Println(m.selected.Path) + os.Exit(0) } // Change to the directory @@ -1091,10 +1210,8 @@ func main() { } // Launch a new shell in the selected directory - shell := os.Getenv("SHELL") - if shell == "" { - shell = "/bin/bash" - } + config, _ := loadConfig() + shell := getShell(config) fmt.Printf("\n🚀 Entering %s\n\n", filepath.Base(m.selected.Path)) @@ -1119,7 +1236,15 @@ func main() { // Touch it if err := os.Chtimes(m.selected.Path, time.Now(), time.Now()); err != nil { // Non-fatal, just log it - fmt.Fprintf(os.Stderr, "Warning: couldn't update access time: %v\n", err) + if !selectOnly { + fmt.Fprintf(os.Stderr, "Warning: couldn't update access time: %v\n", err) + } + } + + if selectOnly { + // Just output the path and exit + fmt.Println(m.selected.Path) + os.Exit(0) } // Change to it @@ -1129,10 +1254,8 @@ func main() { } // Launch a new shell - shell := os.Getenv("SHELL") - if shell == "" { - shell = "/bin/bash" - } + config, _ := loadConfig() + shell := getShell(config) fmt.Printf("\n✨ Created and entering %s\n\n", filepath.Base(m.selected.Path)) @@ -1158,6 +1281,12 @@ func main() { os.Exit(1) } + if selectOnly { + // Just output the path and exit + fmt.Println(targetPath) + os.Exit(0) + } + // Change to the directory if err := os.Chdir(targetPath); err != nil { fmt.Fprintf(os.Stderr, "Error: couldn't change directory: %v\n", err) @@ -1165,10 +1294,8 @@ func main() { } // Launch a new shell - shell := os.Getenv("SHELL") - if shell == "" { - shell = "/bin/bash" - } + config, _ := loadConfig() + shell := getShell(config) fmt.Printf("\n✨ Successfully cloned and entering %s\n\n", filepath.Base(targetPath)) @@ -1191,6 +1318,11 @@ func printHelp() { if basePath == "" { basePath = "Not configured (will prompt on first use)" } + config, _ := loadConfig() + shellInfo := "" + if config.Shell != "" { + shellInfo = fmt.Sprintf("\n Shell: %s", config.Shell) + } help := fmt.Sprintf(`📁 try - Quick Experiment Directories A beautiful TUI for managing lightweight experiment directories. @@ -1198,6 +1330,7 @@ Perfect for people with ADHD who need quick, organized workspaces. USAGE: try [search_term] Launch selector with optional search + try --select-only, -s Output selected path instead of launching shell try --clone Clone a GitHub repository try --help Show this help @@ -1219,7 +1352,7 @@ NAVIGATION: CONFIGURATION: Set TRY_PATH environment variable to change base directory - Current: %s + Current: %s%s EXAMPLES: try # Launch selector @@ -1227,10 +1360,12 @@ EXAMPLES: try new project # Search for "new project" try github.com/user/repo # Shows clone option in TUI try --clone https://github.com/user/repo # Clone directly + try -s # Select and output path + cd $(try -s) # Use with cd in current shell First launch automatically creates the base directory. Selected directories open in a new shell session. -`, basePath) +`, basePath, shellInfo) fmt.Print(help) } @@ -1248,3 +1383,13 @@ func isatty(fd uintptr) bool { } return stat.Mode()&os.ModeCharDevice != 0 } + +// checkTTYRequirements validates TTY requirements based on mode +func checkTTYRequirements(selectOnly bool) bool { + if selectOnly { + // For select-only mode, we only need stdin and stderr to be TTY (stdout goes to pipe) + return isatty(os.Stdin.Fd()) && isatty(os.Stderr.Fd()) + } + // Normal mode requires stdin and stdout to be TTY + return isatty(os.Stdin.Fd()) && isatty(os.Stdout.Fd()) +}