diff --git a/README.md b/README.md index 7b46697..cb91d29 100644 --- a/README.md +++ b/README.md @@ -73,24 +73,6 @@ curl -sSL https://raw.githubusercontent.com/s33g/proj/main/scripts/install.sh | proj ``` -### Shell Integration (Recommended) - -To enable the "Change Directory" feature, add this to your `~/.bashrc` or `~/.zshrc`: - -```bash -# proj - TUI project navigator -proj() { - local output=$(mktemp) - PROJ_CD_FILE="$output" command ~/.local/bin/proj "$@" - if [ -s "$output" ]; then - cd "$(cat "$output")" - fi - rm -f "$output" -} -``` - -Then reload your shell: `source ~/.bashrc` - ## Usage ### Keyboard Shortcuts @@ -147,6 +129,40 @@ When you select a project, these actions are available: > See [docs/DOCKER.md](docs/DOCKER.md) for full Docker integration guide +## Shell Integration + +Shell integration allows `proj` to change your terminal's working directory when you navigate to a project. + +### Automatic Setup + +The installer will detect your shell and set up integration automatically: + +```bash +curl -sSL https://raw.githubusercontent.com/s33g/proj/main/scripts/install.sh | bash +``` + +### Supported Shells + +- **Bash** - Full support with auto-detection +- **Zsh** - Full support with auto-detection +- **Fish** - Full support with auto-detection +- **Others** - Manual setup required (see [docs](docs/INSTALL.md#shell-integration)) + +### Manual Setup + +For manual integration or unsupported shells, see: +- [Installation Guide](docs/INSTALL.md#shell-integration) - Setup instructions +- [Contributing Guide](docs/CONTRIBUTING.md#adding-shell-support) - Adding new shell support + +### How It Works + +1. The installer creates a wrapper function that replaces the `proj` command +2. When you select a project in the TUI, `proj` writes the target directory to a temporary file +3. The wrapper function reads this file and changes to that directory +4. Your shell's working directory is now in the selected project + +Without shell integration, `proj` still works perfectly - you just won't get automatic directory changes. + ## Configuration Configuration is stored in `~/.config/proj/config.json`. diff --git a/docs/CONFIG.md b/docs/CONFIG.md index cb9b32f..22ca64f 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -48,9 +48,7 @@ proj --config # Open config in $EDITOR }, "display": { "showHiddenDirs": false, - "sortBy": "lastModified", - "showGitStatus": true, - "showLanguage": true + "sortBy": "lastModified" }, "excludePatterns": [ ".git", @@ -305,36 +303,6 @@ How to sort projects in the list. } ``` -#### display.showGitStatus - -**Type:** `boolean` -**Default:** `true` - -Whether to show git branch and dirty status in the project list. - -```json -{ - "display": { - "showGitStatus": false - } -} -``` - -#### display.showLanguage - -**Type:** `boolean` -**Default:** `true` - -Whether to show detected language in the project list. - -```json -{ - "display": { - "showLanguage": false - } -} -``` - --- ### excludePatterns diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index dd799d4..3b5fd27 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -264,6 +264,108 @@ make test-coverage - Add comments to exported types and functions - Include examples where helpful +## Adding Shell Support + +proj supports shell integration to enable directory changing from the TUI. We welcome contributions for additional shell support! + +### How Shell Integration Works + +Shell integration uses a wrapper function that: +1. Runs the actual `proj` binary with user arguments +2. Checks for a temporary file at `~/.config/proj/.proj_last_dir` +3. Changes to the directory specified in that file (if it exists) +4. Cleans up the temporary file + +The Go binary writes to this file when a project is selected in the TUI. + +### Adding a New Shell + +To add support for a new shell: + +1. **Create the integration script:** + ```bash + touch scripts/shells/yourshell.ext + ``` + +2. **Implement the wrapper function** following this pattern: + ```shell + # Your shell's syntax for defining functions + proj() { + # Store original directory + original_dir=$(pwd) # or your shell's equivalent + + # Run the actual proj binary + command proj "$@" # or your shell's equivalent for passing args + + # Check for directory change file + proj_dir_file="$HOME/.config/proj/.proj_last_dir" + if [ -f "$proj_dir_file" ]; then + target_dir=$(cat "$proj_dir_file") + if [ -d "$target_dir" ] && [ "$target_dir" != "$original_dir" ]; then + echo "Changing to: $target_dir" + cd "$target_dir" + fi + rm -f "$proj_dir_file" + fi + } + ``` + +3. **Add auto-setup detection** for when the script is sourced: + ```shell + # Your shell's method for detecting if sourced vs executed + if [[ sourced_condition ]]; then + setup_function_or_direct_call + fi + ``` + +4. **Update the installer script** in `scripts/install.sh`: + - Add your shell to the case statement in `setup_shell_integration()` + - Create a `setup_yourshell_integration()` function + - Follow the pattern used by bash/zsh/fish functions + +5. **Test your integration:** + ```bash + # Source your script manually + source scripts/shells/yourshell.ext + + # Test the proj function + proj + # Navigate to a project and verify directory changes work + ``` + +6. **Add examples to documentation:** + - Add manual setup instructions to `docs/INSTALL.md` + - Include your shell in supported shells list + +### Supported Shells + +Currently supported: +- **bash** (`scripts/shells/bash.sh`) +- **zsh** (`scripts/shells/zsh.sh`) +- **fish** (`scripts/shells/fish.fish`) + +Requested shells (contributions welcome): +- PowerShell +- Nushell +- Elvish +- Oil +- Ion +- Xonsh + +### Shell Integration Examples + +See existing implementations for reference: +- [Bash integration](../scripts/shells/bash.sh) +- [Zsh integration](../scripts/shells/zsh.sh) +- [Fish integration](../scripts/shells/fish.fish) + +When contributing shell support, please ensure: +- The wrapper function preserves all `proj` arguments +- Error handling doesn't break the shell session +- The integration works in both interactive and non-interactive modes +- Clean up temporary files properly +- Follow your shell's best practices for function definitions + ## Pull Request Process 1. **Ensure all tests pass:** diff --git a/docs/INSTALL.md b/docs/INSTALL.md index b475209..dc10e4e 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -157,58 +157,7 @@ proj --set-path ~/code Replace `~/code` with your actual projects directory. -### 5. Shell Integration (Highly Recommended) - -Shell integration enables the "Change Directory" action to actually change your terminal's current directory. - -**For Bash (`~/.bashrc`):** - -```bash -# proj - TUI project navigator -proj() { - local output=$(mktemp) - PROJ_CD_FILE="$output" command ~/.local/bin/proj "$@" - if [ -s "$output" ]; then - cd "$(cat "$output")" - fi - rm -f "$output" -} -``` - -**For Zsh (`~/.zshrc`):** - -```zsh -# proj - TUI project navigator -proj() { - local output=$(mktemp) - PROJ_CD_FILE="$output" command ~/.local/bin/proj "$@" - if [ -s "$output" ]; then - cd "$(cat "$output")" - fi - rm -f "$output" -} -``` - -**For Fish (`~/.config/fish/config.fish`):** - -```fish -# proj - TUI project navigator -function proj - set -l output (mktemp) - PROJ_CD_FILE="$output" command ~/.local/bin/proj $argv - if test -s "$output" - cd (cat "$output") - end - rm -f "$output" -end -``` - -Then reload your shell: -```bash -source ~/.bashrc # or ~/.zshrc, or restart terminal -``` - -### 6. Test Everything +### 5. Test Everything ```bash # Launch the TUI @@ -298,8 +247,8 @@ chmod +x ~/.local/bin/proj Shell integration is not set up. **Solution:** -1. Add the shell function from [Step 5](#5-shell-integration-highly-recommended) -2. Reload your shell: `source ~/.bashrc` +1. See [Shell Integration](#shell-integration) section below +2. Reload your shell: `source ~/.bashrc` or start a new terminal 3. Use `proj` (the function) not `~/.local/bin/proj` (the binary) ### Old version still running @@ -374,6 +323,73 @@ cd proj make install ``` +## Shell Integration + +Shell integration allows `proj` to change your current directory when you navigate to a project. This feature is optional but highly recommended. + +### Automatic Setup + +The install script will automatically detect your shell and offer to set up integration: + +```bash +curl -sSL https://raw.githubusercontent.com/s33g/proj/main/scripts/install.sh | bash +``` + +### Manual Setup + +If automatic setup doesn't work or you prefer manual setup: + +#### Bash +```bash +# Download the integration script +curl -sSL https://raw.githubusercontent.com/s33g/proj/main/scripts/shells/bash.sh -o ~/.config/proj/bash_integration.sh + +# Add to ~/.bashrc +echo 'source ~/.config/proj/bash_integration.sh' >> ~/.bashrc + +# Reload shell +source ~/.bashrc +``` + +#### Zsh +```bash +# Download the integration script +curl -sSL https://raw.githubusercontent.com/s33g/proj/main/scripts/shells/zsh.sh -o ~/.config/proj/zsh_integration.sh + +# Add to ~/.zshrc +echo 'source ~/.config/proj/zsh_integration.sh' >> ~/.zshrc + +# Reload shell +source ~/.zshrc +``` + +#### Fish +```bash +# Download the integration script +curl -sSL https://raw.githubusercontent.com/s33g/proj/main/scripts/shells/fish.fish -o ~/.config/fish/conf.d/proj.fish + +# Integration will be active in new fish sessions +``` + +### Unsupported Shells + +For shells not listed above (PowerShell, Nushell, Elvish, etc.), you can: + +1. **Contribute support**: See [Adding Shell Support](CONTRIBUTING.md#adding-shell-support) +2. **Manual integration**: Create your own wrapper function based on the examples +3. **Use without integration**: `proj` works fine without shell integration, you just won't get automatic directory changes + +### How It Works + +Shell integration works by: +1. Creating a wrapper function that replaces the `proj` command +2. Running the actual `proj` binary with your arguments +3. Checking for a temporary file containing the target directory +4. Changing to that directory if it exists +5. Cleaning up the temporary file + +The temporary file is created at `~/.config/proj/.proj_last_dir` when you select a project in the TUI. + ## Installation Locations | Location | Description | diff --git a/go.mod b/go.mod index 0d2a943..a5a05f7 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 + github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 ) @@ -36,7 +37,6 @@ require ( github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect - github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect diff --git a/internal/app/app.go b/internal/app/app.go index 7648367..fac8dc3 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -26,6 +26,7 @@ type View int const ( ViewLoading View = iota ViewProjects + ViewGroup ViewActions ViewNewProject ViewExecuting @@ -41,7 +42,10 @@ type Model struct { view View projects []*project.Project selectedProject *project.Project + selectedGroup *project.Project // Current group being viewed + groupProjects []*project.Project // Projects within the selected group projectList views.ProjectListModel + groupList views.ProjectListModel actionMenu views.ActionMenuModel submenuStack []views.ActionMenuModel // Stack for nested submenus newProject views.NewProjectModel @@ -92,11 +96,12 @@ func New(cfg *config.Config) Model { type errMsg error type projectsLoadedMsg []*project.Project type actionCompleteMsg struct { - success bool - message string - actionLabel string - cdPath string - execCmd []string + success bool + message string + actionLabel string + cdPath string + execCmd []string + shouldReload bool // Whether to reload projects after this action } type branchesLoadedMsg []string type branchSwitchedMsg struct { @@ -153,7 +158,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.resultViewport = viewport.New(m.width-4, m.height-10) m.resultViewport.SetContent(msg.message) m.view = ViewResult - return m, nil + // If this action should reload projects (like project creation), do it + var cmd tea.Cmd + if msg.shouldReload { + cmd = m.loadProjectsAndRefreshGroup() + } + return m, cmd case branchesLoadedMsg: // Create branch list @@ -199,6 +209,11 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, m.keys.Quit): return m, tea.Quit + case key.Matches(msg, m.keys.Refresh): + // Rescan projects + m.view = ViewLoading + m.message = "Rescanning projects..." + return m, m.loadProjects() case key.Matches(msg, m.keys.Sort): // Cycle through sort options m.currentSortBy = m.nextSortBy() @@ -206,6 +221,8 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.projects = project.Sort(m.projects, m.currentSortBy) // Update project list with sorted projects m.projectList = views.NewProjectListModel(m.projects) + // Rebuild to apply expanded/collapsed state + m.projectList.RebuildList() m.updateSizes() return m, nil case key.Matches(msg, m.keys.New): @@ -215,9 +232,36 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, m.newProject.Init() case key.Matches(msg, m.keys.Enter): if m.selectedProject = m.projectList.SelectedProject(); m.selectedProject != nil { + // If this is a pure group (not a project), navigate into it + if m.selectedProject.IsGroup { + m.selectedGroup = m.selectedProject + // Find child projects for this group + m.groupProjects = m.getChildProjects(m.selectedProject.Path) + m.groupList = views.NewGroupListModel(m.groupProjects) + m.view = ViewGroup + m.updateSizes() + return m, nil + } + // Get built-in actions actions := views.DefaultActions(m.selectedProject, m.config.Actions.EnableGitOperations, m.config.Actions.EnableTestRunner) + // If this is a monorepo (project with sub-projects), add "Show child projects" action + if m.selectedProject.SubProjectCount > 0 { + childAction := views.Action{ + ID: "show_children", + Label: fmt.Sprintf("📂 Show child projects (%d)", m.selectedProject.SubProjectCount), + Desc: "View projects within this monorepo", + } + // Insert after "Open in editor" (usually index 1) + insertIdx := 1 + if len(actions) > insertIdx { + actions = append(actions[:insertIdx+1], append([]views.Action{childAction}, actions[insertIdx+1:]...)...) + } else { + actions = append([]views.Action{childAction}, actions...) + } + } + // Get plugin actions if m.pluginRegistry != nil { pluginActions := m.getPluginActions(m.selectedProject) @@ -250,6 +294,60 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, cmd } + case ViewGroup: + switch { + case key.Matches(msg, m.keys.Back): + // Back to main project list + m.view = ViewProjects + m.selectedGroup = nil + m.groupProjects = nil + return m, nil + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit + case key.Matches(msg, m.keys.Refresh): + // Rescan projects and refresh group view + m.message = "Rescanning projects..." + return m, m.loadProjectsAndRefreshGroup() + case key.Matches(msg, m.keys.New): + // Create new project inside the group + m.newProject = views.NewNewProjectModel() + m.view = ViewNewProject + m.updateSizes() + return m, m.newProject.Init() + case key.Matches(msg, m.keys.Enter): + if m.selectedProject = m.groupList.SelectedProject(); m.selectedProject != nil { + // Get built-in actions + actions := views.DefaultActions(m.selectedProject, m.config.Actions.EnableGitOperations, m.config.Actions.EnableTestRunner) + + // Get plugin actions + if m.pluginRegistry != nil { + pluginActions := m.getPluginActions(m.selectedProject) + if len(actions) > 0 { + backIdx := len(actions) - 1 + for i, a := range actions { + if a.ID == "back" { + backIdx = i + break + } + } + actions = append(actions[:backIdx], append(pluginActions, actions[backIdx:]...)...) + } else { + actions = append(actions, pluginActions...) + } + } + + m.actionMenu = views.NewActionMenuModel(m.selectedProject, actions) + m.view = ViewActions + m.updateSizes() + } + return m, nil + default: + // Pass other keys to the list for navigation + var cmd tea.Cmd + m.groupList, cmd = m.groupList.Update(msg) + return m, cmd + } + case ViewActions: switch { case key.Matches(msg, m.keys.Back): @@ -260,8 +358,12 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.submenuStack = m.submenuStack[:len(m.submenuStack)-1] m.updateSizes() } else { - // Back to project list - m.view = ViewProjects + // Back to project list (or group list if we came from there) + if m.selectedGroup != nil { + m.view = ViewGroup + } else { + m.view = ViewProjects + } m.selectedProject = nil } return m, nil @@ -277,13 +379,27 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.submenuStack = m.submenuStack[:len(m.submenuStack)-1] m.updateSizes() } else { - // Back to project list - m.view = ViewProjects + // Back to project list (or group list if we came from there) + if m.selectedGroup != nil { + m.view = ViewGroup + } else { + m.view = ViewProjects + } m.selectedProject = nil } return m, nil } + // Handle "Show child projects" for monorepos + if action.ID == "show_children" { + m.selectedGroup = m.selectedProject + m.groupProjects = m.getChildProjects(m.selectedProject.Path) + m.groupList = views.NewGroupListModel(m.groupProjects) + m.view = ViewGroup + m.updateSizes() + return m, nil + } + // Check if this is a submenu action if action.IsSubmenu { // Push current menu onto stack @@ -321,15 +437,25 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case ViewNewProject: switch { - case key.Matches(msg, m.keys.Back): - m.view = ViewProjects + case key.Matches(msg, m.keys.Back) && msg.String() != "backspace": + // Go back to the appropriate view (but not on backspace - let text input handle that) + if m.selectedGroup != nil { + m.view = ViewGroup + } else { + m.view = ViewProjects + } return m, nil case key.Matches(msg, m.keys.Enter): projectName := m.newProject.Value() if projectName != "" { m.view = ViewExecuting m.message = fmt.Sprintf("Creating project: %s...", projectName) - return m, createProject(projectName, m.config.ReposPath) + // Create in group path if we're inside a group, otherwise use repos path + basePath := m.config.ReposPath + if m.selectedGroup != nil { + basePath = m.selectedGroup.Path + } + return m, createProject(projectName, basePath) } return m, nil default: @@ -410,6 +536,10 @@ func (m Model) updateView(msg tea.Msg) (tea.Model, tea.Cmd) { if len(m.projects) > 0 { m.projectList, cmd = m.projectList.Update(msg) } + case ViewGroup: + if len(m.groupProjects) > 0 { + m.groupList, cmd = m.groupList.Update(msg) + } case ViewActions: m.actionMenu, cmd = m.actionMenu.Update(msg) case ViewNewProject: @@ -434,6 +564,9 @@ func (m *Model) updateSizes() { if len(m.projects) > 0 { m.projectList.SetSize(m.width-4, contentHeight) } + if len(m.groupProjects) > 0 { + m.groupList.SetSize(m.width-4, contentHeight) + } if m.view == ViewActions { m.actionMenu.SetSize(m.width-4, contentHeight) } @@ -455,6 +588,9 @@ func (m Model) View() string { case ViewProjects: return m.renderProjectsView() + case ViewGroup: + return m.renderGroupView() + case ViewActions: return m.renderActionsView() @@ -499,7 +635,7 @@ func (m Model) renderProjectsView() string { sortLabel := m.getSortLabel() sortInfo := tui.SubtitleStyle.Render(fmt.Sprintf("Sort: %s", sortLabel)) - help := tui.HelpStyle.Render("↑/↓: navigate • enter: select • s: sort • n: new • q: quit") + help := tui.HelpStyle.Render("↑/↓: navigate • enter: select • s: sort • n: new • r/F5: refresh • q: quit") errorMsg := "" if m.err != nil { @@ -523,6 +659,49 @@ func (m Model) renderProjectsView() string { ) } +// renderGroupView renders the group projects list view +func (m Model) renderGroupView() string { + groupName := "Group" + if m.selectedGroup != nil { + groupName = m.selectedGroup.Name + } + header := tui.TitleStyle.Render(fmt.Sprintf("📁 %s", groupName)) + + content := "" + if len(m.groupProjects) == 0 { + content = tui.SubtitleStyle.Render("No projects found in this group. Press 'n' to create one.") + } else { + content = m.groupList.View() + } + + projectCount := tui.SubtitleStyle.Render(fmt.Sprintf("%d projects", len(m.groupProjects))) + + help := tui.HelpStyle.Render("↑/↓: navigate • enter: select • n: new • r/F5: refresh • esc: back • q: quit") + + return tui.ContainerStyle.Render( + lipgloss.JoinVertical( + lipgloss.Left, + header, + projectCount, + "", + content, + "", + help, + ), + ) +} + +// getChildProjects returns child projects for a given parent path +func (m Model) getChildProjects(parentPath string) []*project.Project { + children := make([]*project.Project, 0) + for _, p := range m.projects { + if p.ParentPath == parentPath && p.Depth > 0 { + children = append(children, p) + } + } + return children +} + // renderActionsView renders the actions menu view func (m Model) renderActionsView() string { header := views.ActionHeader( @@ -681,6 +860,19 @@ func loadProjects(cfg *config.Config) tea.Cmd { } } +// loadProjects is a method wrapper for loading projects +func (m Model) loadProjects() tea.Cmd { + return loadProjects(m.config) +} + +// loadProjectsAndRefreshGroup loads projects and lets Update refresh the view +func (m *Model) loadProjectsAndRefreshGroup() tea.Cmd { + // Delegate to the existing project loading command. The model and any + // relevant views will be updated in the Update method when the + // corresponding message (e.g. projectsLoadedMsg) is received. + return loadProjects(m.config) +} + // executeAction executes an action func executeAction(actionID string, actionLabel string, actionCommand string, proj *project.Project, cfg *config.Config, registry *plugin.Registry) tea.Cmd { return func() tea.Msg { @@ -770,13 +962,16 @@ func createProject(name string, reposPath string) tea.Cmd { if err := os.MkdirAll(projectPath, 0755); err != nil { return actionCompleteMsg{ success: false, - message: fmt.Sprintf("Failed to create project: %v", err), + message: fmt.Sprintf("Failed to create project directory %s: %v", projectPath, err), + actionLabel: "Create Project", } } return actionCompleteMsg{ success: true, - message: fmt.Sprintf("Created project: %s", name), + message: fmt.Sprintf("Successfully created project: %s\nLocation: %s", name, projectPath), + actionLabel: "Create Project", + shouldReload: true, // Reload projects to show the new project } } } diff --git a/internal/config/config.go b/internal/config/config.go index ffb608f..66ffcb9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -37,8 +37,6 @@ type ThemeConfig struct { type DisplayConfig struct { ShowHiddenDirs bool `json:"showHiddenDirs" mapstructure:"showHiddenDirs"` SortBy string `json:"sortBy" mapstructure:"sortBy"` // "name" or "lastModified" - ShowGitStatus bool `json:"showGitStatus" mapstructure:"showGitStatus"` - ShowLanguage bool `json:"showLanguage" mapstructure:"showLanguage"` } // ActionsConfig holds action-related settings @@ -88,8 +86,6 @@ func DefaultConfig() *Config { Display: DisplayConfig{ ShowHiddenDirs: false, SortBy: "lastModified", - ShowGitStatus: true, - ShowLanguage: true, }, ExcludePatterns: []string{".git", "node_modules", ".DS_Store", "__pycache__", "vendor"}, Actions: ActionsConfig{ @@ -194,8 +190,6 @@ func setDefaults() { viper.SetDefault("theme.errorColor", "#FF6347") viper.SetDefault("display.showHiddenDirs", false) viper.SetDefault("display.sortBy", "lastModified") - viper.SetDefault("display.showGitStatus", true) - viper.SetDefault("display.showLanguage", true) viper.SetDefault("excludePatterns", []string{".git", "node_modules", ".DS_Store", "__pycache__", "vendor"}) viper.SetDefault("actions.enableGitOperations", true) viper.SetDefault("actions.enableTestRunner", true) @@ -209,3 +203,11 @@ func ExpandPath(path string) string { } return path } + +// IsFeatureEnabled checks if a feature is enabled in the configuration +// This implements the FeatureConfig interface for the features package +func (c *Config) IsFeatureEnabled(name string) bool { + // For now, return false as features are not yet implemented in config + // This can be extended when feature flags are added to the Config struct + return false +} diff --git a/internal/project/scanner.go b/internal/project/scanner.go index 5138784..a4d2e5d 100644 --- a/internal/project/scanner.go +++ b/internal/project/scanner.go @@ -15,15 +15,20 @@ import ( // Project represents a code project type Project struct { - Name string - Path string - Language string - GitBranch string - GitDirty bool - IsGitRepo bool - LastModified time.Time - HasDockerfile bool - HasCompose bool + Name string + Path string + ParentPath string // Path to parent group (empty if top-level) + Language string + GitBranch string + GitDirty bool + IsGitRepo bool + LastModified time.Time + HasDockerfile bool + HasCompose bool + Depth int + SubProjectCount int + IsGroup bool // True if this is a folder containing projects (not a project itself) + Expanded bool // True if group is expanded to show children } // Scanner scans directories for projects @@ -40,13 +45,15 @@ func NewScanner(cfg *config.Config) *Scanner { } } -// Scan scans a directory for projects +// Scan scans a directory for projects (1 level deep for groups) func (s *Scanner) Scan(reposPath string) ([]*Project, error) { - // Expand path expandedPath := config.ExpandPath(reposPath) + return s.scanWithGroups(expandedPath) +} - // Read directory entries - entries, err := os.ReadDir(expandedPath) +// scanWithGroups scans top level and detects groups vs projects +func (s *Scanner) scanWithGroups(basePath string) ([]*Project, error) { + entries, err := os.ReadDir(basePath) if err != nil { return nil, err } @@ -54,41 +61,135 @@ func (s *Scanner) Scan(reposPath string) ([]*Project, error) { projects := make([]*Project, 0) for _, entry := range entries { - // Skip files, only process directories if !entry.IsDir() { continue } - // Skip hidden directories unless enabled if !s.showHidden && strings.HasPrefix(entry.Name(), ".") { continue } - // Skip excluded patterns if s.isExcluded(entry.Name()) { continue } - projectPath := filepath.Join(expandedPath, entry.Name()) + dirPath := filepath.Join(basePath, entry.Name()) + isProject := isProjectRoot(dirPath) + + // Check if this directory contains projects (making it a group) + childProjects := s.findChildProjects(dirPath) + + if isProject { + // It's a project (and possibly also contains sub-projects - monorepo) + proj, err := s.scanProject(entry.Name(), dirPath, 0) + if err != nil { + continue + } + proj.SubProjectCount = len(childProjects) + proj.Expanded = true // Projects with children are expanded by default + projects = append(projects, proj) + + // Add child projects + for _, child := range childProjects { + child.ParentPath = dirPath + child.Depth = 1 + projects = append(projects, child) + } + } else if len(childProjects) > 0 { + // It's a group folder (not a project itself, but contains projects) + group := &Project{ + Name: entry.Name(), + Path: dirPath, + Depth: 0, + IsGroup: true, + SubProjectCount: len(childProjects), + Expanded: true, + } + + // Get last modified from directory + info, _ := os.Stat(dirPath) + if info != nil { + group.LastModified = info.ModTime() + } + + projects = append(projects, group) + + // Add child projects + for _, child := range childProjects { + child.ParentPath = dirPath + child.Depth = 1 + projects = append(projects, child) + } + } else { + // It's a regular directory (not a project yet, but still show it) + // This allows users to see directories they create and potentially initialize as projects + proj, err := s.scanProject(entry.Name(), dirPath, 0) + if err != nil { + continue + } + // Mark as not a real project yet (no language, no git status) + proj.Language = "" + proj.IsGitRepo = false + proj.GitBranch = "" + proj.GitDirty = false + projects = append(projects, proj) + } + // No longer skip any directories - show all + } + + return projects, nil +} + +// findChildProjects finds all directories in a directory (1 level only) +// Shows ALL directories, not just those with project indicators +func (s *Scanner) findChildProjects(dirPath string) []*Project { + entries, err := os.ReadDir(dirPath) + if err != nil { + return nil + } + + projects := make([]*Project, 0) + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + if !s.showHidden && strings.HasPrefix(entry.Name(), ".") { + continue + } - // Get project metadata - project, err := s.scanProject(entry.Name(), projectPath) + if s.isExcluded(entry.Name()) { + continue + } + + childPath := filepath.Join(dirPath, entry.Name()) + + proj, err := s.scanProject(entry.Name(), childPath, 1) if err != nil { - // Skip projects we can't read continue } - projects = append(projects, project) + // If it's not a project root, mark it as an empty directory + if !isProjectRoot(childPath) { + proj.Language = "" + proj.IsGitRepo = false + proj.GitBranch = "" + proj.GitDirty = false + } + + projects = append(projects, proj) } - return projects, nil + return projects } // scanProject scans a single project directory -func (s *Scanner) scanProject(name, path string) (*Project, error) { +func (s *Scanner) scanProject(name, path string, depth int) (*Project, error) { project := &Project{ - Name: name, - Path: path, + Name: name, + Path: path, + Depth: depth, } // Get file info for last modified time @@ -134,6 +235,39 @@ func (s *Scanner) isExcluded(name string) bool { return false } +// isProjectRoot checks if a directory appears to be a project root +// by looking for common project indicators +func isProjectRoot(path string) bool { + // Check for common project indicators + indicators := []string{ + ".git", // Git repository + "go.mod", // Go project + "package.json", // Node.js/JavaScript project + "Cargo.toml", // Rust project + "requirements.txt", // Python project + "setup.py", // Python project + "pyproject.toml", // Python project + "Gemfile", // Ruby project + "pom.xml", // Java/Maven project + "build.gradle", // Java/Gradle project + "composer.json", // PHP project + "CMakeLists.txt", // C/C++ project + "Makefile", // General project with Makefile + ".project", // Eclipse project + "README.md", // Common project file + "README", // Common project file + } + + for _, indicator := range indicators { + indicatorPath := filepath.Join(path, indicator) + if _, err := os.Stat(indicatorPath); err == nil { + return true + } + } + + return false +} + // SortBy defines sort orders for projects type SortBy string @@ -143,30 +277,91 @@ const ( SortByLanguage SortBy = "language" ) -// Sort sorts projects by the specified criteria +// Sort sorts projects by the specified criteria while preserving parent-child relationships. +// Top-level items (Depth 0) are sorted, and children stay with their parent. func Sort(projects []*Project, by SortBy) []*Project { - sorted := make([]*Project, len(projects)) - copy(sorted, projects) + // Separate top-level items and their children + type groupedProject struct { + parent *Project + children []*Project + } - switch by { - case SortByName: - sort.Slice(sorted, func(i, j int) bool { - return sorted[i].Name < sorted[j].Name - }) - case SortByLastModified: - sort.Slice(sorted, func(i, j int) bool { - return sorted[i].LastModified.After(sorted[j].LastModified) - }) - case SortByLanguage: - sort.Slice(sorted, func(i, j int) bool { - if sorted[i].Language == sorted[j].Language { - return sorted[i].Name < sorted[j].Name + groups := make([]*groupedProject, 0) + groupMap := make(map[string]*groupedProject) // path -> group + + // First pass: identify parents and standalone projects + for _, p := range projects { + if p.Depth == 0 { + g := &groupedProject{parent: p, children: make([]*Project, 0)} + groups = append(groups, g) + groupMap[p.Path] = g + } + } + + // Second pass: attach children to their parents + for _, p := range projects { + if p.Depth > 0 && p.ParentPath != "" { + if g, ok := groupMap[p.ParentPath]; ok { + g.children = append(g.children, p) + } + } + } + + // Sort the groups by their parent + sortFunc := func(i, j int) bool { + pi := groups[i].parent + pj := groups[j].parent + switch by { + case SortByName: + return pi.Name < pj.Name + case SortByLastModified: + return pi.LastModified.After(pj.LastModified) + case SortByLanguage: + // When sorting by language, place non-group projects before groups. + // Within the same type (both groups or both non-groups), sort by language, then name. + if pi.IsGroup != pj.IsGroup { + // Non-group projects come first; groups go to the end. + return !pi.IsGroup && pj.IsGroup + } + if pi.Language == pj.Language { + return pi.Name < pj.Name + } + return pi.Language < pj.Language + default: + return pi.Name < pj.Name + } + } + sort.Slice(groups, sortFunc) + + // Sort children within each group + for _, g := range groups { + sort.Slice(g.children, func(i, j int) bool { + ci := g.children[i] + cj := g.children[j] + switch by { + case SortByName: + return ci.Name < cj.Name + case SortByLastModified: + return ci.LastModified.After(cj.LastModified) + case SortByLanguage: + if ci.Language == cj.Language { + return ci.Name < cj.Name + } + return ci.Language < cj.Language + default: + return ci.Name < cj.Name } - return sorted[i].Language < sorted[j].Language }) } - return sorted + // Flatten back into a single list + result := make([]*Project, 0, len(projects)) + for _, g := range groups { + result = append(result, g.parent) + result = append(result, g.children...) + } + + return result } // Filter filters projects by a search query diff --git a/internal/project/scanner_test.go b/internal/project/scanner_test.go index 6ef6106..36e7336 100644 --- a/internal/project/scanner_test.go +++ b/internal/project/scanner_test.go @@ -64,9 +64,14 @@ func TestScanner_Scan(t *testing.T) { func TestScanner_ScanWithHidden(t *testing.T) { tmpDir := t.TempDir() - // Create visible and hidden directories - os.Mkdir(filepath.Join(tmpDir, "visible"), 0755) - os.Mkdir(filepath.Join(tmpDir, ".hidden"), 0755) + // Create visible and hidden directories with project markers + visibleDir := filepath.Join(tmpDir, "visible") + os.Mkdir(visibleDir, 0755) + os.WriteFile(filepath.Join(visibleDir, "go.mod"), []byte("module visible"), 0644) + + hiddenDir := filepath.Join(tmpDir, ".hidden") + os.Mkdir(hiddenDir, 0755) + os.WriteFile(filepath.Join(hiddenDir, "go.mod"), []byte("module hidden"), 0644) // Scanner with hidden dirs disabled cfg := config.DefaultConfig() @@ -120,9 +125,11 @@ func TestScanner_GitDetection(t *testing.T) { exec.Command("git", "-C", gitProject, "add", ".").Run() exec.Command("git", "-C", gitProject, "commit", "-m", "initial").Run() - // Create a non-git project + // Create a non-git project with a project marker nonGitProject := filepath.Join(tmpDir, "nongit") os.Mkdir(nonGitProject, 0755) + // Add a project marker (package.json) + os.WriteFile(filepath.Join(nonGitProject, "package.json"), []byte(`{"name": "nongit"}`), 0644) // Scan cfg := config.DefaultConfig() diff --git a/internal/tui/keys.go b/internal/tui/keys.go index c388b44..4c21773 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -4,15 +4,16 @@ import "github.com/charmbracelet/bubbles/key" // KeyMap defines keyboard shortcuts type KeyMap struct { - Up key.Binding - Down key.Binding - Enter key.Binding - Back key.Binding - Quit key.Binding - New key.Binding - Search key.Binding - Sort key.Binding - Help key.Binding + Up key.Binding + Down key.Binding + Enter key.Binding + Back key.Binding + Quit key.Binding + New key.Binding + Search key.Binding + Sort key.Binding + Help key.Binding + Refresh key.Binding } // DefaultKeyMap returns the default key mappings @@ -54,6 +55,10 @@ func DefaultKeyMap() KeyMap { key.WithKeys("?"), key.WithHelp("?", "help"), ), + Refresh: key.NewBinding( + key.WithKeys("f5", "r"), + key.WithHelp("F5/r", "refresh"), + ), } } diff --git a/internal/tui/views/project_list.go b/internal/tui/views/project_list.go index b3816b2..9857ad1 100644 --- a/internal/tui/views/project_list.go +++ b/internal/tui/views/project_list.go @@ -71,50 +71,88 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list p := i.Project isSelected := index == m.Index() + isGroup := p.IsGroup + hasChildren := p.SubProjectCount > 0 // Build the line var line strings.Builder - // Project name - name := p.Name + // Prefix for selection + prefix := " " if isSelected { - line.WriteString(selectedItemStyle.Render("▸ " + name)) + prefix = "▸ " + } + + // Project/group name + name := p.Name + if hasChildren { + name = fmt.Sprintf("%s (%d)", name, p.SubProjectCount) + } + + // Style based on selection and type + if isGroup { + // Pure group (folder containing projects) + groupStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFA500")).Bold(true) + if isSelected { + line.WriteString(selectedItemStyle.Render(prefix + "📁 " + name)) + } else { + line.WriteString(itemStyle.Render(prefix + groupStyle.Render("📁 " + name))) + } + } else if hasChildren { + // Monorepo (project with sub-projects) + if isSelected { + line.WriteString(selectedItemStyle.Render(prefix + "📦 " + name)) + } else { + line.WriteString(itemStyle.Render(prefix + "📦 " + name)) + } + } else if isSelected { + line.WriteString(selectedItemStyle.Render(prefix + name)) } else { - line.WriteString(itemStyle.Render(" " + name)) + line.WriteString(itemStyle.Render(prefix + name)) } - // Add padding for alignment - padding := 25 - len(p.Name) + // Calculate padding for alignment + visibleLen := len(prefix) + len(p.Name) + if hasChildren { + visibleLen += len(fmt.Sprintf(" (%d)", p.SubProjectCount)) + } + if isGroup || hasChildren { + visibleLen += 3 // icon + } + padding := 35 - visibleLen if padding < 2 { padding = 2 } line.WriteString(strings.Repeat(" ", padding)) - // Language - if p.Language != "" && p.Language != "Unknown" { - icon := language.GetIcon(p.Language) - line.WriteString(langStyle.Render(fmt.Sprintf("%s %-12s", icon, p.Language))) - } else { - line.WriteString(strings.Repeat(" ", 14)) - } - - // Git branch - if p.GitBranch != "" { - branch := fmt.Sprintf(" %s", p.GitBranch) - if len(branch) > 30 { - branch = branch[:27] + "..." + // Only show details for actual projects (not pure groups) + if !p.IsGroup { + // Language + if p.Language != "" && p.Language != "Unknown" { + icon := language.GetIcon(p.Language) + line.WriteString(langStyle.Render(fmt.Sprintf("%s %-10s", icon, p.Language))) + } else { + line.WriteString(strings.Repeat(" ", 12)) } - line.WriteString(branchStyle.Render(branch)) - if p.GitDirty { - line.WriteString(dirtyStyle.Render("*")) + + // Git branch + if p.GitBranch != "" { + branch := p.GitBranch + if len(branch) > 20 { + branch = branch[:17] + "..." + } + line.WriteString(branchStyle.Render(branch)) + if p.GitDirty { + line.WriteString(dirtyStyle.Render("*")) + } } - } - // Docker indicators - if p.HasCompose { - line.WriteString(" 🐙") - } else if p.HasDockerfile { - line.WriteString(" 🐳") + // Docker indicators + if p.HasCompose { + line.WriteString(" 🐙") + } else if p.HasDockerfile { + line.WriteString(" 🐳") + } } _, _ = fmt.Fprint(w, line.String()) @@ -122,24 +160,50 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list // ProjectListModel is the model for the project list view type ProjectListModel struct { - list list.Model - projects []*project.Project - width int - height int + list list.Model + projects []*project.Project + width int + height int + showAll bool // When true, show all projects regardless of depth (for group views) } // NewProjectListModel creates a new project list model func NewProjectListModel(projects []*project.Project) ProjectListModel { - items := make([]list.Item, len(projects)) - for i, p := range projects { - items[i] = ProjectListItem{Project: p} + // Use our custom compact delegate + delegate := itemDelegate{} + + // Initialize with reasonable default size (will be updated on WindowSizeMsg) + l := list.New([]list.Item{}, delegate, 80, 24) + l.Title = "" + l.SetShowStatusBar(false) + l.SetFilteringEnabled(true) + l.SetShowHelp(false) + l.SetShowPagination(true) + l.Styles.Title = lipgloss.NewStyle() + l.Styles.PaginationStyle = lipgloss.NewStyle().Foreground(tui.Muted) + l.Styles.HelpStyle = lipgloss.NewStyle().Foreground(tui.Muted) + + m := ProjectListModel{ + list: l, + projects: projects, + width: 80, + height: 24, + showAll: false, // Main list only shows top-level } + + // Build the visible items list + m.RebuildList() + + return m +} +// NewGroupListModel creates a project list model for showing group contents (shows all items) +func NewGroupListModel(projects []*project.Project) ProjectListModel { // Use our custom compact delegate delegate := itemDelegate{} - // Initialize with reasonable default size (will be updated on WindowSizeMsg) - l := list.New(items, delegate, 80, 24) + // Initialize with reasonable default size + l := list.New([]list.Item{}, delegate, 80, 24) l.Title = "" l.SetShowStatusBar(false) l.SetFilteringEnabled(true) @@ -149,12 +213,18 @@ func NewProjectListModel(projects []*project.Project) ProjectListModel { l.Styles.PaginationStyle = lipgloss.NewStyle().Foreground(tui.Muted) l.Styles.HelpStyle = lipgloss.NewStyle().Foreground(tui.Muted) - return ProjectListModel{ + m := ProjectListModel{ list: l, projects: projects, width: 80, height: 24, + showAll: true, // Group list shows all items } + + // Build the visible items list + m.RebuildList() + + return m } func (m ProjectListModel) Init() tea.Cmd { @@ -187,6 +257,23 @@ func (m ProjectListModel) SelectedProject() *project.Project { return item.(ProjectListItem).Project } +// RebuildList rebuilds the list items +func (m *ProjectListModel) RebuildList() { + visibleProjects := make([]*project.Project, 0) + for _, p := range m.projects { + // Show all items if showAll is true, otherwise only top-level + if m.showAll || p.Depth == 0 { + visibleProjects = append(visibleProjects, p) + } + } + + items := make([]list.Item, len(visibleProjects)) + for i, p := range visibleProjects { + items[i] = ProjectListItem{Project: p} + } + m.list.SetItems(items) +} + // ProjectList renders a simple project list func ProjectList(projects []*project.Project, cursor int) string { if len(projects) == 0 { @@ -201,10 +288,18 @@ func ProjectList(projects []*project.Project, cursor int) string { prefix = "> " } - // Project name + // Add indentation based on depth + indent := strings.Repeat(" ", p.Depth) + + // Project name with nesting indicator name := p.Name + if p.SubProjectCount > 0 { + name = name + fmt.Sprintf(" (%d)", p.SubProjectCount) + } if i == cursor { - name = tui.SelectedStyle.Render(name) + name = tui.SelectedStyle.Render(indent + name) + } else { + name = indent + name } // Language icon diff --git a/scripts/install.sh b/scripts/install.sh index fe59657..8ba3703 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -124,72 +124,138 @@ install_binary() { success "Installed to $INSTALL_DIR/proj" } +# Check PATH +check_path() { + echo "" + if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then + warn "$INSTALL_DIR is not in your PATH" + echo "Add this to your shell configuration (~/.bashrc or ~/.zshrc):" + echo " export PATH=\"$INSTALL_DIR:\$PATH\"" + else + success "$INSTALL_DIR is in your PATH" + fi +} + # Setup shell integration setup_shell_integration() { echo "" - info "Shell Integration Setup" - echo "" - echo "To enable the 'cd' command feature, add this to your shell configuration:" - echo "" - echo " # proj - TUI project navigator" - echo " proj() {" - echo " local output=\$(mktemp)" - echo " PROJ_CD_FILE=\"\$output\" command $INSTALL_DIR/proj \"\$@\"" - echo " if [ -s \"\$output\" ]; then" - echo " cd \"\$(cat \"\$output\")\"" - echo " fi" - echo " rm -f \"\$output\"" - echo " }" - echo "" - - # Detect shell - SHELL_NAME=$(basename "$SHELL") - - case "$SHELL_NAME" in + info "Setting up shell integration..." + + # Detect current shell + CURRENT_SHELL=$(basename "$SHELL" 2>/dev/null || echo "unknown") + + case "$CURRENT_SHELL" in bash) - RC_FILE="$HOME/.bashrc" + info "Detected bash shell" + setup_bash_integration ;; zsh) - RC_FILE="$HOME/.zshrc" + info "Detected zsh shell" + setup_zsh_integration + ;; + fish) + info "Detected fish shell" + setup_fish_integration ;; *) - RC_FILE="" + warn "Shell '$CURRENT_SHELL' is not directly supported" + show_manual_integration_help ;; esac +} - if [ -n "$RC_FILE" ]; then - echo "Detected shell: $SHELL_NAME" - echo "Add to: $RC_FILE" - echo "" - read -p "Would you like to add this automatically? [y/N] " -n 1 -r - echo - if [[ $REPLY =~ ^[Yy]$ ]]; then - echo "" >> "$RC_FILE" - echo "# proj - TUI project navigator" >> "$RC_FILE" - echo "proj() {" >> "$RC_FILE" - echo " local output=\$(mktemp)" >> "$RC_FILE" - echo " PROJ_CD_FILE=\"\$output\" command $INSTALL_DIR/proj \"\$@\"" >> "$RC_FILE" - echo " if [ -s \"\$output\" ]; then" >> "$RC_FILE" - echo " cd \"\$(cat \"\$output\")\"" >> "$RC_FILE" - echo " fi" >> "$RC_FILE" - echo " rm -f \"\$output\"" >> "$RC_FILE" - echo "}" >> "$RC_FILE" - success "Added to $RC_FILE" - warn "Run 'source $RC_FILE' or restart your shell to apply changes" - fi +# Setup bash integration +setup_bash_integration() { + local shell_script_url="https://raw.githubusercontent.com/${REPO}/main/scripts/shells/bash.sh" + local target_file="$HOME/.config/proj/bash_integration.sh" + + mkdir -p "$(dirname "$target_file")" + + if command -v curl >/dev/null 2>&1; then + curl -sSL "$shell_script_url" -o "$target_file" + elif command -v wget >/dev/null 2>&1; then + wget -qO "$target_file" "$shell_script_url" + else + warn "Cannot download shell integration file. Please see documentation for manual setup." + show_manual_integration_help + return fi + + success "Downloaded bash integration to $target_file" + echo "Add this line to your ~/.bashrc:" + echo " ${GREEN}source $target_file${NC}" } -# Check PATH -check_path() { - echo "" - if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then - warn "$INSTALL_DIR is not in your PATH" - echo "Add this to your shell configuration (~/.bashrc or ~/.zshrc):" - echo " export PATH=\"$INSTALL_DIR:\$PATH\"" +# Setup zsh integration +setup_zsh_integration() { + local shell_script_url="https://raw.githubusercontent.com/${REPO}/main/scripts/shells/zsh.sh" + local target_file="$HOME/.config/proj/zsh_integration.sh" + + mkdir -p "$(dirname "$target_file")" + + if command -v curl >/dev/null 2>&1; then + curl -sSL "$shell_script_url" -o "$target_file" + elif command -v wget >/dev/null 2>&1; then + wget -qO "$target_file" "$shell_script_url" else - success "$INSTALL_DIR is in your PATH" + warn "Cannot download shell integration file. Please see documentation for manual setup." + show_manual_integration_help + return fi + + success "Downloaded zsh integration to $target_file" + echo "Add this line to your ~/.zshrc:" + echo " ${GREEN}source $target_file${NC}" +} + +# Setup fish integration +setup_fish_integration() { + local shell_script_url="https://raw.githubusercontent.com/${REPO}/main/scripts/shells/fish.fish" + local target_file="$HOME/.config/fish/conf.d/proj.fish" + + mkdir -p "$(dirname "$target_file")" + + if command -v curl >/dev/null 2>&1; then + if ! curl -fsSL "$shell_script_url" -o "$target_file"; then + warn "Failed to download fish integration using curl. Please see documentation for manual setup." + show_manual_integration_help + return + fi + elif command -v wget >/dev/null 2>&1; then + if ! wget -qO "$target_file" "$shell_script_url"; then + warn "Failed to download fish integration using wget. Please see documentation for manual setup." + show_manual_integration_help + return + fi + else + warn "Cannot download shell integration file. Please see documentation for manual setup." + show_manual_integration_help + return + fi + + if [ ! -s "$target_file" ]; then + warn "Downloaded fish integration file is empty or missing. Please see documentation for manual setup." + show_manual_integration_help + return + fi + + success "Fish integration installed to $target_file" + info "Fish integration will be active in new sessions" +} + +# Show manual integration help +show_manual_integration_help() { + echo "" + echo "For manual shell integration setup, please see:" + echo " ${GREEN}https://github.com/${REPO}/blob/main/docs/INSTALL.md#shell-integration${NC}" + echo " ${GREEN}https://github.com/${REPO}/blob/main/docs/CONTRIBUTING.md#adding-shell-support${NC}" + echo "" + echo "Available shell integrations:" + echo " • bash: https://github.com/${REPO}/blob/main/scripts/shells/bash.sh" + echo " • zsh: https://github.com/${REPO}/blob/main/scripts/shells/zsh.sh" + echo " • fish: https://github.com/${REPO}/blob/main/scripts/shells/fish.fish" + echo "" + echo "To contribute support for your shell, see the contribution guide above." } # Main installation diff --git a/scripts/shells/bash.sh b/scripts/shells/bash.sh new file mode 100644 index 0000000..c6b2f1f --- /dev/null +++ b/scripts/shells/bash.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# proj shell integration for bash +# Source this file in your ~/.bashrc or ~/.bash_profile + +# Function to integrate proj with bash +setup_proj_bash_integration() { + # Create proj function that handles directory changes + proj() { + # Store the original directory + local original_dir="$(pwd)" + + # Run proj with all arguments + command proj "$@" + + # Check if a .proj_last_dir file exists (created by proj on directory change) + local proj_dir_file="$HOME/.config/proj/.proj_last_dir" + if [ -f "$proj_dir_file" ]; then + local target_dir="$(cat "$proj_dir_file")" + if [ -d "$target_dir" ] && [ "$target_dir" != "$original_dir" ]; then + echo "Changing to: $target_dir" + cd "$target_dir" + fi + # Clean up the file + rm -f "$proj_dir_file" + fi + } + + echo "proj bash integration enabled" +} + +# Auto-setup if sourced +if [ "${BASH_SOURCE[0]}" != "${0}" ]; then + setup_proj_bash_integration +fi diff --git a/scripts/shells/fish.fish b/scripts/shells/fish.fish new file mode 100644 index 0000000..deb5548 --- /dev/null +++ b/scripts/shells/fish.fish @@ -0,0 +1,33 @@ +#!/usr/bin/env fish +# proj shell integration for fish +# Source this file in your ~/.config/fish/config.fish + +# Function to integrate proj with fish +function setup_proj_fish_integration + # Create proj function that handles directory changes + function proj + # Store the original directory + set original_dir (pwd) + + # Run proj with all arguments + command proj $argv + + # Check if a .proj_last_dir file exists (created by proj on directory change) + set proj_dir_file "$HOME/.config/proj/.proj_last_dir" + if read -l target_dir < "$proj_dir_file" + if test -d "$target_dir" -a "$target_dir" != "$original_dir" + echo "Changing to: $target_dir" + cd "$target_dir" + end + # Clean up the file + rm -f "$proj_dir_file" + end + end + + echo "proj fish integration enabled" +end + +# Auto-setup if sourced +if status --is-interactive + setup_proj_fish_integration +end diff --git a/scripts/shells/zsh.sh b/scripts/shells/zsh.sh new file mode 100644 index 0000000..5bcc069 --- /dev/null +++ b/scripts/shells/zsh.sh @@ -0,0 +1,34 @@ +#!/bin/zsh +# proj shell integration for zsh +# Source this file in your ~/.zshrc + +# Function to integrate proj with zsh +setup_proj_zsh_integration() { + # Create proj function that handles directory changes + proj() { + # Store the original directory + local original_dir="$(pwd)" + + # Run proj with all arguments + command proj "$@" + + # Read .proj_last_dir (created by proj on directory change), if it exists + local proj_dir_file="$HOME/.config/proj/.proj_last_dir" + local target_dir + if target_dir=$(<"$proj_dir_file"); then + if [[ -d "$target_dir" && "$target_dir" != "$original_dir" ]]; then + echo "Changing to: $target_dir" + cd "$target_dir" + fi + # Clean up the file + rm -f "$proj_dir_file" + fi + } + + echo "proj zsh integration enabled" +} + +# Auto-setup if sourced +if [[ "${ZSH_EVAL_CONTEXT}" == *":file" ]]; then + setup_proj_zsh_integration +fi